Phase 3 completion — Personal Workflow feature set is now complete.
Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)
Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash
AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS
Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
7.8 KiB
TypeScript
182 lines
7.8 KiB
TypeScript
"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";
|
|
import { FollowButton } from "@/components/shared/FollowButton";
|
|
import { CollectionPicker } from "@/components/bills/CollectionPicker";
|
|
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
|
|
|
|
export default function BillDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = use(params);
|
|
const billId = decodeURIComponent(id);
|
|
|
|
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,
|
|
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(() => {
|
|
if (newsArticles === undefined || newsArticles.length > 0) return;
|
|
if (newsRetryRef.current >= 3) return;
|
|
const timer = setTimeout(() => {
|
|
newsRetryRef.current += 1;
|
|
refetchNews();
|
|
}, 6000);
|
|
return () => clearTimeout(timer);
|
|
}, [newsArticles]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (isLoading) {
|
|
return <div className="text-center py-20 text-muted-foreground">Loading bill...</div>;
|
|
}
|
|
|
|
if (!bill) {
|
|
return (
|
|
<div className="text-center py-20">
|
|
<p className="text-muted-foreground">Bill not found.</p>
|
|
<Link href="/bills" className="text-sm text-primary mt-2 inline-block">← Back to bills</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const label = billLabel(bill.bill_type, bill.bill_number);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<Link href="/bills" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</Link>
|
|
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
|
{label}
|
|
</span>
|
|
{bill.chamber && (
|
|
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
|
|
{bill.chamber}
|
|
</span>
|
|
)}
|
|
<span className="text-sm text-muted-foreground">{congressLabel(bill.congress_number)}</span>
|
|
</div>
|
|
<h1 className="text-xl font-bold leading-snug">
|
|
{bill.short_title || bill.title || "Untitled Bill"}
|
|
</h1>
|
|
{bill.sponsor && (
|
|
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
|
<User className="w-3.5 h-3.5" />
|
|
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
|
|
{bill.sponsor.name}
|
|
</Link>
|
|
{bill.sponsor.party && (
|
|
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
|
|
{bill.sponsor.party}
|
|
</span>
|
|
)}
|
|
{bill.sponsor.state && <span>{bill.sponsor.state}</span>}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">
|
|
{bill.introduced_date && (
|
|
<span>Introduced: {formatDate(bill.introduced_date)}</span>
|
|
)}
|
|
{bill.congress_url && (
|
|
<a href={bill.congress_url} target="_blank" rel="noopener noreferrer" className="hover:text-primary transition-colors">
|
|
congress.gov <ExternalLink className="w-3 h-3 inline" />
|
|
</a>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<CollectionPicker billId={bill.bill_id} />
|
|
<FollowButton type="bill" value={bill.bill_id} supportsModes />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
{!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}
|
|
latestActionDate={bill.latest_action_date}
|
|
latestActionText={bill.latest_action_text}
|
|
/>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<TrendChart data={trendData} />
|
|
<NewsPanel articles={newsArticles} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|