feat: v1.0.0 — UX polish, security hardening, code quality

UI/UX:
- Bill detail page tab UI (Analysis / Timeline / Votes / Notes)
- Topic tag pills on bill detail and listing pages — filtered to known
  topics, clickable, properly labelled via shared lib/topics.ts
- Notes panel always-open in Notes tab; sign-in prompt for guests
- Collapsible sidebar with icon-only mode and localStorage persistence
- Bills page defaults to has-text filter enabled
- Follow mode dropdown transparency fix
- Favicon (Landmark icon, blue background)

Security:
- Fernet encryption for ntfy passwords at rest (app/core/crypto.py)
- Separate ENCRYPTION_SECRET_KEY env var; falls back to JWT derivation
- ntfy_password no longer returned in GET response — replaced with
  ntfy_password_set: bool; NotificationSettingsUpdate type for writes
- JWT_SECRET_KEY fail-fast on startup if using default placeholder
- get_optional_user catches (JWTError, ValueError) only, not Exception

Bug fixes & code quality:
- Dashboard N+1 topic query replaced with single OR query
- notification_utils.py topic follower N+1 replaced with batch query
- Note query in bill detail page gated on token (enabled: !!token)
- search.py max_length=500 guard against oversized queries
- CollectionCreate.validate_name wired up with @field_validator
- LLM_RATE_LIMIT_RPM default raised from 10 to 50

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:10:31 -04:00
parent 4308404cca
commit 9633b4dcb8
24 changed files with 591 additions and 296 deletions

View File

