From db20f4f46e3e2f3af611cd6cb9927b5e8186e022 Mon Sep 17 00:00:00 2001 From: Sai Shankar Date: Sun, 8 Mar 2026 19:18:04 +0530 Subject: [PATCH] fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows - add company existence check on quota-windows route to guard against sentinel and forged company IDs (was a no-op assertCompanyAccess) - fix useDateRange minuteTick memo frozen at mount; realign interval to next calendar minute boundary via setTimeout + intervalRef pattern - fix midnight timer in Costs.tsx to use stable [] dep and self-scheduling todayTimerRef to avoid StrictMode double-invoke - return null for rolling window rows with no DB data instead of rendering $0.00 / 0 tok false zeros - fix secondsToWindowLabel to handle windows >168h with actual day count instead of silently falling back to 7d - fix byProvider.get(p) non-null assertion to use ?? [] fallback --- server/src/routes/costs.ts | 7 ++++ server/src/services/quota-windows.ts | 4 ++- ui/src/components/ProviderQuotaCard.tsx | 6 ++-- ui/src/hooks/useDateRange.ts | 48 +++++++++++++++++++------ ui/src/pages/Costs.tsx | 23 +++++++----- 5 files changed, 66 insertions(+), 22 deletions(-) diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index fdf5d2a4..91da4aae 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -82,6 +82,13 @@ export function costRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertBoard(req); + // validate companyId resolves to a real company so the "__none__" sentinel + // and any forged ids are rejected before we touch provider credentials + const company = await companies.getById(companyId); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } const results = await fetchAllQuotaWindows(); res.json(results); }); diff --git a/server/src/services/quota-windows.ts b/server/src/services/quota-windows.ts index 50c7da2e..b9a67b0f 100644 --- a/server/src/services/quota-windows.ts +++ b/server/src/services/quota-windows.ts @@ -169,7 +169,9 @@ function secondsToWindowLabel(seconds: number | null | undefined, fallback: stri const hours = seconds / 3600; if (hours < 6) return "5h"; if (hours <= 24) return "24h"; - return "7d"; + if (hours <= 168) return "7d"; + // for windows larger than 7d, show the actual day count rather than silently mislabelling + return `${Math.round(hours / 24)}d`; } async function fetchCodexQuota(token: string, accountId: string | null): Promise { diff --git a/ui/src/components/ProviderQuotaCard.tsx b/ui/src/components/ProviderQuotaCard.tsx index 0ebe5d93..e4d34db5 100644 --- a/ui/src/components/ProviderQuotaCard.tsx +++ b/ui/src/components/ProviderQuotaCard.tsx @@ -137,8 +137,10 @@ export function ProviderQuotaCard({
{ROLLING_WINDOWS.map((w) => { const row = windowMap.get(w); - const cents = row?.costCents ?? 0; - const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0); + // omit windows with no data rather than showing false $0.00 zeros + if (!row) return null; + const cents = row.costCents; + const tokens = row.inputTokens + row.outputTokens; const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0; return (
diff --git a/ui/src/hooks/useDateRange.ts b/ui/src/hooks/useDateRange.ts index 6d875952..2e4a9487 100644 --- a/ui/src/hooks/useDateRange.ts +++ b/ui/src/hooks/useDateRange.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom"; @@ -13,17 +13,12 @@ export const PRESET_LABELS: Record = { export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"]; -// note: computeRange calls new Date() at evaluation time. for sliding presets (7d, 30d, etc.) -// the window is computed once at render time and can be up to ~1 minute stale between re-renders. -// this is acceptable for a cost dashboard but means the displayed range may lag wall clock time -// slightly between poll ticks. +// note: computeRange is called inside a useMemo that re-evaluates once per minute +// (driven by minuteTick). this means sliding windows (7d, 30d) advance their upper +// bound at most once per minute — acceptable for a cost dashboard. function computeRange(preset: DatePreset): { from: string; to: string } { const now = new Date(); - // floor `to` to the nearest minute so the query key is stable across 30s refetch ticks - // (prevents a new cache entry being created on every poll cycle) - const toFloored = new Date(now); - toFloored.setSeconds(0, 0); - const to = toFloored.toISOString(); + const to = now.toISOString(); switch (preset) { case "mtd": { const d = new Date(now.getFullYear(), now.getMonth(), 1); @@ -47,6 +42,14 @@ function computeRange(preset: DatePreset): { from: string; to: string } { } } +// floor a Date to the nearest minute so the query key is stable across +// 30s refetch ticks (prevents new cache entries on every poll cycle) +function floorToMinute(d: Date): string { + const floored = new Date(d); + floored.setSeconds(0, 0); + return floored.toISOString(); +} + export interface UseDateRangeResult { preset: DatePreset; setPreset: (p: DatePreset) => void; @@ -66,6 +69,27 @@ export function useDateRange(): UseDateRangeResult { const [customFrom, setCustomFrom] = useState(""); const [customTo, setCustomTo] = useState(""); + // tick at the next calendar minute boundary, then every 60s, so sliding presets + // (7d, 30d) advance their upper bound in sync with wall clock minutes rather than + // drifting by the mount offset. + const intervalRef = useRef | null>(null); + const [minuteTick, setMinuteTick] = useState(() => floorToMinute(new Date())); + useEffect(() => { + const now = new Date(); + const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + const timeout = setTimeout(() => { + setMinuteTick(floorToMinute(new Date())); + intervalRef.current = setInterval( + () => setMinuteTick(floorToMinute(new Date())), + 60_000, + ); + }, msToNextMinute); + return () => { + clearTimeout(timeout); + if (intervalRef.current != null) clearInterval(intervalRef.current); + }; + }, []); + const { from, to } = useMemo(() => { if (preset !== "custom") return computeRange(preset); // treat custom date strings as local-date boundaries so the full day is included @@ -76,7 +100,9 @@ export function useDateRange(): UseDateRangeResult { from: fromDate ? fromDate.toISOString() : "", to: toDate ? toDate.toISOString() : "", }; - }, [preset, customFrom, customTo]); + // minuteTick drives re-evaluation of sliding presets once per minute. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preset, customFrom, customTo, minuteTick]); const customReady = preset !== "custom" || (!!customFrom && !!customTo); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 660c2d95..c2ed2524 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared"; import { costsApi } from "../api/costs"; @@ -71,16 +71,23 @@ export function Costs() { setBreadcrumbs([{ label: "Costs" }]); }, [setBreadcrumbs]); - // today as state so a scheduled effect can flip it at midnight, triggering a fresh weekRange + // today as state so the weekRange memo refreshes after midnight. + // stable [] dep + ref avoids the StrictMode double-invoke problem of the + // chained [today] dep pattern (which would schedule two concurrent timers). const [today, setToday] = useState(() => new Date().toDateString()); + const todayTimerRef = useRef | null>(null); useEffect(() => { - const msUntilMidnight = () => { + const schedule = () => { const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime(); + const ms = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime(); + todayTimerRef.current = setTimeout(() => { + setToday(new Date().toDateString()); + schedule(); + }, ms); }; - const timer = setTimeout(() => setToday(new Date().toDateString()), msUntilMidnight()); - return () => clearTimeout(timer); - }, [today]); + schedule(); + return () => { if (todayTimerRef.current != null) clearTimeout(todayTimerRef.current); }; + }, []); const weekRange = useMemo(() => currentWeekRange(), [today]); // ---------- spend tab queries (no polling — cost data doesn't change in real time) ---------- @@ -247,7 +254,7 @@ export function Costs() { }, ...providers.map((p) => ({ value: p, - label: , + label: , })), ]; }, [providers, byProvider]);