fix(clients): display CLI-computed values verbatim, align period windows across app/dash/menubar

This commit is contained in:
iamtoruk 2026-07-20 06:26:15 -07:00
parent ba56ad72f5
commit 6eb8797efa
9 changed files with 71 additions and 27 deletions

View file

@ -119,10 +119,11 @@ const PERIOD_LABELS: Record<Period, string> = {
week: 'Last 7 days',
month: 'This month',
'30days': 'Last 30 days',
all: 'All time',
all: 'Last 6 months',
lifetime: 'Lifetime',
}
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', 'lifetime']
// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync. Exported so

View file

@ -9,13 +9,14 @@ import { SegTabs, type SegOption } from './SegTabs'
/** Sentinel option value: no --claude-config-source flag (aggregate all configs). */
const ALL_CONFIGS = ''
/** The real CLI period vocabulary (`codeburn ... --period`). */
/** The real CLI period vocabulary (`codeburn ... --period`, src/cli-date.ts). */
export const PERIOD_OPTIONS: SegOption[] = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: '7D' },
{ value: '30days', label: '30D' },
{ value: 'month', label: 'Month' },
{ value: 'all', label: '6M' },
{ value: 'lifetime', label: 'Life' },
]
/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */

View file

@ -31,6 +31,20 @@ const DAILY = [
entry('2026-07-11'),
]
// All active-day entries at or before NOW's calendar day (the future 07-11 entry
// is always excluded). Reused by the widest windows ('all', 'lifetime').
const ALL_ACTIVE_THROUGH_NOW = [
'2026-05-31',
'2026-06-01',
'2026-06-10',
'2026-06-11',
'2026-07-01',
'2026-07-03',
'2026-07-04',
'2026-07-09',
'2026-07-10',
]
describe('sliceDailyToPeriod', () => {
it.each<[Period, string[]]>([
['today', ['2026-07-10']],
@ -38,32 +52,26 @@ describe('sliceDailyToPeriod', () => {
['week', ['2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['30days', ['2026-06-10', '2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['month', ['2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
[
'all',
[
'2026-05-31',
'2026-06-01',
'2026-06-10',
'2026-06-11',
'2026-07-01',
'2026-07-03',
'2026-07-04',
'2026-07-09',
'2026-07-10',
],
],
['all', ALL_ACTIVE_THROUGH_NOW],
// lifetime is unbounded below (1970), so it holds every active day up to today.
['lifetime', ALL_ACTIVE_THROUGH_NOW],
])('returns only in-window entries for %s', (period, expectedDates) => {
expect(sliceDailyToPeriod(DAILY, period, NOW).map(day => day.date)).toEqual(expectedDates)
})
})
describe('periodWindowStart', () => {
// Parity fixture: the inclusive window-start each period must produce, computed
// exactly as src/cli-date.ts getDateRange() does for the same NOW. If cli-date
// shifts a boundary, this table must move with it or the client will drift.
describe('periodWindowStart matches src/cli-date.ts getDateRange', () => {
// NOW = 2026-07-10. Values below are the local date-key of getDateRange().range.start.
it.each<[Period, string]>([
['today', '2026-07-10'],
['week', '2026-07-03'],
['30days', '2026-06-10'],
['month', '2026-07-01'],
['all', '2026-01-01'],
['today', '2026-07-10'], // new Date(y, m, d)
['week', '2026-07-03'], // new Date(y, m, d - 7)
['30days', '2026-06-10'], // new Date(y, m, d - 30)
['month', '2026-07-01'], // new Date(y, m, 1)
['all', '2026-01-01'], // new Date(y, m - 6, 1)
['lifetime', '1970-01-01'], // new Date(1970, 0, 1)
])('aligns %s to the CLI window start', (period, expected) => {
expect(periodWindowStart(period, NOW)).toBe(expected)
})

View file

@ -25,6 +25,9 @@ export function periodWindowStart(period: Period, now = new Date()): string {
return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1))
case 'all':
return localDateKey(new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1))
case 'lifetime':
// src/cli-date.ts anchors the unbounded window at 1970-01-01.
return localDateKey(new Date(1970, 0, 1))
}
}

View file

@ -4,7 +4,7 @@
// ————— Period + IPC error contract —————
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
export type DateRange = { from: string; to: string }

View file

@ -1,4 +1,4 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
export type ModelDay = {
name: string
@ -171,12 +171,16 @@ export async function fetchDevices(period: Period, provider: string): Promise<{
return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) }
}
// Keys map 1:1 to the CLI's --period values (src/cli-date.ts). Period windows
// are computed server-side by the CLI; the dashboard only forwards the key, so
// these can never drift from the CLI's totals.
export const PERIODS: Array<{ key: Period; label: string }> = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: '7 days' },
{ key: '30days', label: '30 days' },
{ key: 'month', label: 'Month' },
{ key: 'all', label: 'All' },
{ key: 'all', label: '6 months' },
{ key: 'lifetime', label: 'Lifetime' },
]
export type DiscoveredDevice = {

View file

@ -1507,6 +1507,7 @@ enum Period: String, CaseIterable, Identifiable {
case thirtyDays = "30 Days"
case month = "Month"
case all = "6 Months"
case lifetime = "Lifetime"
var id: String { rawValue }
@ -1518,6 +1519,7 @@ enum Period: String, CaseIterable, Identifiable {
case .thirtyDays: "30days"
case .month: "month"
case .all: "all"
case .lifetime: "lifetime"
}
}
@ -1530,6 +1532,7 @@ enum Period: String, CaseIterable, Identifiable {
case .thirtyDays: "30 Days"
case .month: "Month"
case .all: "6 Months"
case .lifetime: "Lifetime"
}
}
@ -1540,6 +1543,7 @@ enum Period: String, CaseIterable, Identifiable {
case .thirtyDays: "30days"
case .month: "month"
case .all: "sixMonths"
case .lifetime: "lifetime"
}
}
@ -1549,6 +1553,7 @@ enum Period: String, CaseIterable, Identifiable {
case "week", "sevenDays": self = .sevenDays
case "month": self = .month
case "sixMonths", "all": self = .all
case "lifetime": self = .lifetime
default: self = .today
}
}
@ -1569,6 +1574,9 @@ enum Period: String, CaseIterable, Identifiable {
case .thirtyDays: compact ? "/30d" : " / 30d"
case .month: compact ? "/mo" : " / mo"
case .all: compact ? "/6mo" : " / 6mo"
// lifetime is a panel-only period (never a menubar metric, see
// menubarMetricCases), but the switch must stay exhaustive.
case .lifetime: compact ? "/life" : " / life"
}
}
}

