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([]); + }); +});