feat: personal notes on bill detail pages

- bill_notes table (migration 0014): user_id, bill_id, content, pinned,
  created_at, updated_at; unique constraint (user_id, bill_id)
- BillNote SQLAlchemy model with back-refs on User and Bill
- GET/PUT/DELETE /api/notes/{bill_id} — auth-required, one note per (user, bill)
- NotesPanel component: collapsible, auto-resize textarea, pin toggle,
  save + delete; shows last-saved date and pin indicator in collapsed header
- Pinned notes render above BriefPanel; unpinned render below DraftLetterPanel
- Guests see nothing (token guard in component + query disabled)

Co-Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 22:14:52 -05:00
parent 128c8e9257
commit 62a217cb22
13 changed files with 393 additions and 30 deletions

View File

@@ -1,11 +1,13 @@
"use client";
import { use, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ArrowLeft, ExternalLink, FileX, User } from "lucide-react";
import { useBill, useBillNews, useBillTrend } from "@/lib/hooks/useBills";
import { BriefPanel } from "@/components/bills/BriefPanel";
import { DraftLetterPanel } from "@/components/bills/DraftLetterPanel";
import { NotesPanel } from "@/components/bills/NotesPanel";
import { ActionTimeline } from "@/components/bills/ActionTimeline";
import { TrendChart } from "@/components/bills/TrendChart";
import { NewsPanel } from "@/components/bills/NewsPanel";
@@ -20,6 +22,15 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
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,
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.
@@ -104,41 +115,51 @@ 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 */}
{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>
) : (
<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 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>
<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} />}
</>
) : (
<>
<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} />}
</>
)}
<ActionTimeline
actions={bill.actions}

View File

@@ -0,0 +1,151 @@
"use client";
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 { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
interface NotesPanelProps {
billId: string;
}
export function NotesPanel({ billId }: NotesPanelProps) {
const token = useAuthStore((s) => s.token);
const qc = useQueryClient();
const queryKey = ["note", billId];
const { data: note, isLoading } = useQuery({
queryKey,
queryFn: () => notesAPI.get(billId),
enabled: !!token,
retry: false, // 404 = no note; don't retry
throwOnError: false,
});
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Sync form from loaded note
useEffect(() => {
if (note) {
setContent(note.content);
setPinned(note.pinned);
}
}, [note]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [content, open]);
const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned),
onSuccess: (updated) => {
qc.setQueryData(queryKey, updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const remove = useMutation({
mutationFn: () => notesAPI.delete(billId),
onSuccess: () => {
qc.removeQueries({ queryKey });
setContent("");
setPinned(false);
setOpen(false);
},
});
// Don't render for guests
if (!token) return null;
if (isLoading) return null;
const hasNote = !!note;
const isDirty = hasNote
? content !== note.content || pinned !== note.pinned
: 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
{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"
>
<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

@@ -86,6 +86,16 @@ export const billsAPI = {
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
// Notes
export const notesAPI = {
get: (billId: string) =>
apiClient.get<import("./types").BillNote>(`/api/notes/${billId}`).then((r) => r.data),
upsert: (billId: string, content: string, pinned: boolean) =>
apiClient.put<import("./types").BillNote>(`/api/notes/${billId}`, { content, pinned }).then((r) => r.data),
delete: (billId: string) =>
apiClient.delete(`/api/notes/${billId}`),
};
// Members
export const membersAPI = {
list: (params?: Record<string, unknown>) =>

View File

@@ -158,6 +158,15 @@ export interface SettingsData {
pytrends_enabled: boolean;
}
export interface BillNote {
id: number;
bill_id: string;
content: string;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface NotificationSettings {
ntfy_topic_url: string;
ntfy_auth_method: string; // "none" | "token" | "basic"