View file

@ -135,7 +135,7 @@ private struct TrendInsight: View {
case .today, .sevenDays: return 19
case .thirtyDays: return 30
case .month: return 31
case .all: return min(days.count, 90)
case .all, .lifetime: return min(days.count, 90)
}
}

View file

@ -9,6 +9,24 @@ struct MenubarPeriodSettingsTests {
#expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all])
}
@Test("period picker exposes the lifetime window, matching the CLI period set")
func periodPickerExposesLifetime() {
// The panel period selector iterates Period.allCases, so lifetime must be
// present there while staying out of the menubar-metric subset.
#expect(Period.allCases == [.today, .sevenDays, .thirtyDays, .month, .all, .lifetime])
#expect(!Period.menubarMetricCases.contains(.lifetime))
}
@Test("period cliArg values match src/cli-date.ts --period values")
func cliArgsMatchCLIPeriods() {
#expect(Period.today.cliArg == "today")
#expect(Period.sevenDays.cliArg == "week")
#expect(Period.thirtyDays.cliArg == "30days")
#expect(Period.month.cliArg == "month")
#expect(Period.all.cliArg == "all")
#expect(Period.lifetime.cliArg == "lifetime")
}
@Test("defaults values map to periods")
func defaultsValuesMapToPeriods() {
#expect(Period(menubarDefaultsValue: "today") == .today)
@ -16,6 +34,7 @@ struct MenubarPeriodSettingsTests {
#expect(Period(menubarDefaultsValue: "month") == .month)
#expect(Period(menubarDefaultsValue: "sixMonths") == .all)
#expect(Period(menubarDefaultsValue: "all") == .all)
#expect(Period(menubarDefaultsValue: "lifetime") == .lifetime)
#expect(Period(menubarDefaultsValue: "30days") == .today)
#expect(Period(menubarDefaultsValue: "bogus") == .today)
#expect(Period(menubarDefaultsValue: nil) == .today)