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:
Jack Levy
2026-03-01 11:29:11 -05:00
parent 5eebc2f196
commit f3a8c1218a
5 changed files with 101 additions and 26 deletions

View File

@@ -9,7 +9,7 @@ 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 { 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 }> }) {
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">
{label}
</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>
</div>
<h1 className="text-xl font-bold leading-snug">
@@ -132,7 +136,11 @@ export default function BillDetailPage({ params }: { params: Promise<{ id: strin
)}
</div>
)}
<ActionTimeline actions={bill.actions} />
<ActionTimeline
actions={bill.actions}
latestActionDate={bill.latest_action_date}
latestActionText={bill.latest_action_text}
/>
</div>
<div className="space-y-4">
<TrendChart data={trendData} />

View File

@@ -137,6 +137,7 @@ export default function SettingsPage() {
} | null>(null);
const [testing, setTesting] = useState(false);
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
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 result = await fn();
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>;
@@ -719,25 +737,36 @@ export default function SettingsPage() {
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{name}</span>
{status === "ok" && (
{taskStatuses[key] === "running" ? (
<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>
)}
</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 && (
) : status === "needed" && count !== undefined && count > 0 ? (
<span className="text-xs text-red-600 dark:text-red-400">
{count.toLocaleString()} {countLabel}
</span>
)}
{taskIds[key] && (
<span className="text-xs text-muted-foreground">queued </span>
)}
) : null}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
</div>
<button
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>
</div>
))}