@@ -1,10 +1,11 @@
"use client";
import { use, useEffect, useRef } from "react";
import { use, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react";
import { ArrowLeft, ExternalLink, FileX, Tag, User } from "lucide-react";
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { useAuthStore } from "@/stores/authStore";
import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel";
@@ -15,28 +16,34 @@ import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton";
import { CollectionPicker } from "@/components/bills/CollectionPicker";
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
const TABS = [
{ id: "analysis", label: "Analysis" },
{ id: "timeline", label: "Timeline" },
{ id: "votes", label: "Votes" },
{ id: "notes", label: "Notes" },
] as const;
type TabId = typeof TABS[number]["id"];
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const billId = decodeURIComponent(id);
const [activeTab, setActiveTab] = useState<TabId>("analysis");
const token = useAuthStore((s) => s.token);
const { data: bill, isLoading } = useBill(billId);
const { data: trendData } = useBillTrend(billId, 30);
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId);
// Fetch the user's note so we know if it's pinned before rendering
const { data: note } = useQuery({
queryKey: ["note", billId],
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
enabled: true,
enabled: !!token,
retry: false,
throwOnError: false,
});
// When the bill page is opened with no stored articles, the backend queues
// a Celery news-fetch task that takes a few seconds to complete.
// Retry up to 3 times (every 6 s) so articles appear without a manual refresh.
// newsRetryRef resets on bill navigation so each bill gets its own retry budget.
const newsRetryRef = useRef(0);
useEffect(() => { newsRetryRef.current = 0; }, [billId]);
useEffect(() => {
@@ -119,60 +126,103 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
{/* Content grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="md:col-span-2 space-y-6">
{/* Pinned note floats above briefs */}
<div className="md:col-span-2 space-y-4">
{/* Pinned note always visible above tabs */}
{note?.pinned && <NotesPanel billId={bill.bill_id} />}
{bill.briefs.length > 0 ? (
<>
<BriefPanel briefs={bill.briefs} />
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</>
) : bill.has_document ? (
<>
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p>
<p className="text-xs text-muted-foreground">
Bill text was retrieved but has not yet been analyzed. Check back shortly.
</p>
</div>
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</>
) : (
<>
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
<div className="flex items-center gap-2 text-muted-foreground">
<FileX className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium">No bill text published</span>
</div>
<p className="text-sm text-muted-foreground">
As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "}
no official text has been received for{" "}
<span className="font-medium">{billLabel(bill.bill_type, bill.bill_number)}</span>.
Analysis will be generated automatically once text is published on Congress.gov.
</p>
{bill.congress_url && (
<a
href={bill.congress_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Check status on Congress.gov <ExternalLink className="w-3 h-3" />
</a>
{/* Tab bar */}
<div className="flex gap-0 border-b border-border">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
</div>
{!note?.pinned && <NotesPanel billId={bill.bill_id} />}
</>
>
{tab.label}
</button>
))}
</div>
{/* Topic tags — only show tags that have a matching topic page */}
{bill.briefs[0]?.topic_tags && bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{bill.briefs[0].topic_tags.filter((t) => TOPIC_TAGS.has(t)).map((tag) => (
<Link
key={tag}
href={`/bills?topic=${encodeURIComponent(tag)}`}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
>
<Tag className="w-2.5 h-2.5" />
{TOPIC_LABEL[tag] ?? tag}
</Link>
))}
</div>
)}
{/* Tab content */}
{activeTab === "analysis" && (
<div className="space-y-6">
{bill.briefs.length > 0 ? (
<>
<BriefPanel briefs={bill.briefs} />
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} />
</>
) : bill.has_document ? (
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2">
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p>
<p className="text-xs text-muted-foreground">
Bill text was retrieved but has not yet been analyzed. Check back shortly.
</p>
</div>
) : (
<div className="bg-card border border-border rounded-lg p-6 space-y-3">
<div className="flex items-center gap-2 text-muted-foreground">
<FileX className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium">No bill text published</span>
</div>
<p className="text-sm text-muted-foreground">
As of {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })},{" "}
no official text has been received for{" "}
<span className="font-medium">{billLabel(bill.bill_type, bill.bill_number)}</span>.
Analysis will be generated automatically once text is published on Congress.gov.
</p>
{bill.congress_url && (
<a
href={bill.congress_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Check status on Congress.gov <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
</div>
)}
{activeTab === "timeline" && (
<ActionTimeline
actions={bill.actions}
latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text}
/>
)}
{activeTab === "votes" && (
<VotePanel billId={bill.bill_id} alwaysRender />
)}
{activeTab === "notes" && (
<NotesPanel billId={bill.bill_id} />
)}
<ActionTimeline
actions={bill.actions}
latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text}
/>
<VotePanel billId={bill.bill_id} />
</div>
<div className="space-y-4">
<TrendChart data={trendData} />
<NewsPanel articles={newsArticles} />

View File

@@ -5,21 +5,16 @@ import { useSearchParams } from "next/navigation";
import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard";
import { TOPICS } from "@/lib/topics";
const CHAMBERS = ["", "House", "Senate"];
const TOPICS = [
"", "healthcare", "taxation", "defense", "education", "immigration",
"environment", "housing", "infrastructure", "technology", "agriculture",
"judiciary", "foreign-policy", "veterans", "social-security", "trade",
"budget", "energy", "banking", "transportation", "labor",
];
export default function BillsPage() {
const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
const [hasText, setHasText] = useState(false);
const [hasText, setHasText] = useState(true);
const [page, setPage] = useState(1);
// Sync URL params → state so tag/topic links work when already on this page
@@ -75,7 +70,7 @@ export default function BillsPage() {
className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
>
<option value="">All Topics</option>
{TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)}
{TOPICS.map((t) => <option key={t.tag} value={t.tag}>{t.label}</option>)}
</select>
<button
onClick={() => { setHasText((v) => !v); setPage(1); }}

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import {
BarChart2,
Bell,
Bookmark,
Calendar,
@@ -8,9 +9,15 @@ import {
Filter,
Heart,
HelpCircle,
ListChecks,
Mail,
MessageSquare,
Rss,
Shield,
Share2,
StickyNote,
TrendingUp,
Users,
Zap,
} from "lucide-react";
@@ -63,11 +70,16 @@ export default function HowItWorksPage() {
{/* Jump links */}
<div className="flex flex-wrap gap-2 mt-3">
{[
{ href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" },
{ href: "#bills", label: "Bills" },
{ href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" },
{ href: "#votes", label: "Votes" },
{ href: "#alignment", label: "Alignment" },
{ href: "#notes", label: "Notes" },
{ href: "#bills", label: "Bills" },
{ href: "#members-topics", label: "Members & Topics" },
{ href: "#dashboard", label: "Dashboard" },
].map(({ href, label }) => (
<a
key={href}
@@ -143,7 +155,7 @@ export default function HowItWorksPage() {
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
Every collection has a unique share link. Anyone with the link can view the collection
no account required. The link works whether the collection is public or private.
no account required.
</Item>
</div>
<p className="text-xs text-muted-foreground">
@@ -156,37 +168,40 @@ export default function HowItWorksPage() {
{/* Notifications */}
<Section id="notifications" title="Notifications" icon={Bell}>
<p className="text-sm text-muted-foreground">
PocketVeto delivers alerts through two independent channels use either or both.
PocketVeto delivers alerts through three independent channels use any combination.
</p>
<div className="space-y-3">
<Item icon={Bell} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Push via ntfy">
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
ntfy
</a>
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a>
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
and receive real-time alerts on any device with the ntfy app.
</Item>
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
Pause push notifications during set hours (e.g. 10 PM 8 AM). Events that arrive
during quiet hours are queued and sent as a batch when the window ends.
</Item>
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
Instead of one push per event, receive a single bundled summary on a daily or weekly
schedule. Your RSS feed is always real-time regardless of this setting.
<Item icon={Mail} color="bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400" title="Email">
Receive alerts as plain-text emails. Add your address in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications Email</Link>.
Every email includes a one-click unsubscribe link, and your address is never used for
anything other than bill alerts.
</Item>
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
(Feedly, NetNewsWire, etc.). Always real-time, completely independent of the other channels.
</Item>
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
Pause push and email notifications during set hours (e.g. 10 PM 8 AM). Events that
arrive during quiet hours are queued and sent as a batch when the window ends.
</Item>
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
Instead of one alert per event, receive a single bundled summary on a daily or weekly
schedule. Your RSS feed is always real-time regardless of this setting.
</Item>
<Item icon={MessageSquare} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Discovery alerts">
Member and topic follows generate Discovery alerts separate from the bills you follow
directly. In{" "}
<Link href="/notifications" className="text-primary hover:underline">Alert Filters Discovery</Link>,
you can enable or disable these independently, tune which event types trigger them, and
mute specific members or topics you&apos;d rather not hear about without unfollowing them.
Each notification also shows a &ldquo;why&rdquo; line so you always know which follow
triggered it.
mute specific members or topics without unfollowing them. Each notification includes a
&ldquo;why&rdquo; line so you always know which follow triggered it.
</Item>
</div>
</Section>
@@ -194,29 +209,86 @@ export default function HowItWorksPage() {
{/* AI Briefs */}
<Section id="briefs" title="AI Briefs" icon={FileText}>
<p className="text-sm text-muted-foreground">
For bills with published official text, PocketVeto generates a plain-English AI brief.
For bills with published official text, PocketVeto generates a plain-English AI brief
automatically no action needed on your part.
</p>
<div className="space-y-3">
<Item icon={FileText} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="What's in a brief">
A plain-English summary, key policy points with references to specific bill sections
(§ chips), and a risks section that flags potential unintended consequences or contested
provisions.
(§ chips you can expand to see the quoted source text), and a risks section that flags
potential unintended consequences or contested provisions.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
Click the share icon in the brief panel to copy a public link. Anyone can read the
brief at that URL no login required.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
Use the Draft Letter panel (below the brief) to generate a personalised letter to your
representative based on the brief&apos;s key points.
Use the Draft Letter panel in the Analysis tab to generate a personalised letter to
your representative based on the brief&apos;s key points.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Briefs are only generated for bills where GovInfo has published official text. Bills
without text show a &ldquo;No text&rdquo; badge on their card.
without text show a &ldquo;No text&rdquo; badge on their card. When a bill is amended,
a new &ldquo;What Changed&rdquo; brief is generated automatically alongside the original.
</p>
</Section>
{/* Votes */}
<Section id="votes" title="Roll-call votes" icon={ListChecks}>
<p className="text-sm text-muted-foreground">
The <strong>Votes</strong> tab on any bill page shows every recorded roll-call vote for
that bill, fetched directly from official House and Senate XML sources.
</p>
<div className="space-y-3">
<Item icon={ListChecks} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Vote breakdown">
Each vote shows the result, chamber, roll number, date, and a visual Yea/Nay bar with
exact counts.
</Item>
<Item icon={Users} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Followed member positions">
If any of your followed members voted on the bill, their individual Yea/Nay positions
are surfaced directly in the vote row no need to dig through the full member list.
</Item>
</div>
</Section>
{/* Alignment */}
<Section id="alignment" title="Representation Alignment" icon={BarChart2}>
<p className="text-sm text-muted-foreground">
The <Link href="/alignment" className="text-primary hover:underline">Alignment</Link> page
shows how often your followed members vote in line with your stated bill positions.
</p>
<div className="space-y-3">
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="How it's calculated">
For every bill you follow with Pocket Boost or Pocket Veto, PocketVeto checks how each
of your followed members voted. A Yea on a boosted bill counts as aligned; a Nay on a
vetoed bill counts as aligned. Not Voting and Present are excluded.
</Item>
<Item icon={BarChart2} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Alignment score">
Each followed member gets an alignment percentage based on all overlapping votes. Members
are ranked from most to least aligned with your positions.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Alignment only appears for members who have actually voted on bills you&apos;ve stanced.
Follow more members and stake positions on more bills to build a fuller picture.
</p>
</Section>
{/* Notes */}
<Section id="notes" title="Notes" icon={StickyNote}>
<p className="text-sm text-muted-foreground">
Add a personal note to any bill visible only to you. Find it in the{" "}
<strong>Notes</strong> tab on any bill detail page.
</p>
<div className="space-y-3">
<Item icon={StickyNote} color="bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" title="Pinning">
Pin a note to float it above the tab bar so it&apos;s always visible when you open the
bill, regardless of which tab you&apos;re on.
</Item>
</div>
</Section>
{/* Bills */}
<Section id="bills" title="Browsing bills" icon={FileText}>
<p className="text-sm text-muted-foreground">
@@ -227,13 +299,55 @@ export default function HowItWorksPage() {
<p><strong className="text-foreground">Search</strong> matches bill ID, title, and short title.</p>
<p><strong className="text-foreground">Chamber</strong> House or Senate.</p>
<p><strong className="text-foreground">Topic</strong> AI-tagged policy area (healthcare, defense, etc.).</p>
<p><strong className="text-foreground">Has text</strong> show only bills with published official text available for AI briefing.</p>
<p><strong className="text-foreground">Has text</strong> show only bills with published official text. On by default.</p>
</div>
<p className="text-xs text-muted-foreground">
Clicking a topic tag on any bill or following page takes you directly to that filtered
view on the Bills page.
Each bill page is organised into four tabs: <strong>Analysis</strong> (AI brief + draft
letter), <strong>Timeline</strong> (action history), <strong>Votes</strong> (roll-call
records), and <strong>Notes</strong> (your personal note).
Topic tags appear just below the tab bar click any tag to jump to that filtered view.
</p>
</Section>
{/* Members & Topics */}
<Section id="members-topics" title="Members & Topics" icon={Users}>
<p className="text-sm text-muted-foreground">
Browse and follow legislators and policy topics independently of specific bills.
</p>
<div className="space-y-3">
<Item icon={Users} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Members">
The <Link href="/members" className="text-primary hover:underline">Members</Link> page
lists all current members of Congress. Each member page shows their sponsored bills,
news coverage, voting trend, and once enough votes are recorded
an <strong>effectiveness score</strong> ranking how often their sponsored bills advance.
</Item>
<Item icon={Filter} color="bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400" title="Topics">
The <Link href="/topics" className="text-primary hover:underline">Topics</Link> page
lists all AI-tagged policy areas. Following a topic sends you a Discovery alert whenever
a new bill is tagged with it useful for staying on top of a policy area without
tracking individual bills.
</Item>
</div>
</Section>
{/* Dashboard */}
<Section id="dashboard" title="Dashboard" icon={TrendingUp}>
<p className="text-sm text-muted-foreground">
The <Link href="/" className="text-primary hover:underline">Dashboard</Link> is your
personalised home view, split into two areas.
</p>
<div className="space-y-3">
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Your feed">
Bills from your follows directly followed bills, bills sponsored by followed members,
and bills matching followed topics sorted by latest activity.
</Item>
<Item icon={TrendingUp} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Trending">
The top 10 bills by composite trend score, calculated nightly from news article volume
(NewsAPI + Google News) and Google Trends interest. A bill climbing here is getting real
public attention regardless of whether you follow it.
</Item>
</div>
</Section>
</div>
);
}

11
frontend/app/icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1e40af"/>
<g stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none">
<line x1="4" y1="28" x2="28" y2="28"/>
<line x1="8" y1="24" x2="8" y2="15"/>
<line x1="13" y1="24" x2="13" y2="15"/>
<line x1="19" y1="24" x2="19" y2="15"/>
<line x1="24" y1="24" x2="24" y2="15"/>
<polygon points="16,5 27,13 5,13"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@@ -297,7 +297,7 @@ export default function NotificationsPage() {
setAuthMethod(settings.ntfy_auth_method ?? "none");
setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? "");
setPassword(""); // never pre-fill — password_set bool shows whether one is stored
setEmailAddress(settings.email_address ?? "");
setEmailEnabled(settings.email_enabled ?? false);
setDigestEnabled(settings.digest_enabled ?? false);
@@ -333,7 +333,7 @@ export default function NotificationsPage() {
ntfy_auth_method: authMethod,
ntfy_token: authMethod === "token" ? token : "",
ntfy_username: authMethod === "basic" ? username : "",
ntfy_password: authMethod === "basic" ? password : "",
ntfy_password: authMethod === "basic" ? (password || undefined) : "",
ntfy_enabled: enabled,
},
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
@@ -565,7 +565,9 @@ export default function NotificationsPage() {
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password</label>
<input type="password" placeholder="your-password" value={password}
<input type="password"
placeholder={settings?.ntfy_password_set && !password ? "••••••• (saved — leave blank to keep)" : "your-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary" />
</div>

View File

@@ -3,29 +3,7 @@
import Link from "next/link";
import { Tags } from "lucide-react";
import { FollowButton } from "@/components/shared/FollowButton";
const TOPICS = [
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
];
import { TOPICS } from "@/lib/topics";
export default function TopicsPage() {
return (

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink, Tag } from "lucide-react";
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink } from "lucide-react";
import { BriefSchema, CitedPoint } from "@/lib/types";
import { formatDate } from "@/lib/utils";
@@ -30,28 +30,32 @@ function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey
<li className="text-sm">
<div className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex-1">{cited ? point.text : point}</span>
{cited && point.label === "inference" && (
<span
title="This point is an analytical interpretation, not a literal statement from the bill text"
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none"
>
Inferred
</span>
)}
{cited && (
<button
onClick={() => setActiveKey(isOpen ? null : openKey)}
title={isOpen ? "Hide source" : "View source"}
className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-mono transition-colors ${
isOpen
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
§ {point.citation}
</button>
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start gap-2">
<span className="flex-1">{cited ? point.text : point}</span>
{cited && point.label === "inference" && (
<span
title="This point is an analytical interpretation, not a literal statement from the bill text"
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none mt-0.5"
>
Inferred
</span>
)}
</div>
{cited && (
<button
onClick={() => setActiveKey(isOpen ? null : openKey)}
title={isOpen ? "Hide source" : "View source"}
className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
isOpen
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
§ {point.citation}
</button>
)}
</div>
</div>
{cited && isOpen && (
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2">
@@ -165,19 +169,6 @@ export function AIBriefCard({ brief }: AIBriefCardProps) {
</div>
)}
{brief.topic_tags && brief.topic_tags.length > 0 && (
<div className="flex items-center gap-2 pt-1 border-t border-border flex-wrap">
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
{brief.topic_tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-1 bg-accent text-accent-foreground rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { StickyNote, Pin, PinOff, Trash2, ChevronDown, ChevronUp, Save } from "lucide-react";
import { Pin, PinOff, Trash2, Save } from "lucide-react";
import { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
@@ -23,7 +23,6 @@ export function NotesPanel({ billId }: NotesPanelProps) {
throwOnError: false,
});
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false);
@@ -43,7 +42,7 @@ export function NotesPanel({ billId }: NotesPanelProps) {
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [content, open]);
}, [content]);
const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned),
@@ -60,12 +59,14 @@ export function NotesPanel({ billId }: NotesPanelProps) {
qc.removeQueries({ queryKey });
setContent("");
setPinned(false);
setOpen(false);
},
});
// Don't render for guests
if (!token) return null;
if (!token) return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">Sign in to add private notes.</p>
</div>
);
if (isLoading) return null;
const hasNote = !!note;
@@ -74,78 +75,56 @@ export function NotesPanel({ billId }: NotesPanelProps) {
: content.trim().length > 0;
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header / toggle */}
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-accent transition-colors"
>
<span className="flex items-center gap-2 font-medium">
<StickyNote className="w-4 h-4 text-muted-foreground" />
My Note
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin above tabs"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<span className="flex items-center gap-1 text-xs text-muted-foreground font-normal">
{note.pinned && <Pin className="w-3 h-3" />}
{new Date(note.updated_at).toLocaleDateString()}
</span>
)}
</span>
{open ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 pb-4 space-y-3 border-t border-border pt-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin to top of page"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<button
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
)}
</div>
)}
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div>
);
}

View File

@@ -9,9 +9,10 @@ import type { BillVote, MemberVotePosition } from "@/lib/types";
interface VotePanelProps {
billId: string;
alwaysRender?: boolean;
}
export function VotePanel({ billId }: VotePanelProps) {
export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
const [expanded, setExpanded] = useState(true);
const { data: votes, isLoading } = useQuery({
@@ -33,7 +34,16 @@ export function VotePanel({ billId }: VotePanelProps) {
.map((f) => f.follow_value)
);
if (isLoading || !votes || votes.length === 0) return null;
if (isLoading || !votes || votes.length === 0) {
if (!alwaysRender) return null;
return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">
{isLoading ? "Checking for roll-call votes…" : "No roll-call votes have been recorded for this bill."}
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">

View File

@@ -3,6 +3,7 @@ import { TrendingUp, Calendar, User, FileText, FileClock, FileX, Tag } from "luc
import { Bill } from "@/lib/types";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton";
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
interface BillCardProps {
bill: Bill;
@@ -12,7 +13,7 @@ interface BillCardProps {
export function BillCard({ bill, compact = false }: BillCardProps) {
const label = billLabel(bill.bill_type, bill.bill_number);
const score = bill.latest_trend?.composite_score;
const tags = bill.latest_brief?.topic_tags?.slice(0, 3) || [];
const tags = (bill.latest_brief?.topic_tags || []).filter((t) => TOPIC_TAGS.has(t)).slice(0, 3);
return (
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
@@ -35,7 +36,7 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
>
<Tag className="w-2.5 h-2.5" />
{tag}
{TOPIC_LABEL[tag] ?? tag}
</Link>
))}
</div>

View File

@@ -153,7 +153,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
</button>
{open && (
<div className="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-md shadow-lg z-50 py-1">
<div className="absolute right-0 mt-1 w-64 bg-card border border-border rounded-md shadow-lg z-50 py-1">
{otherModes.map((mode) => {
const { label: optLabel, icon: OptIcon } = MODES[mode];
return (
@@ -161,7 +161,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
key={mode}
onClick={() => switchMode(mode)}
title={modeDescriptions[mode]}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex flex-col gap-0.5"
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-card-foreground transition-colors flex flex-col gap-0.5"
>
<span className="flex items-center gap-1.5 font-medium">
<OptIcon className="w-3.5 h-3.5" />
@@ -174,7 +174,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
<div className="border-t border-border mt-1 pt-1">
<button
onClick={handleUnfollow}
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-destructive transition-colors"
>
Unfollow
</button>

View File

@@ -1,9 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Bookmark,
ChevronLeft,
ChevronRight,
HelpCircle,
LayoutDashboard,
FileText,
@@ -42,6 +45,23 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout);
const [collapsed, setCollapsed] = useState(false);
// Mobile drawer always shows full sidebar
const isMobile = !!onClose;
const isCollapsed = collapsed && !isMobile;
useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed");
if (saved === "true") setCollapsed(true);
}, []);
function toggleCollapsed() {
setCollapsed((v) => {
localStorage.setItem("sidebar-collapsed", String(!v));
return !v;
});
}
function handleLogout() {
logout();
@@ -50,18 +70,38 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
}
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col">
<div className="p-4 border-b border-border flex items-center gap-2">
<Landmark className="w-5 h-5 text-primary" />
<span className="font-semibold text-sm flex-1">PocketVeto</span>
{onClose && (
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent transition-colors" aria-label="Close menu">
<X className="w-4 h-4" />
</button>
<aside
className={cn(
"shrink-0 border-r border-border bg-card flex flex-col transition-all duration-200",
isCollapsed ? "w-14" : "w-56"
)}
>
{/* Header */}
<div
className={cn(
"h-14 border-b border-border flex items-center gap-2 px-4",
isCollapsed && "justify-center px-0"
)}
>
<Landmark className="w-5 h-5 text-primary shrink-0" />
{!isCollapsed && (
<>
<span className="font-semibold text-sm flex-1">PocketVeto</span>
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-accent transition-colors"
aria-label="Close menu"
>
<X className="w-4 h-4" />
</button>
)}
</>
)}
</div>
<nav className="flex-1 p-3 space-y-1">
{/* Nav */}
<nav className="flex-1 p-2 space-y-0.5">
{NAV.filter(({ adminOnly, requiresAuth }) => {
if (adminOnly && !user?.is_admin) return false;
if (requiresAuth && !token) return false;
@@ -73,52 +113,75 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
key={href}
href={href}
onClick={onClose}
title={isCollapsed ? label : undefined}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
isCollapsed && "justify-center px-0",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{label}
{!isCollapsed && label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-border space-y-2">
{/* Footer */}
<div className={cn("p-3 border-t border-border space-y-2", isCollapsed && "p-2")}>
{token ? (
<>
{user && (
<div className="flex items-center justify-between">
user && (
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
{!isCollapsed && (
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
{user.email}
</span>
<button
onClick={handleLogout}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
title="Sign out"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
)}
</>
) : (
)}
<button
onClick={handleLogout}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
title="Sign out"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
)
) : !isCollapsed ? (
<div className="flex flex-col gap-2">
<Link href="/register" onClick={onClose} className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
<Link
href="/register"
onClick={onClose}
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Register
</Link>
<Link href="/login" onClick={onClose} className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
<Link
href="/login"
onClick={onClose}
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors"
>
Sign in
</Link>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span>
) : null}
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
{!isCollapsed && <span className="text-xs text-muted-foreground">Theme</span>}
<ThemeToggle />
</div>
{/* Collapse toggle — desktop only */}
{!isMobile && (
<button
onClick={toggleCollapsed}
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
className="w-full flex items-center justify-center p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
)}
</div>
</aside>
);

View File

@@ -16,6 +16,7 @@ import type {
NewsArticle,
NotificationEvent,
NotificationSettings,
NotificationSettingsUpdate,
PaginatedResponse,
SettingsData,
TrendScore,
@@ -235,7 +236,7 @@ export interface NotificationTestResult {
export const notificationsAPI = {
getSettings: () =>
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
updateSettings: (data: Partial<NotificationSettings>) =>
updateSettings: (data: NotificationSettingsUpdate) =>
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
resetRssToken: () =>
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),

28
frontend/lib/topics.ts Normal file
View File

@@ -0,0 +1,28 @@
export const TOPICS = [
{ tag: "healthcare", label: "Healthcare", desc: "Health policy, insurance, Medicare, Medicaid" },
{ tag: "taxation", label: "Taxation", desc: "Tax law, IRS, fiscal policy" },
{ tag: "defense", label: "Defense", desc: "Military, NDAA, national security" },
{ tag: "education", label: "Education", desc: "Schools, student loans, higher education" },
{ tag: "immigration", label: "Immigration", desc: "Border, visas, asylum, citizenship" },
{ tag: "environment", label: "Environment", desc: "Climate, EPA, conservation, energy" },
{ tag: "housing", label: "Housing", desc: "Affordable housing, mortgages, HUD" },
{ tag: "infrastructure", label: "Infrastructure", desc: "Roads, bridges, broadband, transit" },
{ tag: "technology", label: "Technology", desc: "AI, cybersecurity, telecom, internet" },
{ tag: "agriculture", label: "Agriculture", desc: "Farm bill, USDA, food policy" },
{ tag: "judiciary", label: "Judiciary", desc: "Courts, criminal justice, civil rights" },
{ tag: "foreign-policy", label: "Foreign Policy", desc: "Diplomacy, foreign aid, sanctions" },
{ tag: "veterans", label: "Veterans", desc: "VA, veteran benefits, military families" },
{ tag: "social-security", label: "Social Security", desc: "SS, Medicare, retirement benefits" },
{ tag: "trade", label: "Trade", desc: "Tariffs, trade agreements, WTO" },
{ tag: "budget", label: "Budget", desc: "Appropriations, debt ceiling, spending" },
{ tag: "energy", label: "Energy", desc: "Oil, gas, renewables, nuclear" },
{ tag: "banking", label: "Banking", desc: "Financial regulation, Fed, CFPB" },
{ tag: "transportation", label: "Transportation", desc: "FAA, DOT, aviation, rail" },
{ tag: "labor", label: "Labor", desc: "Minimum wage, unions, OSHA, employment" },
];
export const TOPIC_TAGS = new Set(TOPICS.map((t) => t.tag));
export const TOPIC_LABEL: Record<string, string> = Object.fromEntries(
TOPICS.map((t) => [t.tag, t.label])
);

View File

@@ -198,7 +198,7 @@ export interface NotificationSettings {
ntfy_auth_method: string; // "none" | "token" | "basic"
ntfy_token: string;
ntfy_username: string;
ntfy_password: string;
ntfy_password_set: boolean;
ntfy_enabled: boolean;
rss_enabled: boolean;
rss_token: string | null;
@@ -212,6 +212,11 @@ export interface NotificationSettings {
alert_filters: Record<string, Record<string, boolean | string[]>> | null;
}
// Write-only — ntfy_password is accepted on PUT but never returned (use ntfy_password_set to check)
export interface NotificationSettingsUpdate extends Omit<Partial<NotificationSettings>, "ntfy_password_set"> {
ntfy_password?: string;
}
export interface Collection {
id: number;
name: string;