From 6ed5b2d62b8a3c08a2e23478c6d4078e2ebf501c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 20 Jun 2026 18:37:18 -0400 Subject: [PATCH] feat(bar): live Codex usage sync with force refresh and spend-chart periods Codex quota in the menu bar came only from frozen local session logs, so it showed stale data ("older session") that no refresh could update. Fetch it live from the same source the dashboard uses, under the existing Claude-style safety controls (10-min TTL, in-flight coalescing, 429 backoff, circuit breaker, serve-stale), falling back to local logs when offline or rate-limited. A success with no usable 5h/weekly window keeps the local reading rather than caching a contentless row. The footer refresh and a new inline button on the Codex card force a live pull past the open debounce, and a forced /summary re-pulls native rows while serving the last-known cached rows if the live pull overruns its budget, so the Claude/Codex cards never blank mid-refresh. The spend strip gains a Today / 7d / 30d selector (default 7d), a taller chart, and bottom time-axis labels (local hours for today, weekday for 7d, dates for 30d). The analytics endpoint now exposes a 24-bucket hourly series for today, converted from the pipeline's UTC hour keys to the user's local clock so the intra-day chart matches the dashboard. --- .../Sources/CCSBarApp/BarAnalyticsView.swift | 159 +++++++- .../Sources/CCSBarApp/BarCardFormatting.swift | 53 +++ macos-bar/Sources/CCSBarApp/BarMenuView.swift | 12 +- .../CCSBarApp/BarSubscriptionCard.swift | 14 + .../Sources/CCSBarApp/BarViewModel.swift | 16 + .../Sources/CCSBarCore/BarAnalytics.swift | 22 ++ .../Sources/CCSBarCore/BarChartStyle.swift | 29 ++ src/web-server/routes/bar-routes.ts | 29 +- src/web-server/usage/bar-analytics.ts | 86 ++++- .../usage/native-quota-collector.ts | 282 ++++++++++++-- tests/unit/web-server/bar-analytics.test.ts | 128 +++++++ tests/unit/web-server/bar-routes.test.ts | 131 +++++++ .../web-server/native-quota-collector.test.ts | 355 +++++++++++++++++- 13 files changed, 1263 insertions(+), 53 deletions(-) diff --git a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift index 4162a4af..8ba2c52f 100644 --- a/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift +++ b/macos-bar/Sources/CCSBarApp/BarAnalyticsView.swift @@ -26,6 +26,11 @@ struct BarAnalyticsView: View { /// Spend header (using the otherwise-blank space) so the user switches /// bars/line in place rather than digging into Settings. nil for `.breakdown`. var onToggleSpendStyle: (() -> Void)? = nil + /// Selected time window for the spend chart. Default .last7d so the rolling + /// 7-day view is the startup default, matching SpendPeriodStore's default. + var spendPeriod: SpendPeriod = .last7d + /// Callback when the user taps a period selector button. nil for .breakdown. + var onSelectPeriod: ((SpendPeriod) -> Void)? = nil private var lastActive: String? { BarFormatting.lastActiveLabel( @@ -71,14 +76,20 @@ struct BarAnalyticsView: View { !analytics.bySurface.isEmpty || !analytics.topModels.isEmpty } - /// The collapsed informational spend strip: a "SPEND" label, one muted caption - /// line, and a thin inline 30-day sparkline when there is real spend. + /// The informational spend strip: a "SPEND" label, period selector, + /// bars/line toggle, a muted caption, a taller sparkline, and axis labels. private var spendStrip: some View { VStack(alignment: .leading, spacing: 5) { + // Header row: section label | period selector | bars/line toggle. HStack(spacing: 6) { SectionLabel("Spend") Spacer() - // Inline bars/line toggle in the header's blank space — no Settings trip. + // Compact 3-segment period selector — only when there is data to chart, + // so the idle state shows no controls that would have no visible effect. + if let select = onSelectPeriod, analytics.hasRecentData { + periodSelector(onSelect: select) + } + // Inline bars/line toggle — only when there is data to render. if let toggle = onToggleSpendStyle, analytics.hasRecentData, !sparklineIsEmpty { Button(action: toggle) { Image( @@ -92,16 +103,16 @@ struct BarAnalyticsView: View { .help("Spend graph: switch to \(spendChartStyle == .bars ? "line" : "bars")") } } + if analytics.hasRecentData { Text(spendCaption) .font(.caption2) .foregroundStyle(.secondary) - if !sparklineIsEmpty { - // height: 30 (up from 18) so daily spend gradations are clearly readable. - Sparkline(values: analytics.byDay.map(\.cost), accent: theme.accent, - style: spendChartStyle) - .frame(height: 30) - } + // height: 56 — more room so per-hour or per-day gradations are readable. + Sparkline(values: activeSeries, accent: theme.accent, style: spendChartStyle) + .frame(height: 56) + // Axis labels below the chart, aligned to the same width. + axisLabelRow } else { Text(idleCaption) .font(.caption2) @@ -110,11 +121,129 @@ struct BarAnalyticsView: View { } } - /// One-line rollup: "today $NN · 7d $N.Nk · 30d $N.Nk". + /// Small 3-button period selector: "Today / 7d / 30d". + private func periodSelector(onSelect: @escaping (SpendPeriod) -> Void) -> some View { + HStack(spacing: 4) { + periodButton("Today", period: .today, onSelect: onSelect) + periodButton("7d", period: .last7d, onSelect: onSelect) + periodButton("30d", period: .last30d, onSelect: onSelect) + } + } + + private func periodButton( + _ label: String, period: SpendPeriod, onSelect: @escaping (SpendPeriod) -> Void + ) -> some View { + Button(label) { onSelect(period) } + .buttonStyle(.borderless) + .font( + spendPeriod == period + ? .system(size: 10, weight: .semibold) + : .system(size: 10)) + .foregroundStyle(spendPeriod == period ? theme.accent : Color.secondary) + } + + /// The value series for the currently-selected period. + private var activeSeries: [Double] { + switch spendPeriod { + case .today: + return analytics.byHour.map(\.cost) + case .last7d: + return Array(analytics.byDay.suffix(7)).map(\.cost) + case .last30d: + return analytics.byDay.map(\.cost) + } + } + + /// Caption showing the cost for the active period. Each period sums the SAME + /// series it charts so the caption total always matches the visible bars. private var spendCaption: String { - "today \(BarFormatting.money(analytics.today.cost))" - + " · 7d \(BarFormatting.money(analytics.last7d.cost))" - + " · 30d \(BarFormatting.money(analytics.last30d.cost))" + switch spendPeriod { + case .today: + // Sum byHour (the charted series) so caption == bars. Fall back to the + // daily today total only when there is no hourly series to draw. + let cost = + analytics.byHour.isEmpty + ? analytics.today.cost + : analytics.byHour.reduce(0) { $0 + $1.cost } + return "today \(BarFormatting.money(cost))" + case .last7d: + let cost = Array(analytics.byDay.suffix(7)).reduce(0) { $0 + $1.cost } + return "7d \(BarFormatting.money(cost))" + case .last30d: + return "30d \(BarFormatting.money(analytics.last30d.cost))" + } + } + + /// Axis labels rendered below the sparkline. Each tick is placed at its data + /// point's horizontal fraction (bar-center) so the label sits under the hour / + /// day it names — not merely evenly distributed, which drifts on the Today + /// view where the tick indices aren't at even fractions of the width. Edge + /// labels are clamped inward by their estimated half-width so they don't clip. + @ViewBuilder private var axisLabelRow: some View { + let ticks = axisTicks(for: spendPeriod) + if !ticks.isEmpty { + GeometryReader { geo in + let width = Double(geo.size.width) + ForEach(Array(ticks.enumerated()), id: \.offset) { _, tick in + // ~2.75pt per char at 9pt monospaced is half a glyph; clamp keeps the + // first/last labels fully on-screen. + let halfW = max(8.0, Double(tick.label.count) * 2.75) + let x = min(max(tick.fraction * width, halfW), max(halfW, width - halfW)) + Text(tick.label) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .fixedSize() + .position(x: x, y: 6) + } + } + .frame(height: 12) + } + } + + /// Tick (label, horizontal fraction 0...1) pairs for the current period. + /// Fraction is the bar CENTER ((i + 0.5) / count) so labels align under the + /// default bar chart; for the line style the end ticks differ by half a bar, + /// which is visually negligible. + private func axisTicks(for period: SpendPeriod) -> [(label: String, fraction: Double)] { + func center(_ i: Int, _ n: Int) -> Double { n > 0 ? (Double(i) + 0.5) / Double(n) : 0 } + switch period { + case .today: + // Hours at 0, 6, 12, 18, 23 — only those that exist. + let hours = analytics.byHour + guard !hours.isEmpty else { return [] } + let n = hours.count + return [0, 6, 12, 18, 23].compactMap { idx -> (String, Double)? in + guard idx < n, let label = BarCardFormatting.hourShort(fromHourKey: hours[idx].hour) + else { return nil } + return (label, center(idx, n)) + } + + case .last7d: + // All 7 days (or whatever suffix(7) yields): short weekday "Mon". + let days = Array(analytics.byDay.suffix(7)) + guard !days.isEmpty else { return [] } + let n = days.count + return days.enumerated().compactMap { (i, d) -> (String, Double)? in + guard let label = BarCardFormatting.weekdayShort(fromDayKey: d.date) else { return nil } + return (label, center(i, n)) + } + + case .last30d: + // 5 evenly-spaced "MMM d" labels: first, ~1/4, mid, ~3/4, last. + let days = analytics.byDay + guard days.count >= 2 else { return [] } + let n = days.count + let last = n - 1 + let indices = [0, last / 4, last / 2, last * 3 / 4, last].reduce(into: [Int]()) { acc, i in + if acc.last != i { acc.append(i) } + } + return indices.compactMap { i -> (String, Double)? in + guard i < n, let label = BarCardFormatting.monthDayShort(fromDayKey: days[i].date) + else { return nil } + return (label, center(i, n)) + } + } } /// Honest idle caption when there's no recent spend, folding in last-active. @@ -125,8 +254,10 @@ struct BarAnalyticsView: View { return headline } + /// True when the active period's series is all zero. Still shows the chart + /// frame + axis (do not hide them), but the bars/line toggle is suppressed. private var sparklineIsEmpty: Bool { - analytics.byDay.allSatisfy { $0.cost <= 0 } + activeSeries.allSatisfy { $0 <= 0 } } } diff --git a/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift index 0121faa1..c2c8fda4 100644 --- a/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift +++ b/macos-bar/Sources/CCSBarApp/BarCardFormatting.swift @@ -51,4 +51,57 @@ enum BarCardFormatting { fmt.dateFormat = "HH:mm" return fmt.string(from: date) } + + // MARK: - Axis label formatters (used by BarAnalyticsView spend chart) + + /// Short hour label from a byHour key "YYYY-MM-DD HH:00", e.g. "12a", "6p". + /// Extracts the HH part (characters at index 11-12) and converts to 12-hour + /// format with lowercase "a"/"p" suffix. Returns nil for unparseable keys. + static func hourShort(fromHourKey key: String) -> String? { + // key format: "YYYY-MM-DD HH:00" — HH is at offset 11, length 2. + guard key.count >= 13 else { return nil } + let start = key.index(key.startIndex, offsetBy: 11) + let end = key.index(start, offsetBy: 2) + guard let hour = Int(key[start.. String? { + guard let date = dayDate(fromKey: key) else { return nil } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "EEE" + return fmt.string(from: date) + } + + /// Short month+day label from a byDay key "YYYY-MM-DD", e.g. "Jun 5". + /// Returns nil for unparseable keys. Formats in UTC to match `dayDate`'s + /// parse zone so the label names the same calendar day as the key. + static func monthDayShort(fromDayKey key: String) -> String? { + guard let date = dayDate(fromKey: key) else { return nil } + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "MMM d" + return fmt.string(from: date) + } + + /// Parse a "YYYY-MM-DD" key into a Date at midnight UTC. + private static func dayDate(fromKey key: String) -> Date? { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "yyyy-MM-dd" + return fmt.date(from: key) + } } diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 4c6b9df2..5a7e08cf 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -92,8 +92,8 @@ struct BarMenuView: View { accountsSection // (3) SPEND — demoted to a thin informational strip below the cockpit. - // spendChartStyle is threaded from the viewModel and toggled inline - // from the Spend header, so a change updates the chart immediately. + // spendChartStyle and spendPeriod are threaded from the viewModel and + // toggled/selected inline from the Spend header so changes are live. if let analytics = viewModel.analytics { Divider() BarAnalyticsView( @@ -102,7 +102,9 @@ struct BarMenuView: View { onToggleSpendStyle: { viewModel.spendChartStyle = viewModel.spendChartStyle == .bars ? .line : .bars - }) + }, + spendPeriod: viewModel.spendPeriod, + onSelectPeriod: { viewModel.spendPeriod = $0 }) } // (4) POOL ACCOUNTS — compact generic rows, subordinate. @@ -185,7 +187,7 @@ struct BarMenuView: View { } else { subscriptionsHeader(parts.subscriptions) ForEach(orderedSubscriptions(parts.subscriptions)) { row in - BarSubscriptionCard(row: row) + BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() }) } } } @@ -341,7 +343,7 @@ struct BarMenuView: View { .help("Settings — appearance/theme, menu-bar glance, and alerts") Spacer() Button { - viewModel.onOpen() + viewModel.forceRefresh() } label: { Image(systemName: "arrow.clockwise") } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index 34bbbc4e..cf96fdde 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -16,6 +16,9 @@ struct BarSubscriptionCard: View { /// Injected clock — defaults to live Date() in production, pinned in previews /// and tests so countdown math is deterministic. var now: Date = Date() + /// Optional force-refresh action. When provided, the stale footnote appends a + /// compact inline refresh button so the user can reload without reopening menus. + var onRefresh: (() -> Void)? = nil private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] } @@ -219,6 +222,8 @@ struct BarSubscriptionCard: View { /// "as of HH:mm (older session)" caption when the Codex reading came from an /// older session. The bar still renders — the data is real, just not live. + /// When `onRefresh` is provided, a compact inline refresh button trails the + /// caption so the user can force-reload without extra navigation. @ViewBuilder private var staleFootnote: some View { if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) { HStack(spacing: 4) { @@ -228,6 +233,15 @@ struct BarSubscriptionCard: View { Text("as of \(clock), older session") .font(.caption2) .foregroundStyle(.tertiary) + if let refresh = onRefresh { + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 9)) + } + .buttonStyle(.borderless) + .foregroundStyle(.tertiary) + .help("Force refresh to get the latest data") + } } } } diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index cbfd324a..8a90c377 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -33,6 +33,11 @@ final class BarViewModel: ObservableObject { @Published var spendChartStyle: SpendChartStyle { didSet { SpendChartStyleStore.save(spendChartStyle) } } + /// Active time window for the spend sparkline (today/7d/30d). Persisted via + /// SpendPeriodStore; didSet mirrors the spendChartStyle pattern. + @Published var spendPeriod: SpendPeriod { + didSet { SpendPeriodStore.save(spendPeriod) } + } /// The alerts the most recent evaluation wanted delivered, surfaced in the /// dropdown so users who deny notifications still see the conditions. @Published var activeAlerts: [BarNotification] = [] @@ -71,6 +76,7 @@ final class BarViewModel: ObservableObject { self.appearance = BarAppearanceStore.load() self.glanceMode = prefs.load().glanceMode self.spendChartStyle = SpendChartStyleStore.load() + self.spendPeriod = SpendPeriodStore.load() reconnect() startBackgroundPolling() } @@ -219,6 +225,16 @@ final class BarViewModel: ObservableObject { reconnectAndLoad(force: force) } + // MARK: - Force refresh (bypasses the 15s debounce) + + /// Unconditional force-refresh: skips the debouncer and calls + /// `reconnectAndLoad(force: true)` directly. Used by the footer Refresh button + /// and by the Codex stale-footnote inline action so those never silently no-op + /// inside the debounce window. + func forceRefresh() { + reconnectAndLoad(force: true) + } + // MARK: - Start CCS (called from offline UI button) /// Trigger a full connect sequence (probe → launch → poll). Used by the diff --git a/macos-bar/Sources/CCSBarCore/BarAnalytics.swift b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift index b1a4e304..17513960 100644 --- a/macos-bar/Sources/CCSBarCore/BarAnalytics.swift +++ b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift @@ -57,6 +57,20 @@ public struct BarAnalytics: Codable, Sendable, Equatable { } } + /// Spend and request count for one hour of today. 24 entries, zero-filled, + /// oldest (00:00) to newest (23:00). The `hour` key is "YYYY-MM-DD HH:00". + public struct Hour: Codable, Sendable, Equatable, Identifiable { + public let hour: String + public let cost: Double + public let requests: Int + public var id: String { hour } + public init(hour: String, cost: Double, requests: Int) { + self.hour = hour + self.cost = cost + self.requests = requests + } + } + public let today: Window public let last7d: Window public let last30d: Window @@ -67,6 +81,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable { public let allTime: Window /// Oldest → newest, exactly 30 zero-filled entries, for the sparkline. public let byDay: [Day] + /// Today's hourly breakdown: 24 entries, oldest (00:00) → newest (23:00), + /// zero-filled. Absent from older payloads; defaults to []. + public let byHour: [Hour] public let topModels: [Model] /// "30d" when recent data exists, else "all". public let topModelsWindow: String @@ -90,6 +107,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable { monthToDate: Window = Window(cost: 0, requests: 0), allTime: Window, byDay: [Day], + byHour: [Hour] = [], topModels: [Model], topModelsWindow: String, lastActivityAt: String? = nil, @@ -104,6 +122,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable { self.monthToDate = monthToDate self.allTime = allTime self.byDay = byDay + self.byHour = byHour self.topModels = topModels self.topModelsWindow = topModelsWindow self.lastActivityAt = lastActivityAt @@ -127,6 +146,9 @@ public struct BarAnalytics: Codable, Sendable, Equatable { (try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0) allTime = try c.decode(Window.self, forKey: .allTime) byDay = try c.decode([Day].self, forKey: .byDay) + // `byHour` is a new field; older payloads omit it. Default to [] so cached + // or older-backend payloads decode cleanly without throwing. + byHour = (try c.decodeIfPresent([Hour].self, forKey: .byHour)) ?? [] topModels = try c.decode([Model].self, forKey: .topModels) topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow) lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt) diff --git a/macos-bar/Sources/CCSBarCore/BarChartStyle.swift b/macos-bar/Sources/CCSBarCore/BarChartStyle.swift index b4befb45..0d629094 100644 --- a/macos-bar/Sources/CCSBarCore/BarChartStyle.swift +++ b/macos-bar/Sources/CCSBarCore/BarChartStyle.swift @@ -33,3 +33,32 @@ public enum SpendChartStyleStore { UserDefaults.standard.set(style.rawValue, forKey: defaultsKey) } } + +// MARK: - SpendPeriod + +/// Time window for the spend sparkline selector: today (hourly), last 7 days, +/// or last 30 days. Mirrors the SpendChartStyle pattern: CaseIterable + Sendable. +public enum SpendPeriod: String, CaseIterable, Sendable { + case today + case last7d + case last30d +} + +// MARK: - SpendPeriodStore + +/// Persists the chosen spend period. Mirrors SpendChartStyleStore exactly: +/// a UserDefaults key, a static load, and a static save. The `?? .last7d` +/// fallback is the sole source of the default. +public enum SpendPeriodStore { + public static let defaultsKey = "ccsbar.spendPeriod" + + public static func load() -> SpendPeriod { + let raw = UserDefaults.standard.string(forKey: defaultsKey) + ?? SpendPeriod.last7d.rawValue + return SpendPeriod(rawValue: raw) ?? .last7d + } + + public static func save(_ period: SpendPeriod) { + UserDefaults.standard.set(period.rawValue, forKey: defaultsKey) + } +} diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 90abafa2..c6d8af96 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -150,8 +150,17 @@ export interface BarRouterDeps { * async so older tests that build deps without it keep passing. The native * collector owns its own long-TTL cache + safety controls, so this is cheap * to call per request. + * + * `opts.force` is forwarded when a debounce-passing refresh=true is in flight, + * so the native rows are re-pulled live alongside the CLIProxy rows. */ - getNativeAccountRows?: () => Promise; + getNativeAccountRows?: (opts?: { force?: boolean }) => Promise; + /** + * Last-known native rows from cache, with NO fetch. Used as an instant + * fallback when a forced live native re-pull overruns the side-load budget, + * so the Claude/Codex cards never vanish mid-refresh. Defaults to []. + */ + getCachedNativeRows?: () => BarSummaryRow[]; } // ============================================================================ @@ -547,10 +556,17 @@ export function createBarRouter(deps: BarRouterDeps): Router { const rows = await Promise.race([gather, deadline]); // Native subscription rows (Claude Code + Codex) are side-loaded AFTER the - // CLIProxy rows resolve, bounded so a slow/failed native fetch degrades to - // [] rather than blocking or erroring the response. + // CLIProxy rows resolve, bounded so a slow/failed native fetch degrades + // rather than blocking or erroring the response. Pass force so a + // debounce-passing refresh also re-pulls native rows live. On timeout fall + // back to the last-known cached native rows (NOT []) so a slow forced + // re-pull never momentarily drops the Claude/Codex cards; the in-flight + // fetch keeps warming the cache for the next poll. const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); - const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? []; + const getCachedNative = deps.getCachedNativeRows ?? (() => [] as BarSummaryRow[]); + const nativeRows = + (await withTimeout(getNative({ force: doForceRefresh }), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? + getCachedNative(); res.json([...rows, ...nativeRows].map(serializeBarRow)); } catch (err) { @@ -607,7 +623,7 @@ import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; import { getTodayCostByAccount } from '../usage/data-aggregator'; import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader'; import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator'; -import { getNativeAccountRows } from '../usage/native-quota-collector'; +import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/native-quota-collector'; /** Production bar router — wired to real dependencies */ const barRouter: Router = createBarRouter({ @@ -620,7 +636,8 @@ const barRouter: Router = createBarRouter({ loadCliproxyDetails: loadCliproxySnapshotDetails, loadDailyUsage: () => getCachedDailyData(), loadHourlyUsage: () => getCachedHourlyData(), - getNativeAccountRows: () => getNativeAccountRows(), + getNativeAccountRows: (opts) => getNativeAccountRows(undefined, opts), + getCachedNativeRows: getCachedNativeAccountRows, }); export default barRouter; diff --git a/src/web-server/usage/bar-analytics.ts b/src/web-server/usage/bar-analytics.ts index 45fc6ff6..df4fb5d1 100644 --- a/src/web-server/usage/bar-analytics.ts +++ b/src/web-server/usage/bar-analytics.ts @@ -21,6 +21,20 @@ export interface BarAnalyticsDay { requests: number; } +/** + * One hour's roll-up for today's spend chart. + * `hour` is the user's LOCAL "YYYY-MM-DD HH:00" clock hour (the UTC keys from + * HourlyUsage are converted to local before bucketing). Covers exactly 24 + * buckets (00:00..23:00) for today only; zero-filled when no activity was + * recorded in that hour. + */ +export interface BarAnalyticsHour { + /** Local "YYYY-MM-DD HH:00" clock-hour bucket key. */ + hour: string; + cost: number; + requests: number; +} + /** * One usage surface's contribution to spend over the active window. * A "surface" is the tool/origin a request came from (Claude Code, Codex, the @@ -64,6 +78,14 @@ export interface BarAnalytics { allTime: BarAnalyticsWindow; /** Oldest → newest, exactly 30 entries (zero-filled), for the sparkline. */ byDay: BarAnalyticsDay[]; + /** + * Hourly spend + request counts for today only. Exactly 24 entries ordered + * 00:00 → 23:00 (local), zero-filled for hours with no activity. Used by the + * bar's intra-day spend chart. `hour` keys are "YYYY-MM-DD HH:00" local time + * matching HourlyUsage.hour. Empty array from the snapshot-only path + * (computeBarAnalytics) which carries no hourly dimension. + */ + byHour: BarAnalyticsHour[]; /** Highest-spend models (descending, capped) for the window in `topModelsWindow`. */ topModels: BarAnalyticsModel[]; /** Which window `topModels` covers — the most recent one that has data. */ @@ -231,6 +253,9 @@ export function computeBarAnalytics( monthToDate, allTime, byDay: Array.from(dayBuckets.values()), + // The snapshot-detail path has no hourly dimension; return empty so the wire + // shape is stable and callers can always iterate byHour safely. + byHour: [], topModels, topModelsWindow: recentHasData ? '30d' : 'all', // The snapshot-detail path has no surface attribution; the daily path does. @@ -275,6 +300,25 @@ function dateFromDayKey(key: string): Date | null { return date; } +/** + * Convert a "YYYY-MM-DD HH:00" hour key into the user's LOCAL Date. + * + * The usage pipeline builds hour keys by slicing the raw ISO timestamp, which is + * UTC for the CLIProxy/Codex/Claude sources, so the key's HH is a UTC hour. The + * dashboard's 24H chart treats these keys as UTC and renders them in local time; + * we do the same here so the bar's intra-day chart shows the user's own clock + * hours (not UTC) and so a late-local-evening record — which is the next day in + * UTC — still lands in today's local chart. Returns null for unparseable keys. + */ +function localDateFromHourKey(key: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):/.exec(key); + if (!match) return null; + const date = new Date( + Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), 0, 0) + ); + return Number.isNaN(date.getTime()) ? null : date; +} + /** * Roll the merged, multi-source usage aggregates into the bar analytics payload. * @@ -300,6 +344,21 @@ export function computeBarAnalyticsFromDaily( // so slice(0,7) yields the local YYYY-MM to compare against the current month. const currentMonth = localMonthKey(now); + // Today's local day key — used to seed the 24 intra-day buckets for the + // spend chart and to filter which hourly records belong to today (local). + const todayKey = localDayKey(now); + + // Seed 24 zero-filled hour buckets for today, ordered 00 → 23, keyed by the + // user's LOCAL clock hour ("YYYY-MM-DD HH:00" in local time). The UTC hour + // keys from the usage pipeline are converted to local before being bucketed + // here (see localDateFromHourKey), so the chart shows the user's own hours. + const hourBuckets = new Map(); + for (let h = 0; h < 24; h++) { + const hh = String(h).padStart(2, '0'); + const key = `${todayKey} ${hh}:00`; + hourBuckets.set(key, { hour: key, cost: 0, requests: 0 }); + } + const dayBuckets = new Map(); for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); @@ -372,17 +431,40 @@ export function computeBarAnalyticsFromDaily( } // Pass 2 — hourly: request counts (daily aggregates don't carry them). + // Also fills the 24-bucket intra-day spend chart for today (local time). for (const h of hourly) { if (!h || !h.hour) continue; + + // Intra-day hourly buckets (LOCAL): convert the UTC hour key to the user's + // local time and bucket today's local hours. Done BEFORE the UTC `delta` + // gate below so a late-local-evening record — next day in UTC — still lands + // in today's chart. Cost precedence mirrors the daily pass (totalCost, then + // cost). Independent of the UTC day-key math the rest of this loop uses. + const localHourDate = localDateFromHourKey(h.hour); + if (localHourDate && localDayKey(localHourDate) === todayKey) { + const hCost = Number.isFinite(h.totalCost) + ? h.totalCost + : Number.isFinite(h.cost) + ? h.cost + : 0; + const localHH = String(localHourDate.getHours()).padStart(2, '0'); + const hBucket = hourBuckets.get(`${todayKey} ${localHH}:00`); + if (hBucket) { + hBucket.cost += hCost; + hBucket.requests += Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0; + } + } + const dayKey = h.hour.slice(0, 10); const activityDate = dateFromDayKey(dayKey); if (!activityDate) continue; const delta = dayDelta(now, activityDate); if (delta < 0) continue; const requests = Number.isFinite(h.requestCount) ? (h.requestCount as number) : 0; - if (requests <= 0) continue; const source = h.source || ''; + if (requests <= 0) continue; + allTime.requests += requests; bumpSurface(surfaceAll, source, 0, requests); touchActivity(dayKey); @@ -422,6 +504,8 @@ export function computeBarAnalyticsFromDaily( monthToDate, allTime, byDay: Array.from(dayBuckets.values()), + // 24 hour buckets for today (00:00 → 23:00 local), zero-filled. + byHour: Array.from(hourBuckets.values()), topModels, topModelsWindow: recentHasData ? '30d' : 'all', bySurface, diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 80ea8eef..bbddccf1 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -2,11 +2,11 @@ * Native subscription quota collector — the ONLY server-side fetch surface for * the user's own Claude Code + Codex subscription quota. * - * The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic. All - * Anthropic traffic originates here, under strict safety controls, because the - * OAuth usage endpoint is undocumented and hostile to polling (persistent 429s, - * no Retry-After, first-party-only policy). The controls below exist to protect - * the user's account: + * The macOS bar reads localhost /api/bar/summary and NEVER calls Anthropic or + * ChatGPT. All upstream traffic originates here, under strict safety controls, + * because these endpoints are undocumented and hostile to polling (persistent + * 429s, no Retry-After, first-party-only policy). The controls below exist to + * protect the user's accounts: * * - long TTL (10 min) on-demand cache, never a tight timer loop * - in-flight coalescing so concurrent /summary calls share one fetch @@ -14,7 +14,10 @@ * - circuit breaker stops calling after repeated 429s for a cooldown * - serve-stale-on-failure; only omit a row when there is genuinely no data * - * Codex is a pure local file read (no network), so it skips the network guards. + * Claude path: reads native credentials + polls api.anthropic.com/api/oauth/usage. + * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via + * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring + * the same safety pattern as the Claude path. */ import { @@ -25,8 +28,10 @@ import { type ClaudeNativeCredentials, } from './claude-native-credentials'; import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; +import { fetchCodexQuota } from '../../cliproxy/quota/quota-fetcher-codex'; +import { getDefaultAccount } from '../../cliproxy/accounts/query'; import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; -import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types'; +import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types'; import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; // ============================================================================ @@ -56,7 +61,7 @@ const CLAUDE_PROVIDER = 'claude-code'; const CODEX_PROVIDER = 'codex'; // ============================================================================ -// Injectable dependencies (tests inject mocks; never live Anthropic in CI) +// Injectable dependencies (tests inject mocks; never live endpoints in CI) // ============================================================================ export interface NativeQuotaDeps { @@ -64,7 +69,17 @@ export interface NativeQuotaDeps { readCredentials?: () => ClaudeNativeCredentials | null; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; - /** Read Codex quota from local session logs (zero network). */ + /** + * Resolve the default Codex account ID for network quota fetch. + * Returns null when no Codex account is configured (bar omits the live path). + */ + getDefaultCodexAccountId?: () => string | null; + /** + * Fetch Codex quota live from the network. + * Injected so tests never hit chatgpt.com. + */ + fetchCodexNetworkQuota?: (accountId: string) => Promise; + /** Read Codex quota from local session logs (zero network, fallback). */ getCodexQuota?: () => Promise; /** Clock seam for deterministic backoff/TTL/breaker tests. */ now?: () => number; @@ -106,10 +121,12 @@ function freshProviderState(): ProviderState { } const claudeState = freshProviderState(); +const codexState = freshProviderState(); /** Reset all module state. Tests call this to avoid cross-test pollution. */ export function resetNativeQuotaState(): void { Object.assign(claudeState, freshProviderState()); + Object.assign(codexState, freshProviderState()); } // ============================================================================ @@ -297,6 +314,75 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { }; } +/** + * Build the Codex row from a LIVE network quota result. + * + * Uses coreUsage (5h/weekly) to produce QuotaWindowDetail entries with the same + * stable keys as the Claude path. quota_percentage = min remaining across present + * core windows. next_reset = soonest core resetAt. No staleAsOf on a live result. + */ +function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryRow { + const windows: QuotaWindowDetail[] = []; + + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + + // quota_percentage = min remaining across the windows present (mirrors Claude derivation) + const coreWindows = [fiveHour, weekly].filter((w): w is NonNullable => !!w); + const quotaPercentage = + coreWindows.length > 0 ? Math.min(...coreWindows.map((w) => w.remainingPercent)) : null; + + // next_reset = soonest resetAt across present core windows + const resets = coreWindows + .map((w) => w.resetAt) + .filter((r): r is string => typeof r === 'string') + .map((r) => ({ iso: r, ms: new Date(r).getTime() })) + .filter((r) => Number.isFinite(r.ms)) + .sort((a, b) => a.ms - b.ms); + const nextReset = resets.length > 0 ? resets[0].iso : null; + + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.planType ?? null, + paused: false, + quota_percentage: quotaPercentage, + quotaStatus: 'ok', + next_reset: nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + // No staleAsOf — live data is always fresh. + ...(windows.length > 0 ? { quotaWindows: windows } : {}), + }; +} + /** Return the cached row marked cached=true (used for TTL + stale serving). */ function serveCached(state: ProviderState): BarSummaryRow | null { if (!state.cachedRow) return null; @@ -307,16 +393,20 @@ function serveCached(state: ProviderState): BarSummaryRow | null { // Claude path with full safety controls // ============================================================================ -async function collectClaudeRow(deps: NativeQuotaDeps): Promise { +async function collectClaudeRow( + deps: NativeQuotaDeps, + force = false +): Promise { const now = (deps.now ?? Date.now)(); const state = claudeState; - // Serve from cache while within TTL — on-demand only, NO network. - if (state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { return serveCached(state); } // Breaker open or cooldown active -> zero network, serve stale (may be null). + // Force does NOT bypass the breaker — it protects the account. if (now < state.breakerOpenUntil || now < state.cooldownUntil) { return serveCached(state); } @@ -419,19 +509,140 @@ async function collectClaudeRow(deps: NativeQuotaDeps): Promise { +async function collectCodexRow( + deps: NativeQuotaDeps, + force = false +): Promise { const now = (deps.now ?? Date.now)(); - const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; - try { - const quota = await getCodex(); - if (!quota) return null; // exec-mode / no rate_limits -> omit the row - return buildCodexRow(quota, now); - } catch { - return null; + const state = codexState; + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); } + + // Breaker open or cooldown active -> skip network, go to LOCAL fallback. + // Force does NOT bypass the breaker — it protects the account. + const breakerOrCooldownActive = now < state.breakerOpenUntil || now < state.cooldownUntil; + + // Coalesce: concurrent callers past TTL share one in-flight resolution. + if (state.pending) { + return state.pending; + } + + const getDefaultAccountId = + deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + const fetchNetwork = + deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); + const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + // ---------------------------------------------------------------- + // PRIMARY: live network fetch (skipped when breaker/cooldown active) + // ---------------------------------------------------------------- + if (!breakerOrCooldownActive) { + const accountId = getDefaultAccountId(); + if (accountId) { + const quota = await fetchNetwork(accountId); + + if (quota.success) { + // A healthy response closes the breaker and clears backoff, + // regardless of content. + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + // Only usable when at least one core window (5h/weekly) resolved. A + // success with empty coreUsage (only code-review/additional windows, + // or a changed payload) carries no glanceable signal — do NOT cache + // a contentless "ok" row or clobber a good cache; fall through to + // the local fallback so the bar shows real data instead. + if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { + const row = buildCodexNetworkRow(quota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else: fall through to LOCAL fallback below. + } else if (quota.needsReauth) { + // Token expired -> reauth row; do NOT cache as a good value. + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: null, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + } else if (quota.httpStatus === 429) { + // 429: apply breaker + backoff, then fall through to local. + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; // retained as an injectable seam for future inline retry + } else if (quota.retryable) { + // Other transient failure: set cooldown, fall through to local. + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } else { + // Terminal non-retryable failure (e.g. 403/404): back off so we + // don't re-hit a dead endpoint every poll when no local data caches + // a row to engage the TTL short-circuit. Then fall through to local. + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + // Fall through to LOCAL fallback below. + } + // No configured accountId -> fall through to local fallback. + } + + // ---------------------------------------------------------------- + // LOCAL FALLBACK: session log read (zero network, always attempted + // when network is unavailable / no accountId / breaker active) + // ---------------------------------------------------------------- + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRow(localQuota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + // No local data either: serve stale (may be null). + return serveCached(state); + } catch { + // Network/parse rejection -> treat as transient, serve stale. + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; } // ============================================================================ @@ -443,11 +654,19 @@ async function collectCodexRow(deps: NativeQuotaDeps): Promise { +export async function getNativeAccountRows( + deps: NativeQuotaDeps = {}, + opts?: { force?: boolean } +): Promise { + const force = opts?.force ?? false; const [claude, codex] = await Promise.all([ - collectClaudeRow(deps).catch(() => null), - collectCodexRow(deps).catch(() => null), + collectClaudeRow(deps, force).catch(() => null), + collectCodexRow(deps, force).catch(() => null), ]); const rows: BarSummaryRow[] = []; @@ -455,3 +674,18 @@ export async function getNativeAccountRows(deps: NativeQuotaDeps = {}): Promise< if (codex) rows.push(codex); return rows; } + +/** + * Last-known native rows from cache, WITHOUT any fetch (instant, no network). + * + * Used as a non-blocking fallback by /summary: when a forced live re-pull + * overruns the native side-load budget, the response serves these cached rows + * instead of dropping the Claude/Codex cards entirely. The in-flight fetch keeps + * warming the cache, so the next poll shows the fresh values. + */ +export function getCachedNativeAccountRows(): BarSummaryRow[] { + const rows: BarSummaryRow[] = []; + if (claudeState.cachedRow) rows.push({ ...claudeState.cachedRow, cached: true }); + if (codexState.cachedRow) rows.push({ ...codexState.cachedRow, cached: true }); + return rows; +} diff --git a/tests/unit/web-server/bar-analytics.test.ts b/tests/unit/web-server/bar-analytics.test.ts index e9bb3f89..6060f33c 100644 --- a/tests/unit/web-server/bar-analytics.test.ts +++ b/tests/unit/web-server/bar-analytics.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'bun:test'; import { computeBarAnalytics, computeBarAnalyticsFromDaily, + localDayKey, } from '../../../src/web-server/usage/bar-analytics'; import type { CliproxyUsageHistoryDetail } from '../../../src/web-server/usage/cliproxy-usage-transformer'; import type { DailyUsage, HourlyUsage } from '../../../src/web-server/usage/types'; @@ -251,3 +252,130 @@ describe('computeBarAnalyticsFromDaily — monthToDate', () => { expect(a.monthToDate).toEqual({ cost: 0, requests: 0 }); }); }); + +// ============================================================================ +// byHour — 24-bucket intra-day spend chart for today +// ============================================================================ + +describe('byHour — intra-day spend chart', () => { + // NOW = 2026-06-08T12:00:00-04:00. byHour buckets are the user's LOCAL clock + // hours; the pipeline's hour keys are UTC and get converted to local. These + // tests build UTC keys FROM the desired local time so they pass regardless of + // the test runner's timezone (the round-trip is machine-TZ-independent). + const todayKey = localDayKey(NOW); + const pad = (n: number): string => String(n).padStart(2, '0'); + + /** A machine-local Date at `hour` o'clock on NOW's local calendar day (+dayOffset). */ + function localDayAt(hour: number, dayOffset = 0): Date { + return new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() + dayOffset, hour, 0, 0); + } + + /** The UTC "YYYY-MM-DD HH:00" key that converts back to `local`'s clock hour. */ + function utcHourKeyForLocal(local: Date): string { + return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad( + local.getUTCDate() + )} ${pad(local.getUTCHours())}:00`; + } + + it('computeBarAnalytics returns byHour as empty array (snapshot path has no hourly)', () => { + const a = computeBarAnalytics([], NOW); + expect(Array.isArray(a.byHour)).toBe(true); + expect(a.byHour).toHaveLength(0); + }); + + it('computeBarAnalyticsFromDaily returns exactly 24 hour buckets', () => { + const a = computeBarAnalyticsFromDaily([], [], NOW); + expect(a.byHour).toHaveLength(24); + }); + + it('hour buckets are LOCAL, ordered 00:00 → 23:00', () => { + const a = computeBarAnalyticsFromDaily([], [], NOW); + expect(a.byHour[0].hour).toBe(`${todayKey} 00:00`); + expect(a.byHour[23].hour).toBe(`${todayKey} 23:00`); + for (let i = 1; i < 24; i++) { + expect(a.byHour[i].hour > a.byHour[i - 1].hour).toBe(true); + } + }); + + it('hours with no data are zero-filled', () => { + const a = computeBarAnalyticsFromDaily([], [], NOW); + for (const h of a.byHour) { + expect(h.cost).toBe(0); + expect(h.requests).toBe(0); + } + }); + + it('a UTC hour key lands in the matching LOCAL hour bucket (not the raw UTC hour)', () => { + // Activity at local 10:00 today, encoded as its UTC key. + const a = computeBarAnalyticsFromDaily( + [], + [ + hourly({ hour: utcHourKeyForLocal(localDayAt(10)), totalCost: 3.5, requestCount: 2 }), + hourly({ hour: utcHourKeyForLocal(localDayAt(14)), totalCost: 1.0, requestCount: 1 }), + ], + NOW + ); + expect(a.byHour[10].hour).toBe(`${todayKey} 10:00`); + expect(a.byHour[10].cost).toBeCloseTo(3.5); + expect(a.byHour[10].requests).toBe(2); + expect(a.byHour[14].cost).toBeCloseTo(1.0); + expect(a.byHour[14].requests).toBe(1); + }); + + it('late local-evening activity (next day in UTC) still lands in TODAY', () => { + // local 23:00 today is the next calendar day in any UTC-negative zone; it + // must NOT be dropped from today's chart (regression guard for the TZ bug). + const a = computeBarAnalyticsFromDaily( + [], + [hourly({ hour: utcHourKeyForLocal(localDayAt(23)), totalCost: 7, requestCount: 4 })], + NOW + ); + expect(a.byHour[23].cost).toBeCloseTo(7); + expect(a.byHour[23].requests).toBe(4); + // Every other hour stays zero. + for (let i = 0; i < 23; i++) expect(a.byHour[i].cost).toBe(0); + }); + + it('yesterday-local activity does NOT land in today buckets', () => { + const a = computeBarAnalyticsFromDaily( + [], + [hourly({ hour: utcHourKeyForLocal(localDayAt(10, -1)), totalCost: 99, requestCount: 10 })], + NOW + ); + for (const h of a.byHour) { + expect(h.cost).toBe(0); + expect(h.requests).toBe(0); + } + }); + + it('accumulates multiple sources into the same local hour bucket', () => { + const key = utcHourKeyForLocal(localDayAt(9)); + const a = computeBarAnalyticsFromDaily( + [], + [ + hourly({ hour: key, source: 'custom-parser', totalCost: 2.0, requestCount: 3 }), + hourly({ hour: key, source: 'codex-native', totalCost: 1.5, requestCount: 1 }), + ], + NOW + ); + expect(a.byHour[9].cost).toBeCloseTo(3.5); + expect(a.byHour[9].requests).toBe(4); + }); + + it('prefers finite totalCost over cost (0 is a valid total)', () => { + const a = computeBarAnalyticsFromDaily( + [], + [ + hourly({ + hour: utcHourKeyForLocal(localDayAt(11)), + cost: 2.2, + totalCost: 0, + requestCount: 1, + }), + ], + NOW + ); + // totalCost = 0 is finite so it wins — the bucket stays 0. + expect(a.byHour[11].cost).toBeCloseTo(0); + }); +}); diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 196e2316..83ce1956 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -987,6 +987,137 @@ describe('today_cost: duplicate-email accounts get null (finding #11)', () => { }); }); +// ============================================================================ +// force flag threading: refresh=true passes {force:true} to getNativeAccountRows +// ============================================================================ + +describe('/summary force flag passed to getNativeAccountRows', () => { + async function buildForceFlagRouter( + onNativeCall: (opts: { force?: boolean } | undefined) => void + ) { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + + const app = express(); + app.use(express.json()); + + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + getNativeAccountRows: async (opts) => { + onNativeCall(opts); + return []; + }, + }); + + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` }; + } + + it('refresh=true (after debounce reset) passes { force: true } to getNativeAccountRows', async () => { + const calls: Array<{ force?: boolean } | undefined> = []; + const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts)); + + await getJson(url, '/api/bar/summary?refresh=true'); + await new Promise((resolve) => srv.close(() => resolve())); + + expect(calls.length).toBeGreaterThan(0); + // The first (and only) call must carry force: true + expect(calls[0]?.force).toBe(true); + }); + + it('refresh=false (no query param) passes { force: false } to getNativeAccountRows', async () => { + const calls: Array<{ force?: boolean } | undefined> = []; + const { srv, url } = await buildForceFlagRouter((opts) => calls.push(opts)); + + await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + expect(calls.length).toBeGreaterThan(0); + // No refresh param → doForceRefresh is false + expect(calls[0]?.force).toBe(false); + }); + + it('falls back to cached native rows when the live native side-load fails (cards do not vanish)', async () => { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + const cachedCodex: BarSummaryRow = { + account_id: 'codex', + provider: 'codex', + displayName: 'Codex', + tier: 'pro', + paused: false, + quota_percentage: 42, + quotaStatus: 'ok', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: true, + fetchedAt: '2026-06-09T14:00:00.000Z', + needsReauth: false, + }; + + const app = express(); + app.use(express.json()); + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [makeAccountInfo()] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + // Live native fetch unavailable → withTimeout resolves null → fallback. + getNativeAccountRows: async () => { + throw new Error('native unavailable'); + }, + getCachedNativeRows: () => [cachedCodex], + }); + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + const url = `http://127.0.0.1:${(addr as { port: number }).port}`; + + const { status, body } = await getJson(url, '/api/bar/summary?refresh=true'); + await new Promise((resolve) => srv.close(() => resolve())); + + // Response is healthy and the cached Codex row is served, not dropped. + expect(status).toBe(200); + expect(body.some((r) => r.provider === 'codex')).toBe(true); + }); +}); + // ============================================================================ // Native subscription rows side-load into /summary // ============================================================================ diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index cd66991b..344c2c7e 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -1,18 +1,21 @@ /** * Tests for the native subscription quota collector. * - * The Anthropic fetch is ALWAYS mocked — these tests NEVER hit the live usage - * endpoint. A controllable clock drives TTL / backoff / breaker assertions. + * Neither the Anthropic nor the ChatGPT endpoint is ever hit — all fetches are + * injected via NativeQuotaDeps. A controllable clock drives TTL / backoff / + * breaker assertions for both the Claude and Codex paths. */ import { beforeEach, describe, expect, it } from 'bun:test'; import { getNativeAccountRows, + getCachedNativeAccountRows, resetNativeQuotaState, type NativeQuotaDeps, } from '../../../src/web-server/usage/native-quota-collector'; -import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types'; +import type { ClaudeQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota/quota-types'; import type { ClaudeNativeCredentials } from '../../../src/web-server/usage/claude-native-credentials'; +import type { CodexLocalQuota } from '../../../src/web-server/usage/codex-local-quota-collector'; // A jump comfortably past any single-call backoff cooldown (<= 60s), used so a // breaker-test fetch is not blocked by the prior 429's per-call cooldown. @@ -351,11 +354,331 @@ describe('stale-on-fail', () => { }); }); +// ============================================================================ +// Codex network helpers +// ============================================================================ + +function codexSuccessQuota(): CodexQuotaResult { + return { + success: true, + windows: [], + coreUsage: { + fiveHour: { + label: 'Primary', + remainingPercent: 60, + resetAfterSeconds: 3600, + resetAt: '2026-06-09T19:00:00.000Z', + }, + weekly: { + label: 'Secondary', + remainingPercent: 80, + resetAfterSeconds: 86400 * 4, + resetAt: '2026-06-13T00:00:00.000Z', + }, + }, + planType: 'pro', + lastUpdated: Date.now(), + accountId: 'codex-user@example.com', + }; +} + +function codexRateLimitedQuota(retryAfter?: string): CodexQuotaResult { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + accountId: 'codex-user@example.com', + httpStatus: 429, + retryable: true, + ...(retryAfter ? { errorDetail: `retry-after:${retryAfter}` } : {}), + error: 'rate limited', + }; +} + +function codexReatuhQuota(): CodexQuotaResult { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + accountId: 'codex-user@example.com', + needsReauth: true, + error: 'Token expired', + }; +} + +function codexLocalQuota(): CodexLocalQuota { + return { + quotaPercentage: 30, + nextReset: '2026-06-09T19:00:00.000Z', + tier: 'pro', + stale: true, + staleAsOf: '2026-06-09T13:30:00.000Z', + windows: [ + { + key: 'five_hour', + label: '5h', + usedPercent: 70, + remainingPercent: 30, + resetAt: '2026-06-09T19:00:00.000Z', + windowMinutes: 300, + }, + ], + }; +} + +/** + * Build a NativeQuotaDeps that completely stubs the Codex path. + * Claude path is disabled (no credentials) so only the Codex row is produced. + */ +function makeCodexDeps( + overrides: Partial & { clock: { now: number } } +): NativeQuotaDeps & { networkCount: () => number; localCount: () => number } { + let networkCalls = 0; + let localCalls = 0; + const { clock, ...rest } = overrides; + return { + // Disable Claude path + readCredentials: () => null, + // Default network quota: success + fetchCodexNetworkQuota: async (_id: string) => { + networkCalls += 1; + return codexSuccessQuota(); + }, + // Default local fallback: stale data + getCodexQuota: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + // Default account id resolved + getDefaultCodexAccountId: () => 'codex-user@example.com', + now: () => clock.now, + sleep: async () => {}, + // Allow overriding any dep + ...rest, + networkCount: () => networkCalls, + localCount: () => localCalls, + }; +} + +// ============================================================================ +// Codex network path tests +// ============================================================================ + +describe('Codex network path', () => { + it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => { + const clock = { now: 1_000_000 }; + const deps = makeCodexDeps({ clock }); + + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + + expect(row).toBeDefined(); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.health).toBe('ok'); + expect(row?.tier).toBe('pro'); + expect(row?.needsReauth).toBe(false); + // No staleAsOf on a live result + expect(row?.staleAsOf).toBeUndefined(); + // quota_percentage = min(60, 80) = 60 + expect(row?.quota_percentage).toBe(60); + // next_reset = soonest = fiveHour resetAt + expect(row?.next_reset).toBe('2026-06-09T19:00:00.000Z'); + // quotaWindows: five_hour + seven_day + expect(row?.quotaWindows).toHaveLength(2); + const fiveHr = row?.quotaWindows?.find((w) => w.key === 'five_hour'); + expect(fiveHr?.label).toBe('5h'); + expect(fiveHr?.remainingPercent).toBe(60); + expect(fiveHr?.usedPercent).toBe(40); + expect(fiveHr?.windowMinutes).toBe(300); + expect(fiveHr?.resetAt).toBe('2026-06-09T19:00:00.000Z'); + const week = row?.quotaWindows?.find((w) => w.key === 'seven_day'); + expect(week?.label).toBe('week'); + expect(week?.remainingPercent).toBe(80); + expect(week?.usedPercent).toBe(20); + expect(week?.windowMinutes).toBe(10080); + // Network was called; local was NOT (network succeeded) + expect(deps.networkCount()).toBe(1); + expect(deps.localCount()).toBe(0); + }); + + it('network failure falls back to local stale row — staleAsOf set, health warning', async () => { + const clock = { now: 1_000_000 }; + const deps = makeCodexDeps({ + clock, + fetchCodexNetworkQuota: async () => ({ + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'network error', + retryable: true, + }), + }); + + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + + expect(row).toBeDefined(); + expect(row?.health).toBe('warning'); + expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z'); + expect(deps.localCount()).toBe(1); + }); + + it('force bypasses TTL and re-fetches from network', async () => { + const clock = { now: 1_000_000 }; + const deps = makeCodexDeps({ clock }); + + // Prime the cache + await getNativeAccountRows(deps); + expect(deps.networkCount()).toBe(1); + + // Normal call within TTL: served from cache + clock.now += 5 * 60 * 1000; + await getNativeAccountRows(deps); + expect(deps.networkCount()).toBe(1); + + // Force call: bypasses TTL, re-fetches + await getNativeAccountRows(deps, { force: true }); + expect(deps.networkCount()).toBe(2); + }); + + it('token_expired (needsReauth) returns a reauth row — not cached as good', async () => { + const clock = { now: 1_000_000 }; + const deps = makeCodexDeps({ + clock, + fetchCodexNetworkQuota: async () => codexReatuhQuota(), + }); + + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + + expect(row?.quotaStatus).toBe('error'); + expect(row?.health).toBe('error'); + expect(row?.needsReauth).toBe(true); + // Local fallback was NOT called (reauth is a distinct path, not a transient error) + expect(deps.localCount()).toBe(0); + }); + + it('breaker-open path: skips network and serves local fallback', async () => { + const clock = { now: 1_000_000 }; + let networkCalls = 0; + let localCalls = 0; + + const deps: NativeQuotaDeps & { networkCount: () => number; localCount: () => number } = { + readCredentials: () => null, + getDefaultCodexAccountId: () => 'codex-user@example.com', + fetchCodexNetworkQuota: async () => { + networkCalls += 1; + return codexRateLimitedQuota(); + }, + getCodexQuota: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + now: () => clock.now, + sleep: async () => {}, + networkCount: () => networkCalls, + localCount: () => localCalls, + }; + + // Trip the breaker with 3 consecutive 429s + for (let i = 0; i < 3; i++) { + await getNativeAccountRows(deps); + // advance past per-call cooldown between each + clock.now += 62_000; + } + const callsAfterTrip = networkCalls; + + // Breaker is now open: next call must skip network + await getNativeAccountRows(deps); + expect(networkCalls).toBe(callsAfterTrip); // no new network call + // Local fallback is called instead + expect(localCalls).toBeGreaterThan(0); + }); + + it('no-account path: skips network and serves local fallback', async () => { + const clock = { now: 1_000_000 }; + let networkCalls = 0; + let localCalls = 0; + + const deps: NativeQuotaDeps = { + readCredentials: () => null, + getDefaultCodexAccountId: () => null, // no account configured + fetchCodexNetworkQuota: async () => { + networkCalls += 1; + return codexSuccessQuota(); + }, + getCodexQuota: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + now: () => clock.now, + sleep: async () => {}, + }; + + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + + // No network call since no account + expect(networkCalls).toBe(0); + // Local fallback was used + expect(localCalls).toBe(1); + // Row is present from local data + expect(row).toBeDefined(); + expect(row?.health).toBe('warning'); // local stale + }); + + it('network success with EMPTY coreUsage falls back to local (no contentless ok row)', async () => { + const clock = { now: 1_000_000 }; + // Track our own network counter — an override passed to makeCodexDeps + // replaces the helper's default counting fetcher, so deps.networkCount() + // would not see this override's calls. + let networkCalls = 0; + const deps = makeCodexDeps({ + clock, + // Healthy response but no resolved core windows (e.g. only code-review + // windows or a changed payload) — carries no glanceable 5h/weekly signal. + fetchCodexNetworkQuota: async () => { + networkCalls += 1; + return { + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'pro', + lastUpdated: clock.now, + accountId: 'codex-user@example.com', + }; + }, + }); + + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'codex'); + + // Network was attempted, but the empty result must NOT be cached as an "ok" + // row — the local fallback supplies real (stale) data instead. + expect(networkCalls).toBe(1); + expect(deps.localCount()).toBe(1); + expect(row).toBeDefined(); + expect(row?.health).toBe('warning'); // came from local stale path + expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z'); + expect(row?.quotaWindows).toHaveLength(1); // local five_hour window + }); +}); + +// ============================================================================ +// Codex path (original local-only tests, preserved) +// These tests explicitly set getDefaultCodexAccountId: () => null so the +// network path is skipped and only the local fallback is exercised. +// ============================================================================ + describe('Codex path', () => { it('maps a local Codex quota into a codex ok row', async () => { const clock = { now: 1_000_000 }; const deps: NativeQuotaDeps = { readCredentials: () => null, // no claude row + getDefaultCodexAccountId: () => null, // disable network path getCodexQuota: async () => ({ quotaPercentage: 52, nextReset: '2026-06-09T19:00:00.000Z', @@ -398,6 +721,7 @@ describe('Codex path', () => { const clock = { now: 1_000_000 }; const deps: NativeQuotaDeps = { readCredentials: () => null, + getDefaultCodexAccountId: () => null, // disable network path getCodexQuota: async () => ({ quotaPercentage: 10, nextReset: null, @@ -419,6 +743,7 @@ describe('Codex path', () => { const clock = { now: 1_000_000 }; const deps: NativeQuotaDeps = { readCredentials: () => null, + getDefaultCodexAccountId: () => null, // disable network path getCodexQuota: async () => null, now: () => clock.now, }; @@ -426,3 +751,27 @@ describe('Codex path', () => { expect(rows.find((r) => r.provider === 'codex')).toBeUndefined(); }); }); + +describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => { + it('returns [] before any successful collect', () => { + expect(getCachedNativeAccountRows()).toEqual([]); + }); + + it('returns the last cached codex row (cached=true) after a successful collect, [] after reset', async () => { + const clock = { now: 1_000_000 }; + const deps = makeCodexDeps({ clock }); + + // Prime the cache via a successful network collect. + await getNativeAccountRows(deps); + + const cached = getCachedNativeAccountRows(); + const codex = cached.find((r) => r.provider === 'codex'); + expect(codex).toBeDefined(); + expect(codex?.cached).toBe(true); + // No additional network call — the accessor reads cache only. + expect(deps.networkCount()).toBe(1); + + resetNativeQuotaState(); + expect(getCachedNativeAccountRows()).toEqual([]); + }); +});