Add chamber color badges, action history fallback, and task status polling
- Add chamberBadgeColor util: amber/gold for Senate, slate/silver for House - Apply chamber badge to BillCard and bill detail header - ActionTimeline: show latest_action_date/text as fallback when full history not yet fetched, with note that full history loads in background - Manual Controls: poll task status every 5s after triggering, show spinning indicator while running, task ID prefix, and completion/failure state Authored-By: Jack Levy
This commit is contained in:
@@ -9,7 +9,7 @@ import { ActionTimeline } from "@/components/bills/ActionTimeline";
|
|||||||
import { TrendChart } from "@/components/bills/TrendChart";
|
import { TrendChart } from "@/components/bills/TrendChart";
|
||||||
import { NewsPanel } from "@/components/bills/NewsPanel";
|
import { NewsPanel } from "@/components/bills/NewsPanel";
|
||||||
import { FollowButton } from "@/components/shared/FollowButton";
|
import { FollowButton } from "@/components/shared/FollowButton";
|
||||||
import { billLabel, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
|
import { billLabel, chamberBadgeColor, congressLabel, formatDate, partyBadgeColor, cn } from "@/lib/utils";
|
||||||
|
|
||||||
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);
|
||||||
@@ -62,7 +62,11 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
<span className="font-mono text-sm font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">{bill.chamber}</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>
|
<span className="text-sm text-muted-foreground">{congressLabel(bill.congress_number)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold leading-snug">
|
<h1 className="text-xl font-bold leading-snug">
|
||||||
@@ -132,7 +136,11 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ActionTimeline actions={bill.actions} />
|
<ActionTimeline
|
||||||
|
actions={bill.actions}
|
||||||
|
latestActionDate={bill.latest_action_date}
|
||||||
|
latestActionText={bill.latest_action_text}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TrendChart data={trendData} />
|
<TrendChart data={trendData} />
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export default function SettingsPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
|
||||||
|
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
|
||||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||||
|
|
||||||
const testLLM = async () => {
|
const testLLM = async () => {
|
||||||
@@ -152,9 +153,26 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pollTaskStatus = async (name: string, taskId: string) => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
try {
|
||||||
|
const data = await adminAPI.getTaskStatus(taskId);
|
||||||
|
if (["SUCCESS", "FAILURE", "REVOKED"].includes(data.status)) {
|
||||||
|
setTaskStatuses((prev) => ({ ...prev, [name]: data.status === "SUCCESS" ? "done" : "error" }));
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin-stats"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* ignore polling errors */ }
|
||||||
|
}
|
||||||
|
setTaskStatuses((prev) => ({ ...prev, [name]: "error" }));
|
||||||
|
};
|
||||||
|
|
||||||
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
|
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
|
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
|
||||||
|
setTaskStatuses((prev) => ({ ...prev, [name]: "running" }));
|
||||||
|
pollTaskStatus(name, result.task_id);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
||||||
@@ -719,25 +737,36 @@ export default function SettingsPage() {
|
|||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium">{name}</span>
|
<span className="text-sm font-medium">{name}</span>
|
||||||
{status === "ok" && (
|
{taskStatuses[key] === "running" ? (
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">✓ Up to date</span>
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
running
|
||||||
|
{taskIds[key] && (
|
||||||
|
<code className="font-mono opacity-60">{taskIds[key].slice(0, 8)}…</code>
|
||||||
)}
|
)}
|
||||||
{status === "needed" && count !== undefined && count > 0 && (
|
</span>
|
||||||
|
) : taskStatuses[key] === "done" ? (
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">✓ Complete</span>
|
||||||
|
) : taskStatuses[key] === "error" ? (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400">✗ Failed</span>
|
||||||
|
) : status === "ok" ? (
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">✓ Up to date</span>
|
||||||
|
) : status === "needed" && count !== undefined && count > 0 ? (
|
||||||
<span className="text-xs text-red-600 dark:text-red-400">
|
<span className="text-xs text-red-600 dark:text-red-400">
|
||||||
⚠ {count.toLocaleString()} {countLabel}
|
⚠ {count.toLocaleString()} {countLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
{taskIds[key] && (
|
|
||||||
<span className="text-xs text-muted-foreground">queued ✓</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => trigger(key, fn)}
|
onClick={() => trigger(key, fn)}
|
||||||
className="shrink-0 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium"
|
disabled={taskStatuses[key] === "running"}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Run
|
{taskStatuses[key] === "running" ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : "Run"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { formatDate } from "@/lib/utils";
|
|||||||
|
|
||||||
interface ActionTimelineProps {
|
interface ActionTimelineProps {
|
||||||
actions: BillAction[];
|
actions: BillAction[];
|
||||||
|
latestActionDate?: string;
|
||||||
|
latestActionText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionTimeline({ actions }: ActionTimelineProps) {
|
export function ActionTimeline({ actions, latestActionDate, latestActionText }: ActionTimelineProps) {
|
||||||
if (!actions || actions.length === 0) {
|
const hasActions = actions && actions.length > 0;
|
||||||
|
const hasFallback = !hasActions && latestActionText;
|
||||||
|
|
||||||
|
if (!hasActions && !hasFallback) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg p-6">
|
<div className="bg-card border border-border rounded-lg p-6">
|
||||||
<h2 className="font-semibold mb-3 flex items-center gap-2">
|
<h2 className="font-semibold mb-3 flex items-center gap-2">
|
||||||
@@ -24,13 +29,16 @@ export function ActionTimeline({ actions }: ActionTimelineProps) {
|
|||||||
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
<h2 className="font-semibold mb-4 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
Action History
|
Action History
|
||||||
|
{hasActions && (
|
||||||
<span className="text-xs text-muted-foreground font-normal">({actions.length})</span>
|
<span className="text-xs text-muted-foreground font-normal">({actions.length})</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
|
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
|
||||||
<ul className="space-y-4 pl-7">
|
<ul className="space-y-4 pl-7">
|
||||||
{actions.map((action, i) => (
|
{hasActions ? (
|
||||||
|
actions.map((action) => (
|
||||||
<li key={action.id} className="relative">
|
<li key={action.id} className="relative">
|
||||||
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-primary/60 border-2 border-background" />
|
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-primary/60 border-2 border-background" />
|
||||||
<div className="text-xs text-muted-foreground mb-0.5">
|
<div className="text-xs text-muted-foreground mb-0.5">
|
||||||
@@ -39,7 +47,20 @@ export function ActionTimeline({ actions }: ActionTimelineProps) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-snug">{action.action_text}</p>
|
<p className="text-sm leading-snug">{action.action_text}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<li className="relative">
|
||||||
|
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-muted-foreground/40 border-2 border-background" />
|
||||||
|
<div className="text-xs text-muted-foreground mb-0.5">
|
||||||
|
{formatDate(latestActionDate)}
|
||||||
|
<span className="ml-1.5 italic">· latest known action</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-snug">{latestActionText}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 italic">
|
||||||
|
Full history loads in the background — refresh to see all actions.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TrendingUp, Calendar, User } from "lucide-react";
|
import { TrendingUp, Calendar, User } from "lucide-react";
|
||||||
import { Bill } from "@/lib/types";
|
import { Bill } from "@/lib/types";
|
||||||
import { billLabel, 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";
|
||||||
|
|
||||||
interface BillCardProps {
|
interface BillCardProps {
|
||||||
@@ -22,9 +22,11 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
|
|||||||
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
{bill.chamber && (
|
||||||
|
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
|
||||||
{bill.chamber}
|
{bill.chamber}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ export function partyBadgeColor(party?: string): string {
|
|||||||
return "bg-slate-500 text-white";
|
return "bg-slate-500 text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function congressLabel(congress: number): string {
|
||||||
|
const lastTwo = congress % 100;
|
||||||
|
if (lastTwo >= 11 && lastTwo <= 13) return `${congress}th Congress`;
|
||||||
|
const suffixes: Record<number, string> = { 1: "st", 2: "nd", 3: "rd" };
|
||||||
|
return `${congress}${suffixes[congress % 10] ?? "th"} Congress`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chamberBadgeColor(chamber?: string): string {
|
||||||
|
if (!chamber) return "bg-muted text-muted-foreground";
|
||||||
|
const c = chamber.toLowerCase();
|
||||||
|
if (c === "senate") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400";
|
||||||
|
if (c.startsWith("house")) return "bg-slate-100 text-slate-600 dark:bg-slate-700/50 dark:text-slate-300";
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
export function trendColor(score?: number): string {
|
export function trendColor(score?: number): string {
|
||||||
if (!score) return "text-muted-foreground";
|
if (!score) return "text-muted-foreground";
|
||||||
if (score >= 70) return "text-red-500";
|
if (score >= 70) return "text-red-500";
|
||||||
|
|||||||
Reference in New Issue
Block a user