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

@@ -2,7 +2,7 @@ from datetime import date, timedelta
from fastapi import Depends from fastapi import Depends
from fastapi import APIRouter from fastapi import APIRouter
from sqlalchemy import desc, select from sqlalchemy import desc, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -92,15 +92,15 @@ async def get_dashboard(
feed_bills.append(bill) feed_bills.append(bill)
seen_ids.add(bill.bill_id) seen_ids.add(bill.bill_id)
# 3. Bills matching followed topics # 3. Bills matching followed topics (single query with OR across all topics)
for topic in followed_topics: if followed_topics:
result = await db.execute( result = await db.execute(
select(Bill) select(Bill)
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores)) .options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
.join(BillBrief, Bill.bill_id == BillBrief.bill_id) .join(BillBrief, Bill.bill_id == BillBrief.bill_id)
.where(BillBrief.topic_tags.contains([topic])) .where(or_(*[BillBrief.topic_tags.contains([t]) for t in followed_topics]))
.order_by(desc(Bill.latest_action_date)) .order_by(desc(Bill.latest_action_date))
.limit(10) .limit(20)
) )
for bill in result.scalars().all(): for bill in result.scalars().all():
if bill.bill_id not in seen_ids: if bill.bill_id not in seen_ids:

View File

@@ -12,6 +12,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings as app_settings from app.config import settings as app_settings
from app.core.crypto import decrypt_secret, encrypt_secret
from app.core.dependencies import get_current_user from app.core.dependencies import get_current_user
from app.database import get_db from app.database import get_db
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
@@ -41,7 +42,7 @@ def _prefs_to_response(prefs: dict, rss_token: str | None) -> NotificationSettin
ntfy_auth_method=prefs.get("ntfy_auth_method", "none"), ntfy_auth_method=prefs.get("ntfy_auth_method", "none"),
ntfy_token=prefs.get("ntfy_token", ""), ntfy_token=prefs.get("ntfy_token", ""),
ntfy_username=prefs.get("ntfy_username", ""), ntfy_username=prefs.get("ntfy_username", ""),
ntfy_password=prefs.get("ntfy_password", ""), ntfy_password_set=bool(decrypt_secret(prefs.get("ntfy_password", ""))),
ntfy_enabled=prefs.get("ntfy_enabled", False), ntfy_enabled=prefs.get("ntfy_enabled", False),
rss_enabled=prefs.get("rss_enabled", False), rss_enabled=prefs.get("rss_enabled", False),
rss_token=rss_token, rss_token=rss_token,
@@ -88,7 +89,7 @@ async def update_notification_settings(
if body.ntfy_username is not None: if body.ntfy_username is not None:
prefs["ntfy_username"] = body.ntfy_username.strip() prefs["ntfy_username"] = body.ntfy_username.strip()
if body.ntfy_password is not None: if body.ntfy_password is not None:
prefs["ntfy_password"] = body.ntfy_password.strip() prefs["ntfy_password"] = encrypt_secret(body.ntfy_password.strip())
if body.ntfy_enabled is not None: if body.ntfy_enabled is not None:
prefs["ntfy_enabled"] = body.ntfy_enabled prefs["ntfy_enabled"] = body.ntfy_enabled
if body.rss_enabled is not None: if body.rss_enabled is not None:

View File

@@ -11,7 +11,7 @@ router = APIRouter()
@router.get("") @router.get("")
async def search( async def search(
q: str = Query(..., min_length=2), q: str = Query(..., min_length=2, max_length=500),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# Bill ID direct match # Bill ID direct match

View File

@@ -1,4 +1,5 @@
from functools import lru_cache from functools import lru_cache
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -13,6 +14,11 @@ class Settings(BaseSettings):
JWT_SECRET_KEY: str = "change-me-in-production" JWT_SECRET_KEY: str = "change-me-in-production"
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# Symmetric encryption for sensitive user prefs (ntfy password, etc.)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Falls back to JWT_SECRET_KEY derivation if not set (not recommended for production)
ENCRYPTION_SECRET_KEY: str = ""
# Database # Database
DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto" DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto"
SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto" SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto"
@@ -40,9 +46,9 @@ class Settings(BaseSettings):
OLLAMA_MODEL: str = "llama3.1" OLLAMA_MODEL: str = "llama3.1"
# Max LLM requests per minute — Celery enforces this globally across all workers. # Max LLM requests per minute — Celery enforces this globally across all workers.
# Safe defaults: free Gemini=15 RPM, Anthropic paid=50 RPM, OpenAI paid=500 RPM. # Defaults: free Gemini=15 RPM, Anthropic paid=50 RPM, OpenAI paid=500 RPM.
# Raise this in .env once you confirm your API tier. # Lower this in .env if you hit rate limit errors on a restricted tier.
LLM_RATE_LIMIT_RPM: int = 10 LLM_RATE_LIMIT_RPM: int = 50
# Google Civic Information API (zip → representative lookup) # Google Civic Information API (zip → representative lookup)
# Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com # Free key: https://console.cloud.google.com/apis/library/civicinfo.googleapis.com
@@ -54,6 +60,15 @@ class Settings(BaseSettings):
# pytrends # pytrends
PYTRENDS_ENABLED: bool = True PYTRENDS_ENABLED: bool = True
@model_validator(mode="after")
def check_secrets(self) -> "Settings":
if self.JWT_SECRET_KEY == "change-me-in-production":
raise ValueError(
"JWT_SECRET_KEY must be set to a secure random value in .env. "
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
return self
# SMTP (Email notifications) # SMTP (Email notifications)
SMTP_HOST: str = "" SMTP_HOST: str = ""
SMTP_PORT: int = 587 SMTP_PORT: int = 587

View File

@@ -0,0 +1,44 @@
"""Symmetric encryption for sensitive user prefs (e.g. ntfy password).
Key priority:
1. ENCRYPTION_SECRET_KEY env var (recommended — dedicated key, easily rotatable)
2. Derived from JWT_SECRET_KEY (fallback for existing installs)
Generate a dedicated key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
"""
import base64
import hashlib
from cryptography.fernet import Fernet
_PREFIX = "enc:"
_fernet_instance: Fernet | None = None
def _fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
from app.config import settings
if settings.ENCRYPTION_SECRET_KEY:
# Use dedicated key directly (must be a valid 32-byte base64url key)
_fernet_instance = Fernet(settings.ENCRYPTION_SECRET_KEY.encode())
else:
# Fallback: derive from JWT secret
key_bytes = hashlib.sha256(settings.JWT_SECRET_KEY.encode()).digest()
_fernet_instance = Fernet(base64.urlsafe_b64encode(key_bytes))
return _fernet_instance
def encrypt_secret(plaintext: str) -> str:
"""Encrypt a string and return a prefixed ciphertext."""
if not plaintext:
return plaintext
return _PREFIX + _fernet().encrypt(plaintext.encode()).decode()
def decrypt_secret(value: str) -> str:
"""Decrypt a value produced by encrypt_secret. Returns plaintext as-is (legacy support)."""
if not value or not value.startswith(_PREFIX):
return value # legacy plaintext — return unchanged
return _fernet().decrypt(value[len(_PREFIX):].encode()).decode()

View File

@@ -40,7 +40,7 @@ async def get_optional_user(
try: try:
user_id = decode_token(token) user_id = decode_token(token)
return await db.get(User, user_id) return await db.get(User, user_id)
except Exception: except (JWTError, ValueError):
return None return None

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Any, Generic, Optional, TypeVar from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel from pydantic import BaseModel, field_validator
# ── Notifications ────────────────────────────────────────────────────────────── # ── Notifications ──────────────────────────────────────────────────────────────
@@ -31,7 +31,7 @@ class NotificationSettingsResponse(BaseModel):
ntfy_auth_method: str = "none" # none | token | basic ntfy_auth_method: str = "none" # none | token | basic
ntfy_token: str = "" ntfy_token: str = ""
ntfy_username: str = "" ntfy_username: str = ""
ntfy_password: str = "" ntfy_password_set: bool = False
ntfy_enabled: bool = False ntfy_enabled: bool = False
rss_enabled: bool = False rss_enabled: bool = False
rss_token: Optional[str] = None rss_token: Optional[str] = None
@@ -315,11 +315,13 @@ class CollectionCreate(BaseModel):
name: str name: str
is_public: bool = False is_public: bool = False
def validate_name(self) -> str: @field_validator("name")
name = self.name.strip() @classmethod
if not 1 <= len(name) <= 100: def validate_name(cls, v: str) -> str:
v = v.strip()
if not 1 <= len(v) <= 100:
raise ValueError("name must be 1100 characters") raise ValueError("name must be 1100 characters")
return name return v
class CollectionUpdate(BaseModel): class CollectionUpdate(BaseModel):

View File

@@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone
import requests import requests
from app.core.crypto import decrypt_secret
from app.database import get_sync_db from app.database import get_sync_db
from app.models.follow import Follow from app.models.follow import Follow
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
@@ -162,7 +163,7 @@ def dispatch_notifications(self):
ntfy_auth_method = prefs.get("ntfy_auth_method", "none") ntfy_auth_method = prefs.get("ntfy_auth_method", "none")
ntfy_token = prefs.get("ntfy_token", "").strip() ntfy_token = prefs.get("ntfy_token", "").strip()
ntfy_username = prefs.get("ntfy_username", "").strip() ntfy_username = prefs.get("ntfy_username", "").strip()
ntfy_password = prefs.get("ntfy_password", "").strip() ntfy_password = decrypt_secret(prefs.get("ntfy_password", "").strip())
ntfy_enabled = prefs.get("ntfy_enabled", False) ntfy_enabled = prefs.get("ntfy_enabled", False)
rss_enabled = prefs.get("rss_enabled", False) rss_enabled = prefs.get("rss_enabled", False)
digest_enabled = prefs.get("digest_enabled", False) digest_enabled = prefs.get("digest_enabled", False)

View File

@@ -129,16 +129,20 @@ def emit_topic_follow_notifications(
from app.models.follow import Follow from app.models.follow import Follow
from app.models.notification import NotificationEvent from app.models.notification import NotificationEvent
# Collect unique followers across all matching tags, recording the first matching tag per user # Single query for all topic followers, then deduplicate by user_id
all_follows = db.query(Follow).filter(
Follow.follow_type == "topic",
Follow.follow_value.in_(topic_tags),
).all()
seen_user_ids: set[int] = set() seen_user_ids: set[int] = set()
followers = [] followers = []
follower_topic: dict[int, str] = {} follower_topic: dict[int, str] = {}
for tag in topic_tags: for follow in all_follows:
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all(): if follow.user_id not in seen_user_ids:
if follow.user_id not in seen_user_ids: seen_user_ids.add(follow.user_id)
seen_user_ids.add(follow.user_id) followers.append(follow)
followers.append(follow) follower_topic[follow.user_id] = follow.follow_value
follower_topic[follow.user_id] = tag
if not followers: if not followers:
return 0 return 0

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { use, useEffect, useRef } from "react"; import { use, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Link from "next/link"; 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 { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { useAuthStore } from "@/stores/authStore";
import { BriefPanel } from "@/components/bills/BriefPanel"; import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel"; import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel"; import { NotesPanel } from "@/components/bills/NotesPanel";
@@ -15,28 +16,34 @@ import { NewsPanel } from "@/components/bills/NewsPanel";
import { FollowButton } from "@/components/shared/FollowButton"; import { FollowButton } from "@/components/shared/FollowButton";
import { CollectionPicker } from "@/components/bills/CollectionPicker"; import { CollectionPicker } from "@/components/bills/CollectionPicker";
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils"; 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 }> }) { export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params); const { id } = use(params);
const billId = decodeURIComponent(id); const billId = decodeURIComponent(id);
const [activeTab, setActiveTab] = useState<TabId>("analysis");
const token = useAuthStore((s) => s.token);
const { data: bill, isLoading } = useBill(billId); const { data: bill, isLoading } = useBill(billId);
const { data: trendData } = useBillTrend(billId, 30); const { data: trendData } = useBillTrend(billId, 30);
const { data: newsArticles, refetch: refetchNews } = useBillNews(billId); 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({ const { data: note } = useQuery({
queryKey: ["note", billId], queryKey: ["note", billId],
queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)), queryFn: () => import("@/lib/api").then((m) => m.notesAPI.get(billId)),
enabled: true, enabled: !!token,
retry: false, retry: false,
throwOnError: 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); const newsRetryRef = useRef(0);
useEffect(() => { newsRetryRef.current = 0; }, [billId]); useEffect(() => { newsRetryRef.current = 0; }, [billId]);
useEffect(() => { useEffect(() => {
@@ -119,60 +126,103 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
{/* Content grid */} {/* Content grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
<div className="md:col-span-2 space-y-6"> <div className="md:col-span-2 space-y-4">
{/* Pinned note floats above briefs */} {/* Pinned note always visible above tabs */}
{note?.pinned && <NotesPanel billId={bill.bill_id} />} {note?.pinned && <NotesPanel billId={bill.bill_id} />}
{bill.briefs.length > 0 ? ( {/* Tab bar */}
<> <div className="flex gap-0 border-b border-border">
<BriefPanel briefs={bill.briefs} /> {TABS.map((tab) => (
<DraftLetterPanel billId={bill.bill_id} brief={bill.briefs[0]} chamber={bill.chamber} /> <button
{!note?.pinned && <NotesPanel billId={bill.bill_id} />} key={tab.id}
</> onClick={() => setActiveTab(tab.id)}
) : bill.has_document ? ( className={cn(
<> "px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
<div className="bg-card border border-border rounded-lg p-6 text-center space-y-2"> activeTab === tab.id
<p className="text-sm font-medium text-muted-foreground">Analysis pending</p> ? "border-primary text-foreground"
<p className="text-xs text-muted-foreground"> : "border-transparent text-muted-foreground hover:text-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>
)} )}
</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>
<div className="space-y-4"> <div className="space-y-4">
<TrendChart data={trendData} /> <TrendChart data={trendData} />
<NewsPanel articles={newsArticles} /> <NewsPanel articles={newsArticles} />

View File

@@ -5,21 +5,16 @@ import { useSearchParams } from "next/navigation";
import { FileText, Search } from "lucide-react"; import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills"; import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard"; import { BillCard } from "@/components/shared/BillCard";
import { TOPICS } from "@/lib/topics";
const CHAMBERS = ["", "House", "Senate"]; 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() { export default function BillsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get("q") ?? ""); const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? ""); const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? ""); const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
const [hasText, setHasText] = useState(false); const [hasText, setHasText] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Sync URL params → state so tag/topic links work when already on this page // 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" className="px-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
> >
<option value="">All Topics</option> <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> </select>
<button <button
onClick={() => { setHasText((v) => !v); setPage(1); }} onClick={() => { setHasText((v) => !v); setPage(1); }}

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { import {
BarChart2,
Bell, Bell,
Bookmark, Bookmark,
Calendar, Calendar,
@@ -8,9 +9,15 @@ import {
Filter, Filter,
Heart, Heart,
HelpCircle, HelpCircle,
ListChecks,
Mail,
MessageSquare,
Rss, Rss,
Shield, Shield,
Share2, Share2,
StickyNote,
TrendingUp,
Users,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
@@ -63,11 +70,16 @@ export default function HowItWorksPage() {
{/* Jump links */} {/* Jump links */}
<div className="flex flex-wrap gap-2 mt-3"> <div className="flex flex-wrap gap-2 mt-3">
{[ {[
{ href: "#follow", label: "Following" }, { href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" }, { href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" }, { href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" }, { href: "#briefs", label: "AI Briefs" },
{ href: "#bills", label: "Bills" }, { 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 }) => ( ].map(({ href, label }) => (
<a <a
key={href} key={href}
@@ -143,7 +155,7 @@ export default function HowItWorksPage() {
</Item> </Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing"> <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 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> </Item>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -156,37 +168,40 @@ export default function HowItWorksPage() {
{/* Notifications */} {/* Notifications */}
<Section id="notifications" title="Notifications" icon={Bell}> <Section id="notifications" title="Notifications" icon={Bell}>
<p className="text-sm text-muted-foreground"> <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> </p>
<div className="space-y-3"> <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"> <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"> <a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ntfy</a>
ntfy
</a>
{" "}is a free, open-source push notification service. Configure a topic URL in{" "} {" "}is a free, open-source push notification service. Configure a topic URL in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "} <Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
and receive real-time alerts on any device with the ntfy app. and receive real-time alerts on any device with the ntfy app.
</Item> </Item>
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours"> <Item icon={Mail} color="bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400" title="Email">
Pause push notifications during set hours (e.g. 10 PM 8 AM). Events that arrive Receive alerts as plain-text emails. Add your address in{" "}
during quiet hours are queued and sent as a batch when the window ends. <Link href="/notifications" className="text-primary hover:underline">Notifications Email</Link>.
</Item> Every email includes a one-click unsubscribe link, and your address is never used for
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode"> anything other than bill alerts.
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> </Item>
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed"> <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 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>
<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 Member and topic follows generate Discovery alerts separate from the bills you follow
directly. In{" "} directly. In{" "}
<Link href="/notifications" className="text-primary hover:underline">Alert Filters Discovery</Link>, <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 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. mute specific members or topics without unfollowing them. Each notification includes a
Each notification also shows a &ldquo;why&rdquo; line so you always know which follow &ldquo;why&rdquo; line so you always know which follow triggered it.
triggered it.
</Item> </Item>
</div> </div>
</Section> </Section>
@@ -194,29 +209,86 @@ export default function HowItWorksPage() {
{/* AI Briefs */} {/* AI Briefs */}
<Section id="briefs" title="AI Briefs" icon={FileText}> <Section id="briefs" title="AI Briefs" icon={FileText}>
<p className="text-sm text-muted-foreground"> <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> </p>
<div className="space-y-3"> <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"> <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 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 (§ chips you can expand to see the quoted source text), and a risks section that flags
provisions. potential unintended consequences or contested provisions.
</Item> </Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief"> <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 Click the share icon in the brief panel to copy a public link. Anyone can read the
brief at that URL no login required. brief at that URL no login required.
</Item> </Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter"> <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 Use the Draft Letter panel in the Analysis tab to generate a personalised letter to
representative based on the brief&apos;s key points. your representative based on the brief&apos;s key points.
</Item> </Item>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Briefs are only generated for bills where GovInfo has published official text. Bills 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> </p>
</Section> </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 */} {/* Bills */}
<Section id="bills" title="Browsing bills" icon={FileText}> <Section id="bills" title="Browsing bills" icon={FileText}>
<p className="text-sm text-muted-foreground"> <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">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">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">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> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Clicking a topic tag on any bill or following page takes you directly to that filtered Each bill page is organised into four tabs: <strong>Analysis</strong> (AI brief + draft
view on the Bills page. 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> </p>
</Section> </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> </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"); setAuthMethod(settings.ntfy_auth_method ?? "none");
setToken(settings.ntfy_token ?? ""); setToken(settings.ntfy_token ?? "");
setUsername(settings.ntfy_username ?? ""); setUsername(settings.ntfy_username ?? "");
setPassword(settings.ntfy_password ?? ""); setPassword(""); // never pre-fill — password_set bool shows whether one is stored
setEmailAddress(settings.email_address ?? ""); setEmailAddress(settings.email_address ?? "");
setEmailEnabled(settings.email_enabled ?? false); setEmailEnabled(settings.email_enabled ?? false);
setDigestEnabled(settings.digest_enabled ?? false); setDigestEnabled(settings.digest_enabled ?? false);
@@ -333,7 +333,7 @@ export default function NotificationsPage() {
ntfy_auth_method: authMethod, ntfy_auth_method: authMethod,
ntfy_token: authMethod === "token" ? token : "", ntfy_token: authMethod === "token" ? token : "",
ntfy_username: authMethod === "basic" ? username : "", ntfy_username: authMethod === "basic" ? username : "",
ntfy_password: authMethod === "basic" ? password : "", ntfy_password: authMethod === "basic" ? (password || undefined) : "",
ntfy_enabled: enabled, ntfy_enabled: enabled,
}, },
{ onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } } { onSuccess: () => { setNtfySaved(true); setTimeout(() => setNtfySaved(false), 2000); } }
@@ -565,7 +565,9 @@ export default function NotificationsPage() {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Password</label> <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)} 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" /> 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> </div>

View File

@@ -3,29 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Tags } from "lucide-react"; import { Tags } from "lucide-react";
import { FollowButton } from "@/components/shared/FollowButton"; import { FollowButton } from "@/components/shared/FollowButton";
import { TOPICS } from "@/lib/topics";
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 default function TopicsPage() { export default function TopicsPage() {
return ( return (

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; 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 { BriefSchema, CitedPoint } from "@/lib/types";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
@@ -30,28 +30,32 @@ function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey
<li className="text-sm"> <li className="text-sm">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">{icon}</span> <span className="mt-0.5 shrink-0">{icon}</span>
<span className="flex-1">{cited ? point.text : point}</span> <div className="flex-1 min-w-0 space-y-1">
{cited && point.label === "inference" && ( <div className="flex items-start gap-2">
<span <span className="flex-1">{cited ? point.text : point}</span>
title="This point is an analytical interpretation, not a literal statement from the bill text" {cited && point.label === "inference" && (
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none" <span
> title="This point is an analytical interpretation, not a literal statement from the bill text"
Inferred 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"
</span> >
)} Inferred
{cited && ( </span>
<button )}
onClick={() => setActiveKey(isOpen ? null : openKey)} </div>
title={isOpen ? "Hide source" : "View source"} {cited && (
className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-mono transition-colors ${ <button
isOpen onClick={() => setActiveKey(isOpen ? null : openKey)}
? "bg-primary text-primary-foreground" title={isOpen ? "Hide source" : "View source"}
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground" className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
}`} isOpen
> ? "bg-primary text-primary-foreground"
§ {point.citation} : "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
</button> }`}
)} >
§ {point.citation}
</button>
)}
</div>
</div> </div>
{cited && isOpen && ( {cited && isOpen && (
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2"> <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> </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> </div>
); );
} }

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore"; import { useAuthStore } from "@/stores/authStore";
@@ -23,7 +23,6 @@ export function NotesPanel({ billId }: NotesPanelProps) {
throwOnError: false, throwOnError: false,
}); });
const [open, setOpen] = useState(false);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false); const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@@ -43,7 +42,7 @@ export function NotesPanel({ billId }: NotesPanelProps) {
if (!el) return; if (!el) return;
el.style.height = "auto"; el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`; el.style.height = `${el.scrollHeight}px`;
}, [content, open]); }, [content]);
const upsert = useMutation({ const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned), mutationFn: () => notesAPI.upsert(billId, content, pinned),
@@ -60,12 +59,14 @@ export function NotesPanel({ billId }: NotesPanelProps) {
qc.removeQueries({ queryKey }); qc.removeQueries({ queryKey });
setContent(""); setContent("");
setPinned(false); setPinned(false);
setOpen(false);
}, },
}); });
// Don't render for guests if (!token) return (
if (!token) return null; <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; if (isLoading) return null;
const hasNote = !!note; const hasNote = !!note;
@@ -74,78 +75,56 @@ export function NotesPanel({ billId }: NotesPanelProps) {
: content.trim().length > 0; : content.trim().length > 0;
return ( return (
<div className="bg-card border border-border rounded-lg overflow-hidden"> <div className="bg-card border border-border rounded-lg p-4 space-y-3">
{/* Header / toggle */} <textarea
<button ref={textareaRef}
onClick={() => setOpen((v) => !v)} value={content}
className="w-full flex items-center justify-between px-4 py-3 text-sm hover:bg-accent transition-colors" onChange={(e) => setContent(e.target.value)}
> placeholder="Add a private note about this bill…"
<span className="flex items-center gap-2 font-medium"> rows={3}
<StickyNote className="w-4 h-4 text-muted-foreground" /> 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"
My Note />
<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 && ( {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 <button
onClick={() => upsert.mutate()} onClick={() => remove.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)} disabled={remove.isPending}
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" 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" /> <Trash2 className="w-3.5 h-3.5" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button> </button>
</div> )}
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div> </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> </div>
); );
} }

View File

@@ -9,9 +9,10 @@ import type { BillVote, MemberVotePosition } from "@/lib/types";
interface VotePanelProps { interface VotePanelProps {
billId: string; billId: string;
alwaysRender?: boolean;
} }
export function VotePanel({ billId }: VotePanelProps) { export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const { data: votes, isLoading } = useQuery({ const { data: votes, isLoading } = useQuery({
@@ -33,7 +34,16 @@ export function VotePanel({ billId }: VotePanelProps) {
.map((f) => f.follow_value) .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 ( return (
<div className="bg-card border border-border rounded-lg overflow-hidden"> <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 { Bill } from "@/lib/types";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils"; import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton"; import { FollowButton } from "./FollowButton";
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
interface BillCardProps { interface BillCardProps {
bill: Bill; bill: Bill;
@@ -12,7 +13,7 @@ interface BillCardProps {
export function BillCard({ bill, compact = false }: BillCardProps) { export function BillCard({ bill, compact = false }: BillCardProps) {
const label = billLabel(bill.bill_type, bill.bill_number); const label = billLabel(bill.bill_type, bill.bill_number);
const score = bill.latest_trend?.composite_score; 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 ( return (
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors"> <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" 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 className="w-2.5 h-2.5" />
{tag} {TOPIC_LABEL[tag] ?? tag}
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -153,7 +153,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
</button> </button>
{open && ( {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) => { {otherModes.map((mode) => {
const { label: optLabel, icon: OptIcon } = MODES[mode]; const { label: optLabel, icon: OptIcon } = MODES[mode];
return ( return (
@@ -161,7 +161,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
key={mode} key={mode}
onClick={() => switchMode(mode)} onClick={() => switchMode(mode)}
title={modeDescriptions[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"> <span className="flex items-center gap-1.5 font-medium">
<OptIcon className="w-3.5 h-3.5" /> <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"> <div className="border-t border-border mt-1 pt-1">
<button <button
onClick={handleUnfollow} 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 Unfollow
</button> </button>

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
Bookmark, Bookmark,
ChevronLeft,
ChevronRight,
HelpCircle, HelpCircle,
LayoutDashboard, LayoutDashboard,
FileText, FileText,
@@ -42,6 +45,23 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout); 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() { function handleLogout() {
logout(); logout();
@@ -50,18 +70,38 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
} }
return ( return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col"> <aside
<div className="p-4 border-b border-border flex items-center gap-2"> className={cn(
<Landmark className="w-5 h-5 text-primary" /> "shrink-0 border-r border-border bg-card flex flex-col transition-all duration-200",
<span className="font-semibold text-sm flex-1">PocketVeto</span> isCollapsed ? "w-14" : "w-56"
{onClose && ( )}
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent transition-colors" aria-label="Close menu"> >
<X className="w-4 h-4" /> {/* Header */}
</button> <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> </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 }) => { {NAV.filter(({ adminOnly, requiresAuth }) => {
if (adminOnly && !user?.is_admin) return false; if (adminOnly && !user?.is_admin) return false;
if (requiresAuth && !token) return false; if (requiresAuth && !token) return false;
@@ -73,52 +113,75 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
key={href} key={href}
href={href} href={href}
onClick={onClose} onClick={onClose}
title={isCollapsed ? label : undefined}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors", "flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
isCollapsed && "justify-center px-0",
active active
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)} )}
> >
<Icon className="w-4 h-4 shrink-0" /> <Icon className="w-4 h-4 shrink-0" />
{label} {!isCollapsed && label}
</Link> </Link>
); );
})} })}
</nav> </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 ? ( {token ? (
<> user && (
{user && ( <div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
<div className="flex items-center justify-between"> {!isCollapsed && (
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}> <span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
{user.email} {user.email}
</span> </span>
<button )}
onClick={handleLogout} <button
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent" onClick={handleLogout}
title="Sign out" 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> <LogOut className="w-3.5 h-3.5" />
</div> </button>
)} </div>
</> )
) : ( ) : !isCollapsed ? (
<div className="flex flex-col gap-2"> <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 Register
</Link> </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 Sign in
</Link> </Link>
</div> </div>
)} ) : null}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span> <div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
{!isCollapsed && <span className="text-xs text-muted-foreground">Theme</span>}
<ThemeToggle /> <ThemeToggle />
</div> </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> </div>
</aside> </aside>
); );

View File

@@ -16,6 +16,7 @@ import type {
NewsArticle, NewsArticle,
NotificationEvent, NotificationEvent,
NotificationSettings, NotificationSettings,
NotificationSettingsUpdate,
PaginatedResponse, PaginatedResponse,
SettingsData, SettingsData,
TrendScore, TrendScore,
@@ -235,7 +236,7 @@ export interface NotificationTestResult {
export const notificationsAPI = { export const notificationsAPI = {
getSettings: () => getSettings: () =>
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data), 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), apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
resetRssToken: () => resetRssToken: () =>
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data), 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_auth_method: string; // "none" | "token" | "basic"
ntfy_token: string; ntfy_token: string;
ntfy_username: string; ntfy_username: string;
ntfy_password: string; ntfy_password_set: boolean;
ntfy_enabled: boolean; ntfy_enabled: boolean;
rss_enabled: boolean; rss_enabled: boolean;
rss_token: string | null; rss_token: string | null;
@@ -212,6 +212,11 @@ export interface NotificationSettings {
alert_filters: Record<string, Record<string, boolean | string[]>> | null; 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 { export interface Collection {
id: number; id: number;
name: string; name: string;