From 0c9d8269c16f3a4b48ffe13cc635eb38012d01f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 23:31:58 -0400 Subject: [PATCH] refactor(dashboard): consolidate BudgetAlertBanner dual source into helper --- .../dashboard/BudgetAlertBanner.tsx | 131 +++--------------- src/renderer/lib/budget-alerts.ts | 70 ++++++++++ 2 files changed, 90 insertions(+), 111 deletions(-) create mode 100644 src/renderer/lib/budget-alerts.ts diff --git a/src/renderer/components/dashboard/BudgetAlertBanner.tsx b/src/renderer/components/dashboard/BudgetAlertBanner.tsx index 2c1d0620..044c60a7 100644 --- a/src/renderer/components/dashboard/BudgetAlertBanner.tsx +++ b/src/renderer/components/dashboard/BudgetAlertBanner.tsx @@ -3,6 +3,7 @@ import { Warning, X } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "framer-motion"; import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types"; import { useReducedMotion } from "../ui"; +import { computeBudgetAlerts, type BudgetAlert } from "../../lib/budget-alerts"; export interface BudgetAlertBannerProps { agents: AgentRecord[]; @@ -10,18 +11,11 @@ export interface BudgetAlertBannerProps { companyId?: string; } -interface AgentOverThreshold { - id: string; - name: string; - pct: number; -} - export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) { const reducedMotion = useReducedMotion(); const [dismissed, setDismissed] = useState(false); const [incidents, setIncidents] = useState([]); - // Load policy-based incidents when companyId is provided useEffect(() => { if (!companyId) return; void (async () => { @@ -30,28 +24,15 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert })(); }, [companyId]); - // Fallback: agent-level threshold detection (existing logic) - const agentsOverThreshold = useMemo(() => { - return agents - .filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8) - .map((a) => ({ - id: a.id, - name: a.name, - pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100), - })) - .sort((a, b) => b.pct - a.pct); - }, [agents]); + const alerts = useMemo( + () => computeBudgetAlerts(incidents, agents), + [incidents, agents], + ); - // Determine whether to use policy-based or agent-based alerts - const usePolicyAlerts = companyId && incidents.length > 0; - - // Reset dismissed state when the set of alerts changes - const alertKey = useMemo(() => { - if (usePolicyAlerts) { - return incidents.map((i) => i.id).sort().join(","); - } - return agentsOverThreshold.map((a) => a.id).sort().join(","); - }, [usePolicyAlerts, incidents, agentsOverThreshold]); + const alertKey = useMemo( + () => alerts.map((a) => a.id).sort().join(","), + [alerts], + ); useEffect(() => { setDismissed(false); @@ -60,46 +41,30 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert const handleDismiss = useCallback(() => setDismissed(true), []); const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]); - // Nothing to show - if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null; + if (alerts.length === 0) return null; if (dismissed) return null; - function getScopeName(scopeType: string, scopeId: string): string { - if (scopeType === "company") return "Company"; - if (scopeType === "agent") { - const agent = agents.find((a) => a.id === scopeId); - return agent ? agent.name : scopeId.slice(0, 8); - } - return scopeId.slice(0, 8); - } - - // Policy-based alert content - const policyContent = usePolicyAlerts ? ( + const content = (
- {incidents.map((incident) => { - const isHardStop = incident.thresholdType === "hard_stop"; - const borderColor = isHardStop ? "border-[color:var(--destructive)]" : "border-[color:var(--destructive)]"; - const bgColor = isHardStop ? "bg-destructive-muted" : "bg-warning-muted"; - const textColor = isHardStop ? "text-destructive" : "text-warning"; - const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0; - + {alerts.map((alert) => { + const isDanger = alert.severity === "danger"; + const bgColor = isDanger ? "bg-destructive-muted" : "bg-warning-muted"; + const textColor = isDanger ? "text-destructive" : "text-warning"; return (
-
- {isHardStop ? "Budget Hard Stop" : "Budget Warning"} -
+
{alert.title}
- {getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%) + {alert.scopeLabel} — ${alert.observedUsd.toFixed(2)} / ${alert.limitUsd.toFixed(2)} ({alert.pct}%)
- ) : null; - - // Fallback agent-level alert content (existing logic) - const agentContent = !usePolicyAlerts ? ( -
-
-
- -
-
-
- Budget Alert -
-
- {agentsOverThreshold.length} agent(s) approaching budget limit -
-
- {agentsOverThreshold.map((agent) => ( - = 100 - ? "text-destructive" - : "text-warning" - }`} - style={{ fontVariantNumeric: "tabular-nums" }} - > - {agent.name} ({agent.pct}%) - - ))} -
- -
- -
-
- ) : null; - - const content = usePolicyAlerts ? policyContent : agentContent; + ); if (reducedMotion) { return content; diff --git a/src/renderer/lib/budget-alerts.ts b/src/renderer/lib/budget-alerts.ts new file mode 100644 index 00000000..73e89664 --- /dev/null +++ b/src/renderer/lib/budget-alerts.ts @@ -0,0 +1,70 @@ +import type { AgentRecord, BudgetIncidentRecord } from "@shared/types"; + +export type BudgetAlertSeverity = "warn" | "danger"; + +export interface BudgetAlert { + id: string; + severity: BudgetAlertSeverity; + title: string; + scopeLabel: string; + pct: number; + observedUsd: number; + limitUsd: number; +} + +/** + * Unify policy-based BudgetIncidentRecord alerts and fallback agent-level + * threshold alerts into a single `BudgetAlert[]`. + * + * When `incidents` is non-empty, policy-based alerts are used exclusively. + * Otherwise we derive alerts from agents whose month-to-date spend crosses + * 80% of their monthly budget. + */ +export function computeBudgetAlerts( + incidents: BudgetIncidentRecord[], + agents: AgentRecord[], +): BudgetAlert[] { + if (incidents.length > 0) { + return incidents.map((incident) => { + const pct = incident.amountLimit > 0 + ? Math.round((incident.amountObserved / incident.amountLimit) * 100) + : 0; + const isHardStop = incident.thresholdType === "hard_stop"; + const scopeLabel = getScopeLabel(incident.scopeType, incident.scopeId, agents); + return { + id: incident.id, + severity: isHardStop ? "danger" : "warn", + title: isHardStop ? "Budget Hard Stop" : "Budget Warning", + scopeLabel, + pct, + observedUsd: incident.amountObserved / 100, + limitUsd: incident.amountLimit / 100, + }; + }); + } + + return agents + .filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8) + .map((a): BudgetAlert => { + const pct = Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100); + return { + id: a.id, + severity: pct >= 100 ? "danger" : "warn", + title: "Budget Alert", + scopeLabel: a.name, + pct, + observedUsd: a.spentMonthlyUsd, + limitUsd: a.budgetMonthlyUsd, + }; + }) + .sort((a, b) => b.pct - a.pct); +} + +function getScopeLabel(scopeType: string, scopeId: string, agents: AgentRecord[]): string { + if (scopeType === "company") return "Company"; + if (scopeType === "agent") { + const agent = agents.find((a) => a.id === scopeId); + return agent ? agent.name : scopeId.slice(0, 8); + } + return scopeId.slice(0, 8); +}