diff --git a/Resources/Info.plist b/Resources/Info.plist index bc2b3c6..4adf947 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.2.5 + 0.2.6 CFBundleVersion - 6 + 7 LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift index c4e6400..db4302e 100644 --- a/Sources/CodexLimits/AnalyticsWorkspace.swift +++ b/Sources/CodexLimits/AnalyticsWorkspace.swift @@ -50,7 +50,7 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { func interval( within bounds: DateInterval, - endingAt proposedEnd: Date + now: Date ) -> DateInterval { let duration: TimeInterval switch self { @@ -65,63 +65,138 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable { case .twelveWeeks: duration = 84 * 86_400 } - let end = min(max(proposedEnd, bounds.start), bounds.end) + let end = max(now, bounds.start) return DateInterval( - start: max(bounds.start, end.addingTimeInterval(-duration)), + start: end.addingTimeInterval(-duration), end: end ) } } -struct AccountTokenActivityRange: Equatable, Sendable { - let days: [TokenDay] - let completeDayCount: Int - let completeTokens: Int64? - - init(days: [TokenDay], interval: DateInterval) { - let day: TimeInterval = 86_400 - self.days = days - .filter { - $0.date >= interval.start - && $0.date.addingTimeInterval(day) <= interval.end - } - .sorted { $0.date < $1.date } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(secondsFromGMT: 0) - ?? calendar.timeZone - let startOfDay = calendar.startOfDay(for: interval.start) - var expected = startOfDay < interval.start - ? startOfDay.addingTimeInterval(day) - : startOfDay - var expectedDates: [Date] = [] - while expected.addingTimeInterval(day) <= interval.end { - expectedDates.append(expected) - expected = expected.addingTimeInterval(day) - } - completeDayCount = expectedDates.count +func accountTokenInterval( + at date: Date, + in intervals: [AccountTokenActivityInterval], + within range: DateInterval +) -> AccountTokenActivityInterval? { + intervals.first { + $0.start >= range.start + && $0.end <= range.end + && $0.start <= date + && date <= $0.end + } +} + +func steppedAccountTokenInterval( + in intervals: [AccountTokenActivityInterval], + from selected: AccountTokenActivityInterval?, + by offset: Int +) -> AccountTokenActivityInterval? { + let ordered = intervals.sorted { + $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start + } + guard !ordered.isEmpty else { return nil } + guard let selected, + let index = ordered.firstIndex(of: selected) else { + return offset < 0 ? ordered.last : ordered.first + } + return ordered[min(max(index + offset, 0), ordered.count - 1)] +} + +func retainedAccountTokenInterval( + _ selected: AccountTokenActivityInterval?, + in intervals: [AccountTokenActivityInterval], + range: DateInterval +) -> AccountTokenActivityInterval? { + guard let selected, + selected.start >= range.start, + selected.end <= range.end else { return nil } + return intervals.first { $0 == selected } +} + +struct AccountTokenActivityDisplayInterval: Equatable, Hashable, Identifiable, + Sendable { + let sourceIntervals: [AccountTokenActivityInterval] + + init(sourceIntervals: [AccountTokenActivityInterval]) { + precondition(!sourceIntervals.isEmpty) + self.sourceIntervals = sourceIntervals + } - guard !expectedDates.isEmpty else { - completeTokens = nil - return + var id: Self { self } + var start: Date { sourceIntervals[0].start } + var end: Date { sourceIntervals[sourceIntervals.count - 1].end } + var tokenDelta: Int64 { + sourceIntervals.reduce(0) { $0 + $1.tokenDelta } + } + var method: AccountTokenActivityMethod { sourceIntervals[0].method } + var isAggregated: Bool { sourceIntervals.count > 1 } +} + +func displayedAccountTokenIntervals( + _ intervals: [AccountTokenActivityInterval], + breaks: [AccountTokenActivityBreak], + maximumMarks: Int +) -> [AccountTokenActivityDisplayInterval] { + let ordered = intervals.sorted { + $0.start == $1.start ? $0.end < $1.end : $0.start < $1.start + } + guard !ordered.isEmpty else { return [] } + let groupLimit = max( + Int(ceil(Double(ordered.count) / Double(max(maximumMarks, 1)))), + 1 + ) + var result: [AccountTokenActivityDisplayInterval] = [] + var group = [ordered[0]] + for interval in ordered.dropFirst() { + let previous = group[group.count - 1] + let hasBreak = breaks.contains { + $0.timestamp == previous.end + && $0.timestamp == interval.start } - var total: Int64 = 0 - for date in expectedDates { - guard let bucket = self.days.first(where: { $0.date == date }), - bucket.completeness == .complete, - bucket.tokens >= 0 else { - completeTokens = nil - return - } - let result = total.addingReportingOverflow(bucket.tokens) - guard !result.overflow else { - completeTokens = nil - return - } - total = result.partialValue + let compatible = previous.end == interval.start + && previous.method == interval.method + && previous.accountPartitionID == interval.accountPartitionID + && previous.limitID == interval.limitID + && previous.allowanceReset == interval.allowanceReset + && (previous.tokenDelta == 0) == (interval.tokenDelta == 0) + && !hasBreak + if compatible, group.count < groupLimit { + group.append(interval) + } else { + result.append(AccountTokenActivityDisplayInterval( + sourceIntervals: group + )) + group = [interval] } - completeTokens = total } + result.append(AccountTokenActivityDisplayInterval(sourceIntervals: group)) + return result +} + +func accountTokenDisplayInterval( + at date: Date, + in intervals: [AccountTokenActivityDisplayInterval], + within range: DateInterval +) -> AccountTokenActivityDisplayInterval? { + intervals.first { + $0.start >= range.start + && $0.end <= range.end + && $0.start <= date + && date <= $0.end + } +} + +func steppedAccountTokenDisplayInterval( + in intervals: [AccountTokenActivityDisplayInterval], + from selected: AccountTokenActivityDisplayInterval?, + by offset: Int +) -> AccountTokenActivityDisplayInterval? { + guard !intervals.isEmpty else { return nil } + guard let selected, + let index = intervals.firstIndex(of: selected) else { + return offset < 0 ? intervals.last : intervals.first + } + return intervals[min(max(index + offset, 0), intervals.count - 1)] } struct WorkspaceFilters: Codable, Equatable, Sendable { @@ -312,7 +387,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { func effectiveRange( within bounds: DateInterval, - endingAt latestObserved: Date + now: Date ) -> DateInterval { if state.timeRange == .selected, let visibleRange = state.visibleRange, @@ -321,7 +396,7 @@ final class AnalyticsWorkspaceStore: ObservableObject { } return state.timeRange.interval( within: bounds, - endingAt: latestObserved + now: now ) } diff --git a/Sources/CodexLimits/CodexAssistedInsights.swift b/Sources/CodexLimits/CodexAssistedInsights.swift index 8d09a2a..0b7c4a6 100644 --- a/Sources/CodexLimits/CodexAssistedInsights.swift +++ b/Sources/CodexLimits/CodexAssistedInsights.swift @@ -193,7 +193,8 @@ struct CodexMetadataAnalysisPayload: Codable, Equatable, Sendable { ) -> CodexMetadataAnalysisPayload { let deterministicInput = DeterministicInsightInput( reader: reader, - exploration: exploration + exploration: exploration, + now: now ) let selectedUsage = deterministicInput.usagePerToken let range = deterministicInput.selectedRange ?? reader.interval.map { diff --git a/Sources/CodexLimits/DeterministicInsights.swift b/Sources/CodexLimits/DeterministicInsights.swift index 92ad282..20f593b 100644 --- a/Sources/CodexLimits/DeterministicInsights.swift +++ b/Sources/CodexLimits/DeterministicInsights.swift @@ -110,7 +110,8 @@ struct DeterministicInsightInput: Equatable, Sendable { init( reader: UsageReaderSnapshot, - exploration: AnalyticsExplorationState + exploration: AnalyticsExplorationState, + now: Date = Date() ) { sourceState = reader.sourceState freshness = reader.freshness @@ -133,7 +134,7 @@ struct DeterministicInsightInput: Equatable, Sendable { selectedRange = Self.effectiveRange( usagePerToken: selectedUsage, observedInterval: reader.interval, - fetchedAt: reader.fetchedAt, + now: now, exploration: exploration ) filters = exploration.filters @@ -166,7 +167,7 @@ struct DeterministicInsightInput: Equatable, Sendable { static func effectiveRange( usagePerToken: UsagePerTokenSnapshot, observedInterval: UsageObservedInterval?, - fetchedAt: Date?, + now: Date, exploration: AnalyticsExplorationState ) -> DateInterval? { let evidence = usagePerToken.history @@ -183,7 +184,7 @@ struct DeterministicInsightInput: Equatable, Sendable { ? exploration.visibleRange : nil } - let bounds = DateInterval(start: start, end: end) + let bounds = DateInterval(start: start, end: max(end, now)) if exploration.timeRange == .currentWindow { return usagePerToken.current?.interval ?? observedRange } @@ -195,7 +196,7 @@ struct DeterministicInsightInput: Equatable, Sendable { } return exploration.timeRange.interval( within: bounds, - endingAt: fetchedAt ?? evidenceEnd ?? end + now: now ) } } @@ -207,12 +208,14 @@ enum DeterministicInsightEngine { static func evaluate( reader: UsageReaderSnapshot, exploration: AnalyticsExplorationState, + now: Date = Date(), dispositions: [String: InsightDisposition] ) -> DeterministicInsightsSnapshot { evaluate( DeterministicInsightInput( reader: reader, - exploration: exploration + exploration: exploration, + now: now ), dispositions: dispositions ) diff --git a/Sources/CodexLimits/ForecastEngine.swift b/Sources/CodexLimits/ForecastEngine.swift index 7bbc64c..c907f4e 100644 --- a/Sources/CodexLimits/ForecastEngine.swift +++ b/Sources/CodexLimits/ForecastEngine.swift @@ -1,5 +1,10 @@ import Foundation +struct RecentAccountMovement: Equatable, Sendable { + let latest: UsageSample + let percentPerDay: Double +} + enum ForecastEngine { static func evaluate( window: UsageWindow, @@ -7,9 +12,16 @@ enum ForecastEngine { tokenHistory: [TokenDay], safetyBuffer: Double, now: Date, - previousStatus: PaceStatus? + previousStatus: PaceStatus?, + recentMovement: RecentAccountMovement? = nil ) -> Forecast { - let daysLeft = max(window.resetsAt.timeIntervalSince(now) / 86_400, 0) + let latestDate = recentMovement?.latest.observedAt ?? now + let latestRemaining = recentMovement?.latest.remainingPercent + ?? window.remainingPercent + let daysLeft = max( + window.resetsAt.timeIntervalSince(latestDate) / 86_400, + 0 + ) let currentSamples = samples .filter { $0.resetsAt == window.resetsAt && $0.observedAt <= now } .sorted { $0.observedAt < $1.observedAt } @@ -27,9 +39,10 @@ enum ForecastEngine { recentRate = windowRate } - let currentRate = currentIntervalSamples.count > 1 - ? 0.7 * recentRate + 0.3 * windowRate - : windowRate + let currentRate = recentMovement?.percentPerDay + ?? (currentIntervalSamples.count > 1 + ? 0.7 * recentRate + 0.3 * windowRate + : windowRate) let historicalRates = comparableHistoricalRates( samples: samples, excluding: window.resetsAt @@ -49,11 +62,17 @@ enum ForecastEngine { historicalRate = median(Array(historicalRates.prefix(4))) historicalReferenceSource = .accountHistory } - let expectedRate = 0.75 * currentRate + 0.25 * historicalRate - let safetyRate = max(currentRate, historicalRate) * 1.2 - let expected = max(window.remainingPercent - expectedRate * daysLeft, 0) - let safety = max(window.remainingPercent - safetyRate * daysLeft, 0) - let historical = max(window.remainingPercent - historicalRate * daysLeft, 0) + let expectedRate = recentMovement == nil + ? 0.75 * currentRate + 0.25 * historicalRate + : currentRate + let safetyRate = ( + recentMovement == nil + ? max(currentRate, historicalRate) + : currentRate + ) * 1.2 + let expected = max(latestRemaining - expectedRate * daysLeft, 0) + let safety = max(latestRemaining - safetyRate * daysLeft, 0) + let historical = max(latestRemaining - historicalRate * daysLeft, 0) let historicalReference = historicalReferenceSource.map { UsageForecastReference( source: $0, @@ -62,7 +81,7 @@ enum ForecastEngine { ) } let allowanceRate = daysLeft > 0 - ? max(window.remainingPercent - safetyBuffer, 0) / daysLeft + ? max(latestRemaining - safetyBuffer, 0) / daysLeft : 0 let recommended = historicalReferenceSource == .accountHistory ? min(allowanceRate, historicalRate * 1.2) diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift index c364c28..3448c13 100644 --- a/Sources/CodexLimits/MenuContentView.swift +++ b/Sources/CodexLimits/MenuContentView.swift @@ -100,28 +100,31 @@ struct MenuContentView: View { Task { await monitor.refresh() } } ) { - AnalyticsWorkspaceBody( - reader: monitor.readerSnapshot, - store: workspace, - assistedInsights: assistedInsights, - analyticsPreferencesChanged: { - monitor.analyticsPreferencesDidChange( - exploration: workspace.state, - dispositions: workspace.insightDispositions - ) - }, - resetReminderState: monitor.resetReminderState, - setResetReminderEnabled: { isEnabled in - Task { - await monitor.setResetReminderEnabled(isEnabled) - } - }, - setResetReminderLeadTime: { leadTime in - Task { - await monitor.setResetReminderLeadTime(leadTime) + TimelineView(.periodic(from: .now, by: 60)) { context in + AnalyticsWorkspaceBody( + reader: monitor.readerSnapshot, + store: workspace, + assistedInsights: assistedInsights, + now: context.date, + analyticsPreferencesChanged: { + monitor.analyticsPreferencesDidChange( + exploration: workspace.state, + dispositions: workspace.insightDispositions + ) + }, + resetReminderState: monitor.resetReminderState, + setResetReminderEnabled: { isEnabled in + Task { + await monitor.setResetReminderEnabled(isEnabled) + } + }, + setResetReminderLeadTime: { leadTime in + Task { + await monitor.setResetReminderLeadTime(leadTime) + } } - } - ) + ) + } } } @@ -215,6 +218,7 @@ struct AnalyticsWorkspaceBody: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore @ObservedObject var assistedInsights: CodexAssistedInsightStore + let now: Date let analyticsPreferencesChanged: () -> Void let resetReminderState: ResetReminderState let setResetReminderEnabled: (Bool) -> Void @@ -224,6 +228,7 @@ struct AnalyticsWorkspaceBody: View { reader: UsageReaderSnapshot, store: AnalyticsWorkspaceStore, assistedInsights: CodexAssistedInsightStore, + now: Date = Date(), analyticsPreferencesChanged: @escaping () -> Void = {}, resetReminderState: ResetReminderState = ResetReminderState( isEnabled: false, @@ -237,6 +242,7 @@ struct AnalyticsWorkspaceBody: View { self.reader = reader self.store = store self.assistedInsights = assistedInsights + self.now = now self.analyticsPreferencesChanged = analyticsPreferencesChanged self.resetReminderState = resetReminderState self.setResetReminderEnabled = setResetReminderEnabled @@ -248,7 +254,7 @@ struct AnalyticsWorkspaceBody: View { Group { switch store.state.section { case .graphs: - GraphsWorkspace(reader: reader, store: store) + GraphsWorkspace(reader: reader, store: store, now: now) case .facts: FactsWorkspace( reader: reader, @@ -271,6 +277,16 @@ struct AnalyticsWorkspaceBody: View { .onChange(of: store.insightDispositions) { _, _ in analyticsPreferencesChanged() } + .onChange(of: now) { _, _ in + guard store.state.section == .graphs, + store.state.graph == .tokenActivity else { return } + switch store.state.timeRange { + case .oneDay, .threeDays, .fourWeeks, .twelveWeeks: + analyticsPreferencesChanged() + case .currentWindow, .selected: + break + } + } } } @@ -296,7 +312,7 @@ private struct WorkspaceHeader: View { Text("% remaining") .foregroundStyle(.secondary) } else { - Text("Weekly usage unavailable") + Text(reader.evidence.reason ?? "Weekly usage unavailable") .font(.headline) } @@ -344,9 +360,6 @@ private struct WorkspaceHeader: View { .font(.callout) .foregroundStyle(.secondary) .lineLimit(isCompact ? 2 : 1) - Text(reader.evidenceText) - .font(.caption) - .foregroundStyle(.tertiary) } } } @@ -448,6 +461,7 @@ private struct HeaderFact: View { private struct GraphsWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -457,7 +471,16 @@ private struct GraphsWorkspace: View { case .usageRemaining: usageRemaining case .tokenActivity: - TokenActivityWorkspace(reader: reader, store: store) + if store.state.timeRange != .currentWindow + || reader.weeklyUsageRemaining != nil { + TokenActivityWorkspace( + reader: reader, + store: store, + now: now + ) + } else { + unavailableCurrentWindow + } case .usagePerToken: UsagePerTokenWorkspace( sourceSnapshot: reader.usagePerToken, @@ -536,13 +559,15 @@ private struct GraphsWorkspace: View { @ViewBuilder private var usageRemaining: some View { - if let weekly = reader.weeklyUsageRemaining { + if let weekly = reader.weeklyUsageRemaining + ?? historicalWeeklyUsageRemaining { VStack(alignment: .leading, spacing: 14) { UsageRemainingChart( window: weekly.window, chart: reader.chart, evidence: reader.evidence, - store: store + store: store, + now: now ) Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 6) { @@ -564,7 +589,7 @@ private struct GraphsWorkspace: View { GridRow { Text("Runway") .foregroundStyle(.secondary) - Text(reader.guidance?.runway.text ?? "Not enough data") + Text(reader.runwayText) } if let gap = reader.guidance?.runway.gapText { GridRow { @@ -584,12 +609,20 @@ private struct GraphsWorkspace: View { .font(.callout) } } else { - UnavailableGraph( - title: "Weekly usage unavailable", - message: "Try refreshing to check again." - ) + unavailableCurrentWindow } } + + private var historicalWeeklyUsageRemaining: LimitReading? { + store.state.timeRange == .currentWindow ? nil : reader.account?.mainLimit + } + + private var unavailableCurrentWindow: some View { + UnavailableGraph( + title: reader.evidence.reason ?? "Weekly usage unavailable", + message: "Try refreshing to check again." + ) + } } struct UsagePerTokenWorkspace: View { @@ -634,7 +667,7 @@ struct UsagePerTokenWorkspace: View { } return store.effectiveRange( within: bounds, - endingAt: snapshot.current?.interval.end ?? bounds.end + now: snapshot.current?.interval.end ?? bounds.end ) } @@ -1165,7 +1198,7 @@ private struct ConcurrencyWorkspace: View { private var visibleRange: DateInterval { store.effectiveRange( within: bounds, - endingAt: min( + now: min( reader.localTokenActivity.observedAt ?? bounds.end, bounds.end ) @@ -1514,15 +1547,64 @@ private struct ConcurrencyWorkspace: View { } } +func accountTokenIntervalText( + _ interval: DateInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.timeZone = timeZone + formatter.locale = locale + return "\(formatter.string(from: interval.start))–\(formatter.string(from: interval.end))" +} + +func accountTokenIntervalAccessibilityValue( + _ interval: AccountTokenActivityInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let dates = accountTokenIntervalText( + DateInterval(start: interval.start, end: interval.end), + timeZone: timeZone, + locale: locale + ) + return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName)." +} + +func accountTokenDisplayIntervalAccessibilityValue( + _ interval: AccountTokenActivityDisplayInterval, + timeZone: TimeZone = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent +) -> String { + let dates = accountTokenIntervalText( + DateInterval(start: interval.start, end: interval.end), + timeZone: timeZone, + locale: locale + ) + let aggregation = interval.isAggregated + ? " Combined from \(interval.sourceIntervals.count) observed periods." + : "" + let zero = interval.tokenDelta == 0 + ? " Observed zero-token interval." + : "" + return "\(interval.tokenDelta) account tokens. Account. \(dates). \(interval.method.displayName).\(aggregation)\(zero)" +} + private struct TokenActivityWorkspace: View { let reader: UsageReaderSnapshot @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date - @State private var selectedDay: TokenDay? - private let day: TimeInterval = 86_400 + @State private var selectedInterval: AccountTokenActivityDisplayInterval? - private var accountDays: [TokenDay] { - (reader.account?.tokenHistory ?? []).sorted { $0.date < $1.date } + private var displayIntervals: [AccountTokenActivityDisplayInterval] { + displayedAccountTokenIntervals( + reader.accountTokenActivity.intervals, + breaks: reader.accountTokenActivity.breaks, + maximumMarks: 120 + ) } private var currentWindowBounds: DateInterval? { @@ -1534,56 +1616,49 @@ private struct TokenActivityWorkspace: View { } } - private var bounds: DateInterval { - let fallback = reader.fetchedAt ?? Date() - let first = accountDays.first?.date - ?? currentWindowBounds?.start - ?? fallback.addingTimeInterval(-day) - let last = accountDays.last?.date.addingTimeInterval(day) - ?? currentWindowBounds?.end - ?? fallback + private var zoomBounds: DateInterval { + let tokenDates = (reader.account?.tokenHistory ?? []).flatMap { + [$0.date, $0.date.addingTimeInterval(86_400)] + } + let dates = reader.chart.allObserved.map(\.date) + + tokenDates + + [visibleRange.start, visibleRange.end, now] + + [currentWindowBounds?.start, currentWindowBounds?.end].compactMap { $0 } return DateInterval( - start: min(first, currentWindowBounds?.start ?? first), - end: max(last, currentWindowBounds?.end ?? last) + start: dates.min() ?? visibleRange.start, + end: dates.max() ?? visibleRange.end ) } private var visibleRange: DateInterval { + if let evaluatedRange = reader.accountTokenActivity.range { + return evaluatedRange + } if store.state.timeRange == .currentWindow, let currentWindowBounds { return currentWindowBounds } - return store.effectiveRange( - within: bounds, - endingAt: min( - reader.fetchedAt ?? bounds.end, - bounds.end - ) - ) - } - - private var accountRange: AccountTokenActivityRange { - AccountTokenActivityRange( - days: accountDays, - interval: visibleRange + if store.state.timeRange == .selected, + let selected = store.state.visibleRange { + return selected + } + return store.state.timeRange.interval( + within: DateInterval(start: now, end: now), + now: now ) } var body: some View { - content(accountRange) - } - - private func content(_ range: AccountTokenActivityRange) -> some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Token activity") .font(.title3.weight(.semibold)) - Text("Daily token totals from your Codex account.") + Text("Observed token activity from your Codex account.") .font(.callout) .foregroundStyle(.secondary) } - accountCard(range) + accountCard VStack(alignment: .leading, spacing: 10) { ViewThatFits(in: .horizontal) { @@ -1598,151 +1673,144 @@ private struct TokenActivityWorkspace: View { } } - if range.days.isEmpty { + zoomControls + + if reader.accountTokenActivity.intervals.isEmpty { WorkspaceMessage( icon: "chart.xyaxis.line", - title: "No complete daily totals", - message: store.state.timeRange == .currentWindow - ? "Choose 4 weeks to see account history." - : "Codex did not return a complete day for this range." + title: reader.accountTokenActivity.reason + ?? "No account readings in this range", + message: "Missing time remains empty." ) { EmptyView() } .frame(minHeight: 170) } else { - accountChart(range) + accountIntervalChart( + displayIntervals + ) } - selectedPointDetail(range) + intervalSelectionDetail + evidenceDetails } } + .onChange(of: reader.accountTokenActivity.intervals) { _, _ in + selectedInterval = displayIntervals.first { $0 == selectedInterval } + } .onChange(of: visibleRange) { _, range in - if let selectedDay, - selectedDay.date >= range.end - || selectedDay.date.addingTimeInterval(day) <= range.start { - self.selectedDay = nil + guard let selectedInterval, + selectedInterval.start >= range.start, + selectedInterval.end <= range.end else { + self.selectedInterval = nil + return + } + self.selectedInterval = displayIntervals.first { + $0 == selectedInterval } } } private var chartSourceLabel: some View { ChartLegendItem( - label: "Daily totals · Account", + label: "Observed intervals · Account", color: .blue ) } private var chartIntervalLabel: some View { - Text(intervalText(visibleRange)) + Text(accountTokenIntervalText(visibleRange)) .font(.caption) .foregroundStyle(.tertiary) } - private func accountCard( - _ range: AccountTokenActivityRange - ) -> some View { + private var zoomControls: some View { + HStack(spacing: 10) { + Button { + zoom(by: 1.5) + } label: { + Label("Zoom in", systemImage: "plus.magnifyingglass") + } + .help("Show a shorter range") + Button { + zoom(by: 1 / 1.5) + } label: { + Label("Zoom out", systemImage: "minus.magnifyingglass") + } + .help("Show a longer range") + if store.state.timeRange == .selected { + Button("Reset range") { + store.resetVisibleRange() + } + } + } + .buttonStyle(.borderless) + .font(.caption) + } + + private var accountCard: some View { TokenSourceCard( title: "Account", - source: store.state.timeRange == .currentWindow - ? accountSource - : "Codex daily token totals", - value: summaryTokens(in: range).map(compactTokenCount) + source: reader.accountTokenActivity.sourceDescription, + value: reader.accountTokenActivity.tokens.map(compactTokenCount) ?? "Not available", - detail: summaryDetail(in: range), - coverage: summaryCoverage(in: range), + detail: summaryDetail, freshness: reader.fetchedAt, freshnessLabel: "Updated", color: .blue ) } - private var accountSource: String { - switch reader.accountTokenActivity.method { - case .lifetimeDelta: - "Codex account summary" - case .dailyBuckets: - "Codex daily token totals" - case nil: - "Codex account" - } - } - - private func summaryTokens( - in range: AccountTokenActivityRange - ) -> Int64? { - if store.state.timeRange == .currentWindow { - return reader.accountTokenActivity.tokens - } - return range.completeTokens - } - - private func summaryDetail( - in range: AccountTokenActivityRange - ) -> String { - guard store.state.timeRange == .currentWindow else { - if range.completeDayCount == 0 { - return "No full days in this range" - } - return range.completeTokens == nil - ? "Codex returned only part of this range" - : "Sum of \(range.completeDayCount) complete days" - } - switch reader.accountTokenActivity.method { - case .lifetimeDelta: - return "Change between two account readings" - case .dailyBuckets: - return reader.accountTokenActivity.state == .partial - ? "Sum of complete days" - : "Complete daily totals" - case nil: + private var summaryDetail: String { + guard let observed = reader.accountTokenActivity.interval else { return reader.accountTokenActivity.reason ?? "Account token activity is unavailable" } + let qualifier = store.state.timeRange == .currentWindow + ? "Activity so far · " + : "" + return "\(qualifier)Observed from \(accountTokenIntervalText(observed))" } - private func summaryCoverage( - in range: AccountTokenActivityRange - ) -> String { - guard store.state.timeRange == .currentWindow else { - return range.completeTokens == nil - ? "Unavailable" - : "Complete days" - } - switch reader.accountTokenActivity.state { - case .exact: return "Complete" - case .partial: return "Partial" - case .unavailable: return "Unavailable" - } - } - - private func renderedDays( - _ range: AccountTokenActivityRange - ) -> [TokenDay] { - downsampledForDisplay(range.days) - } - - private func accountChart( - _ range: AccountTokenActivityRange + private func accountIntervalChart( + _ intervals: [AccountTokenActivityDisplayInterval] ) -> some View { Chart { - ForEach(renderedDays(range), id: \.date) { tokenDay in - BarMark( - x: .value("Day", tokenDay.date, unit: .day), - y: .value("Account tokens", tokenDay.tokens) - ) - .foregroundStyle(Color.blue) + ForEach(intervals) { interval in + if interval.tokenDelta == 0 { + PointMark( + x: .value("Observed interval", midpoint(of: interval)), + y: .value("Account tokens", 0) + ) + .foregroundStyle(Color.blue) + .symbol(.diamond) + .symbolSize(55) + .accessibilityLabel("Account token interval") + .accessibilityValue( + accountTokenDisplayIntervalAccessibilityValue(interval) + ) + } else { + RectangleMark( + xStart: .value("Start", interval.start), + xEnd: .value("End", interval.end), + yStart: .value("Baseline", 0), + yEnd: .value("Account tokens", interval.tokenDelta) + ) + .foregroundStyle(Color.blue) + .accessibilityLabel("Account token interval") + .accessibilityValue( + accountTokenDisplayIntervalAccessibilityValue(interval) + ) + } } - if let selectedDay { - RuleMark(x: .value("Selected time", selectedDay.date)) - .foregroundStyle(Color.primary.opacity(0.45)) + if let selectedInterval { + RuleMark(x: .value("Selected start", selectedInterval.start)) + .foregroundStyle(Color.primary.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + RuleMark(x: .value("Selected end", selectedInterval.end)) + .foregroundStyle(Color.primary.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) - PointMark( - x: .value("Selected day", selectedDay.date), - y: .value("Account tokens", selectedDay.tokens) - ) - .foregroundStyle(Color.blue) - .symbolSize(52) } } .chartXScale(domain: visibleRange.start ... visibleRange.end) @@ -1763,89 +1831,78 @@ private struct TokenActivityWorkspace: View { Rectangle() .fill(.clear) .contentShape(Rectangle()) - .onContinuousHover { phase in - switch phase { - case let .active(location): - selectNearestPoint( - at: location, - proxy: proxy, - geometry: geometry, - days: range.days - ) - case .ended: - selectedDay = nil - } - } + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + selectInterval( + at: value.location, + proxy: proxy, + geometry: geometry + ) + } + ) } } .frame(height: 180) - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel("Account token activity") .accessibilityValue( - selectedDay.map { - "\(compactTokenCount($0.tokens)) account tokens, \($0.date.formatted(date: .abbreviated, time: .omitted))" - } ?? "Daily account token totals are shown." + store.state.timeRange == .currentWindow + ? reader.accountTokenActivity.currentWindowAccessibilityValue + : reader.accountTokenActivity.accessibilityValue ) - .accessibilityHint( - "Use Previous point and Next point for exact values." + } + + private func midpoint( + of interval: AccountTokenActivityDisplayInterval + ) -> Date { + interval.start.addingTimeInterval( + interval.end.timeIntervalSince(interval.start) / 2 ) } - @ViewBuilder - private func selectedPointDetail( - _ range: AccountTokenActivityRange - ) -> some View { - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 10) { - if let selectedDay { - ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { - Text("Daily account total") - .fontWeight(.semibold) - Text(compactTokenCount(selectedDay.tokens)) - .monospacedDigit() - Text( - selectedDay.date.formatted( - date: .abbreviated, - time: .omitted - ) - ) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text("Daily account total") - .fontWeight(.semibold) - Text(compactTokenCount(selectedDay.tokens)) - .monospacedDigit() - } - Text( - selectedDay.date.formatted( - date: .abbreviated, - time: .omitted - ) - ) - .foregroundStyle(.secondary) - } - } - } else { - Text("Choose a point for exact details.") + private var intervalSelectionDetail: some View { + HStack(spacing: 10) { + if let selectedInterval { + VStack(alignment: .leading, spacing: 3) { + Text("Selected interval") + .fontWeight(.semibold) + Text("\(compactTokenCount(selectedInterval.tokenDelta)) tokens · Account") + .monospacedDigit() + Text( + "\(accountTokenIntervalText(DateInterval(start: selectedInterval.start, end: selectedInterval.end))) · \(selectedInterval.method.displayName)" + ) + .foregroundStyle(.secondary) + if selectedInterval.isAggregated { + Text( + "Combined from \(selectedInterval.sourceIntervals.count) observed periods" + ) .foregroundStyle(.secondary) + } } - Spacer() - Button { - moveSelection(in: range.days, by: -1) - } label: { - Image(systemName: "chevron.left") - } - .accessibilityLabel("Previous point") - Button { - moveSelection(in: range.days, by: 1) - } label: { - Image(systemName: "chevron.right") - } - .accessibilityLabel("Next point") + .accessibilityElement(children: .ignore) + .accessibilityLabel( + accountTokenDisplayIntervalAccessibilityValue( + selectedInterval + ) + ) + } else { + Text("Choose an observed interval for exact details.") + .foregroundStyle(.secondary) + } + Spacer() + Button { + moveIntervalSelection(by: -1) + } label: { + Image(systemName: "chevron.left") + } + .accessibilityLabel("Previous interval") + Button { + moveIntervalSelection(by: 1) + } label: { + Image(systemName: "chevron.right") } + .accessibilityLabel("Next interval") } .font(.caption) .padding(10) @@ -1856,45 +1913,89 @@ private struct TokenActivityWorkspace: View { ) } - private func moveSelection( - in days: [TokenDay], - by offset: Int - ) { - selectedDay = steppedPoint( - in: days, - from: selectedDay, + private var evidenceDetails: some View { + DisclosureGroup("Evidence Details") { + VStack(alignment: .leading, spacing: 5) { + Text("Source · \(reader.accountTokenActivity.sourceDescription)") + if let observed = reader.accountTokenActivity.interval { + Text("Observed Interval · \(accountTokenIntervalText(observed))") + } + if let method = selectedInterval?.method + ?? reader.accountTokenActivity.method { + Text("Method · \(method.displayName)") + } + ForEach(evidenceCaveats, id: \.self) { caveat in + Text(caveat) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 5) + } + .font(.caption) + } + + private var evidenceCaveats: [String] { + var caveats: [String] = [] + if reader.accountTokenActivity.state == .partial { + caveats.append( + "Only complete observed intervals inside the selected range are included." + ) + } + if reader.accountTokenActivity.method == .dailyBuckets { + caveats.append( + "Lifetime intervals were unavailable; complete UTC daily buckets were used." + ) + } + if !reader.accountTokenActivity.breaks.isEmpty { + caveats.append("Counter breaks remain empty on the chart.") + } + return caveats + } + + private func moveIntervalSelection(by offset: Int) { + selectedInterval = steppedAccountTokenDisplayInterval( + in: displayIntervals, + from: selectedInterval, by: offset ) } - private func selectNearestPoint( + private func zoom(by factor: CGFloat) { + let anchor = selectedInterval.map { + Date( + timeIntervalSince1970: + ($0.start.timeIntervalSince1970 + + $0.end.timeIntervalSince1970) / 2 + ) + } ?? Date( + timeIntervalSince1970: + (visibleRange.start.timeIntervalSince1970 + + visibleRange.end.timeIntervalSince1970) / 2 + ) + store.zoom( + factor: Double(factor), + anchor: anchor, + currentRange: visibleRange, + within: zoomBounds + ) + } + + private func selectInterval( at location: CGPoint, proxy: ChartProxy, - geometry: GeometryProxy, - days: [TokenDay] + geometry: GeometryProxy ) { guard let date = chartDate( at: location, proxy: proxy, geometry: geometry ) else { return } - selectedDay = nearestPoint( - in: days, - to: date, - date: \.date - ) - } - - private func intervalText(_ interval: DateInterval) -> String { - let start = interval.start.formatted( - date: .abbreviated, - time: .shortened - ) - let end = interval.end.formatted( - date: .abbreviated, - time: .shortened + selectedInterval = accountTokenDisplayInterval( + at: date, + in: displayIntervals, + within: visibleRange ) - return "\(start)–\(end)" } } @@ -1903,7 +2004,6 @@ private struct TokenSourceCard: View { let source: String let value: String let detail: String - let coverage: String let freshness: Date? let freshnessLabel: String let color: Color @@ -1925,17 +2025,14 @@ private struct TokenSourceCard: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(3) - HStack(spacing: 12) { - Text("Coverage \(coverage)") - if let freshness { - Text(freshnessLabel + " " + freshness.formatted( - date: .abbreviated, - time: .shortened - )) - } + if let freshness { + Text(freshnessLabel + " " + freshness.formatted( + date: .abbreviated, + time: .shortened + )) + .font(.caption2) + .foregroundStyle(.tertiary) } - .font(.caption2) - .foregroundStyle(.tertiary) } .padding(14) .frame(maxWidth: .infinity, minHeight: 155, alignment: .topLeading) @@ -1948,7 +2045,7 @@ private struct TokenSourceCard: View { .stroke(color.opacity(0.16)) } .help( - "\(title), \(source). \(value) tokens. Coverage \(coverage). \(detail)" + "\(title), \(source). \(value) tokens. \(detail)" ) } } @@ -1961,7 +2058,7 @@ private struct WorkspaceFilterMenu: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - endingAt: min( + now: min( reader.localTokenActivity.observedAt ?? bounds.end, bounds.end ) @@ -2046,6 +2143,7 @@ private struct UsageRemainingChart: View { let chart: UsageChartSnapshot let evidence: UsageEvidence @ObservedObject var store: AnalyticsWorkspaceStore + let now: Date @State private var selection: UsageChartSelection? @State private var pendingRange: DateInterval? @@ -2057,7 +2155,11 @@ private struct UsageRemainingChart: View { } private var bounds: DateInterval { - chart.availableRange(including: currentWindowBounds) + let available = chart.availableRange(including: currentWindowBounds) + return DateInterval( + start: available.start, + end: max(available.end, now) + ) } private var visibleRange: DateInterval { @@ -2066,7 +2168,7 @@ private struct UsageRemainingChart: View { } return store.effectiveRange( within: bounds, - endingAt: chart.preferredZoomAnchor ?? min(Date(), bounds.end) + now: now ) } @@ -2933,7 +3035,7 @@ private struct FactsWorkspace: View { let bounds = reader.usageReceipts.interval return store.effectiveRange( within: bounds, - endingAt: min( + now: min( reader.localTokenActivity.observedAt ?? bounds.end, bounds.end ) diff --git a/Sources/CodexLimits/UsageHistory.swift b/Sources/CodexLimits/UsageHistory.swift index d0b31b4..da35ec5 100644 --- a/Sources/CodexLimits/UsageHistory.swift +++ b/Sources/CodexLimits/UsageHistory.swift @@ -983,34 +983,39 @@ actor UsageHistory { } private func normalized(_ samples: [UsageSample]) -> [UsageSample] { - let valid = samples.filter(\.isValid) - var byIdentity: [UsageSample: UsageSample] = [:] - for sample in valid { - guard let existing = byIdentity[sample] else { - byIdentity[sample] = sample - continue - } - let lifetimeTokens = if let newTokens = sample.lifetimeTokens, - newTokens > (existing.lifetimeTokens ?? .min) { - newTokens - } else { - existing.lifetimeTokens + let valid = samples.filter(\.hasValidObservationMetadata) + let normalized = Dictionary(grouping: valid, by: { $0 }).values + .flatMap { matchingSamples -> [UsageSample] in + let sample = matchingSamples[0] + let comparisonBreak = matchingSamples.contains { + $0.comparisonBreak + } + let counters = Set(matchingSamples.compactMap(\.lifetimeTokens)) + if counters.isEmpty { + return [UsageSample( + observedAt: sample.observedAt, + remainingPercent: sample.remainingPercent, + resetsAt: sample.resetsAt, + comparisonBreak: comparisonBreak + )] + } + return counters.map { + UsageSample( + observedAt: sample.observedAt, + remainingPercent: sample.remainingPercent, + resetsAt: sample.resetsAt, + lifetimeTokens: $0, + comparisonBreak: comparisonBreak + ) + } } - byIdentity[sample] = UsageSample( - observedAt: existing.observedAt, - remainingPercent: existing.remainingPercent, - resetsAt: existing.resetsAt, - lifetimeTokens: lifetimeTokens, - comparisonBreak: - existing.comparisonBreak || sample.comparisonBreak - ) - } - return byIdentity.values.sorted { + return normalized.sorted { if $0.observedAt != $1.observedAt { return $0.observedAt < $1.observedAt } if $0.remainingPercent != $1.remainingPercent { return $0.remainingPercent > $1.remainingPercent } - return $0.resetsAt < $1.resetsAt + if $0.resetsAt != $1.resetsAt { return $0.resetsAt < $1.resetsAt } + return ($0.lifetimeTokens ?? .min) < ($1.lifetimeTokens ?? .min) } } diff --git a/Sources/CodexLimits/UsageIntelligenceEngine.swift b/Sources/CodexLimits/UsageIntelligenceEngine.swift index 3535d5b..92c7099 100644 --- a/Sources/CodexLimits/UsageIntelligenceEngine.swift +++ b/Sources/CodexLimits/UsageIntelligenceEngine.swift @@ -97,7 +97,7 @@ enum UsageRunway: Equatable, Sendable { .locale(Locale(identifier: "en_US")) ) case .throughReset: - "Through reset" + "Limit should last through reset" } } @@ -358,9 +358,45 @@ enum AccountTokenActivityState: String, Equatable, Sendable { case unavailable } -enum AccountTokenActivityMethod: String, Equatable, Sendable { +enum AccountTokenActivityMethod: String, Equatable, Hashable, Sendable { case lifetimeDelta case dailyBuckets + + var displayName: String { + switch self { + case .lifetimeDelta: "Lifetime counter interval" + case .dailyBuckets: "UTC daily bucket" + } + } +} + +struct AccountTokenActivityInterval: Equatable, Hashable, Identifiable, Sendable { + let start: Date + let end: Date + let tokenDelta: Int64 + let method: AccountTokenActivityMethod + let accountPartitionID: String? + let limitID: String? + let allowanceReset: Date? + + var id: Self { self } + var duration: TimeInterval { end.timeIntervalSince(start) } +} + +enum AccountTokenActivityBreakReason: String, Equatable, Hashable, Sendable { + case counterDecrease + case accountChange + case allowanceWindowChange + case conflictingObservation + case correction + case invalidCounter +} + +struct AccountTokenActivityBreak: Equatable, Hashable, Identifiable, Sendable { + let timestamp: Date + let reason: AccountTokenActivityBreakReason + + var id: Self { self } } struct AccountTokenActivitySnapshot: Equatable, Sendable { @@ -369,19 +405,74 @@ struct AccountTokenActivitySnapshot: Equatable, Sendable { let method: AccountTokenActivityMethod? let interval: DateInterval? let reason: String? + let range: DateInterval? + let intervals: [AccountTokenActivityInterval] + let breaks: [AccountTokenActivityBreak] + + init( + state: AccountTokenActivityState, + tokens: Int64?, + method: AccountTokenActivityMethod?, + interval: DateInterval?, + reason: String?, + range: DateInterval? = nil, + intervals: [AccountTokenActivityInterval] = [], + breaks: [AccountTokenActivityBreak] = [] + ) { + self.state = state + self.tokens = tokens + self.method = method + self.interval = interval + self.reason = reason + self.range = range + self.intervals = intervals + self.breaks = breaks + } static func unavailable( _ reason: String, - interval: DateInterval? = nil + interval: DateInterval? = nil, + range: DateInterval? = nil, + breaks: [AccountTokenActivityBreak] = [] ) -> AccountTokenActivitySnapshot { AccountTokenActivitySnapshot( state: .unavailable, tokens: nil, method: nil, interval: interval, - reason: reason + reason: reason, + range: range, + breaks: breaks ) } + + var accessibilityValue: String { + guard let tokens else { + return reason ?? "No account readings in this range" + } + let zero = intervals.contains { $0.tokenDelta == 0 } + ? " Includes an observed zero-token interval." + : "" + return "\(tokens) account tokens observed.\(zero) Time without account observations is missing, not zero." + } + + var currentWindowAccessibilityValue: String { + guard let tokens else { + return reason ?? "No account readings in this range" + } + let zero = intervals.contains { $0.tokenDelta == 0 } + ? " Includes an observed zero-token interval." + : "" + return "\(tokens) account tokens so far.\(zero) Gaps between readings and time since the latest reading are missing, not zero. Time from now until reset is future and has no observation." + } + + var sourceDescription: String { + switch method { + case .lifetimeDelta: "Codex account summary" + case .dailyBuckets: "Codex UTC daily token totals" + case nil: "Codex account" + } + } } struct UsageIntelligenceInput: Equatable, Sendable { @@ -555,6 +646,9 @@ struct UsageReaderSnapshot: Equatable, Sendable { let freshness: UsageFreshness let evidence: UsageEvidence let guidance: UsageGuidance? + let guidanceUnavailableReason: String? + let suggestedPacePercentPerDay: Double? + let suggestedPaceUnavailableReason: String? let chart: UsageChartSnapshot let bankedResets: BankedResetSummary? let accountTokenActivity: AccountTokenActivitySnapshot @@ -570,22 +664,38 @@ struct UsageReaderSnapshot: Equatable, Sendable { var fetchedAt: Date? { account?.fetchedAt } - var weeklyUsageRemaining: LimitReading? { account?.mainLimit } + var weeklyUsageRemaining: LimitReading? { + interval == nil ? nil : account?.mainLimit + } var accountFacts: AccountFacts? { account?.accountFacts } var otherLimits: [LimitReading] { account?.otherLimits ?? [] } var guidanceTitle: String { - guidance?.title ?? "Not enough data" + guidance?.title + ?? guidanceUnavailableReason + ?? (evidence.coverage == .notApplicable ? evidence.reason : nil) + ?? "Guidance unavailable" } var guidanceMessage: String { - guidance?.message ?? evidence.reason ?? "More account history is needed." + guidance?.message + ?? guidanceUnavailableReason + ?? evidence.reason + ?? "Guidance unavailable" + } + + var runwayText: String { + guidance?.runway.text + ?? guidanceUnavailableReason + ?? "Guidance unavailable" } var suggestedPaceText: String { - guidance?.suggestedPace ?? "Not enough data" + suggestedPacePercentPerDay.map(formattedSuggestedPace) + ?? suggestedPaceUnavailableReason + ?? "Weekly usage unavailable" } var evidenceText: String { @@ -628,6 +738,11 @@ private enum CurrentUsagePolicy { static let partialShare = 0.5 } +private enum RecentMovementBreak { + case resetOrCorrection + case accountChange +} + enum UsageIntelligenceEngine { static func evaluate(_ input: UsageIntelligenceInput) -> UsageReaderSnapshot { let currentSamples = input.account.flatMap { account in @@ -675,28 +790,37 @@ enum UsageIntelligenceEngine { now: input.now, workloadMixChanged: workloadMixChanged ) - let forecast: Forecast? = input.account.flatMap { account in - guard let weeklyLimit = account.mainLimit else { return nil } - guard case .available = input.sourceState, - input.now >= weeklyLimit.window.startsAt, - input.now < weeklyLimit.window.resetsAt else { - return nil - } + let recentMovement = recentAccountMovement( + account: input.account, + samples: input.samples, + sourceState: input.sourceState, + now: input.now, + accountEpochStartedAt: input.accountEpochStartedAt + ) + let forecast: Forecast? = recentMovement.movement.flatMap { movement in + guard let account = input.account, + let weeklyLimit = account.mainLimit else { return nil } return ForecastEngine.evaluate( window: weeklyLimit.window, samples: input.samples, tokenHistory: account.tokenHistory, safetyBuffer: input.safetyBuffer, now: input.now, - previousStatus: input.previousStatus + previousStatus: input.previousStatus, + recentMovement: movement ) } - let guidance: UsageGuidance? = forecast.flatMap { forecast in - guard evidence.confidence == .high || evidence.confidence == .medium else { - return nil - } - guard let weeklyLimit = input.account?.mainLimit else { return nil } - return UsageGuidance( + let suggestedPace = suggestedPace( + account: input.account, + sourceState: input.sourceState, + safetyBuffer: input.safetyBuffer, + now: input.now + ) + let guidance: UsageGuidance? + if let forecast, + let movement = recentMovement.movement, + let weeklyLimit = input.account?.mainLimit { + guidance = UsageGuidance( source: .derivedEstimate, status: forecast.status, title: title(for: forecast.status), @@ -704,17 +828,15 @@ enum UsageIntelligenceEngine { window: weeklyLimit.window, forecast: forecast, safetyBuffer: input.safetyBuffer, - now: input.now - ), - suggestedPace: suggestedPace( - forecast: forecast, - reset: weeklyLimit.window.resetsAt, - now: input.now + movement: movement ), + suggestedPace: suggestedPace.percentPerDay + .map(formattedSuggestedPace) + ?? suggestedPace.reason + ?? "Weekly usage unavailable", runway: runway( window: weeklyLimit.window, - forecast: forecast, - now: input.now + movement: movement ), remainingAtResetRange: evidence.confidence == .medium ? estimateRange(forecast) @@ -722,11 +844,15 @@ enum UsageIntelligenceEngine { caveat: evidence.confidence == .medium ? evidence.reason : nil, forecast: forecast ) + } else { + guidance = nil } let chart = chart( account: input.account, samples: input.samples, forecast: forecast, + recentMovement: recentMovement.movement, + sourceState: input.sourceState, safetyBuffer: input.safetyBuffer ) let currentFreshness = freshness( @@ -734,14 +860,51 @@ enum UsageIntelligenceEngine { sourceState: input.sourceState, now: input.now ) - let accountTokenActivity = accountTokenActivity( + let weeklyAccountTokenActivity = accountTokenActivity( account: input.account, samples: currentSamples ) + let selectedTokenRange: DateInterval? + switch input.analyticsExploration.timeRange { + case .currentWindow: + if let window = input.account?.mainLimit?.window, + window.startsAt <= input.now, + input.now < window.resetsAt { + selectedTokenRange = DateInterval( + start: window.startsAt, + end: window.resetsAt + ) + } else { + selectedTokenRange = nil + } + case .selected: + selectedTokenRange = input.analyticsExploration.visibleRange + default: + selectedTokenRange = input.analyticsExploration.timeRange.interval( + within: DateInterval(start: input.now, end: input.now), + now: input.now + ) + } + let accountTokenActivity = if let selectedTokenRange { + selectedRangeAccountTokenActivity( + account: input.account, + samples: input.samples, + range: selectedTokenRange, + now: input.now, + accountPartitionID: input.accountPartitionID, + accountEpochStartedAt: input.accountEpochStartedAt + ) + } else { + AccountTokenActivitySnapshot.unavailable( + input.analyticsExploration.timeRange == .currentWindow + ? "Current allowance window unavailable" + : "No account readings in this range" + ) + } let localTokenActivity: LocalTokenActivitySnapshot if let interval = tokenActivityInterval( account: input.account, - accountActivity: accountTokenActivity, + accountActivity: weeklyAccountTokenActivity, accountEpochStartedAt: input.accountEpochStartedAt ) { if let cached = reusableLocalAggregates?.localTokenActivity, @@ -930,13 +1093,18 @@ enum UsageIntelligenceEngine { localHistory: localHistoryCache ) } - let observedInterval = input.account.flatMap { account in - account.mainLimit.map { - UsageObservedInterval( - limitID: $0.limitId, - durationMinutes: $0.window.durationMinutes, - startsAt: $0.window.startsAt, - resetsAt: $0.window.resetsAt + let observedInterval = input.account.flatMap { + account -> UsageObservedInterval? in + return account.mainLimit.flatMap { limit in + guard input.now >= limit.window.startsAt, + input.now < limit.window.resetsAt else { + return nil + } + return UsageObservedInterval( + limitID: limit.limitId, + durationMinutes: limit.window.durationMinutes, + startsAt: limit.window.startsAt, + resetsAt: limit.window.resetsAt ) } } @@ -952,7 +1120,7 @@ enum UsageIntelligenceEngine { selectedRange: DeterministicInsightInput.effectiveRange( usagePerToken: usagePerToken, observedInterval: observedInterval, - fetchedAt: input.account?.fetchedAt, + now: input.now, exploration: input.analyticsExploration ), filters: input.analyticsExploration.filters @@ -972,6 +1140,9 @@ enum UsageIntelligenceEngine { freshness: currentFreshness, evidence: evidence, guidance: guidance, + guidanceUnavailableReason: recentMovement.reason, + suggestedPacePercentPerDay: suggestedPace.percentPerDay, + suggestedPaceUnavailableReason: suggestedPace.reason, chart: chart, bankedResets: bankedResetSummary( account: input.account, @@ -1193,6 +1364,263 @@ enum UsageIntelligenceEngine { ) } + private static func selectedRangeAccountTokenActivity( + account: UsageSnapshot?, + samples: [UsageSample], + range: DateInterval, + now: Date, + accountPartitionID: String?, + accountEpochStartedAt: Date? + ) -> AccountTokenActivitySnapshot { + var timeline = samples + if let account, + let window = account.mainLimit?.window, + let facts = account.accountFacts, + let lifetimeTokens = facts.lifetimeTokens { + let observedAt = facts.lifetimeTokensObservedAt ?? account.fetchedAt + guard window.startsAt <= observedAt, + observedAt <= window.resetsAt else { + return evaluateAccountTokenTimeline( + timeline, + account: account, + range: range, + now: now, + accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt + ) + } + timeline.append(UsageSample( + observedAt: observedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + lifetimeTokens: lifetimeTokens + )) + } + return evaluateAccountTokenTimeline( + timeline, + account: account, + range: range, + now: now, + accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt + ) + } + + private static func evaluateAccountTokenTimeline( + _ timeline: [UsageSample], + account: UsageSnapshot?, + range: DateInterval, + now: Date, + accountPartitionID: String?, + accountEpochStartedAt: Date? + ) -> AccountTokenActivitySnapshot { + let readings = timeline.filter { $0.observedAt <= now }.sorted { + if $0.observedAt != $1.observedAt { + return $0.observedAt < $1.observedAt + } + if $0.resetsAt != $1.resetsAt { + return $0.resetsAt < $1.resetsAt + } + if $0.lifetimeTokens != $1.lifetimeTokens { + return ($0.lifetimeTokens ?? -1) < ($1.lifetimeTokens ?? -1) + } + if $0.remainingPercent != $1.remainingPercent { + return $0.remainingPercent < $1.remainingPercent + } + return !$0.comparisonBreak && $1.comparisonBreak + } + var unique: [UsageSample] = [] + for reading in readings { + if let last = unique.last, + last.observedAt == reading.observedAt, + last.resetsAt == reading.resetsAt, + last.lifetimeTokens == reading.lifetimeTokens, + last.remainingPercent == reading.remainingPercent { + if reading.comparisonBreak { + unique[unique.count - 1] = reading + } + continue + } + unique.append(reading) + } + let conflictingTimestamps = Set( + Dictionary(grouping: unique, by: \.observedAt) + .compactMap { $0.value.count > 1 ? $0.key : nil } + ) + var intervals: [AccountTokenActivityInterval] = [] + var breaks: [AccountTokenActivityBreak] = [] + var previous: UsageSample? + var sawEarlierEpoch = false + + func recordBreak( + at timestamp: Date, + reason: AccountTokenActivityBreakReason + ) { + guard range.contains(timestamp) else { return } + let value = AccountTokenActivityBreak( + timestamp: timestamp, + reason: reason + ) + if breaks.last != value { breaks.append(value) } + } + + for reading in unique { + if let accountEpochStartedAt, + reading.observedAt < accountEpochStartedAt { + sawEarlierEpoch = true + previous = nil + continue + } + let crossedAccountEpoch = sawEarlierEpoch + if crossedAccountEpoch, let accountEpochStartedAt { + recordBreak(at: accountEpochStartedAt, reason: .accountChange) + sawEarlierEpoch = false + } + if conflictingTimestamps.contains(reading.observedAt) { + recordBreak( + at: reading.observedAt, + reason: .conflictingObservation + ) + previous = nil + continue + } + guard reading.isValid, + let readingTokens = reading.lifetimeTokens, + readingTokens >= 0 else { + recordBreak(at: reading.observedAt, reason: .invalidCounter) + previous = nil + continue + } + if reading.comparisonBreak { + if !crossedAccountEpoch { + recordBreak(at: reading.observedAt, reason: .correction) + } + previous = reading + continue + } + guard let start = previous, + let startTokens = start.lifetimeTokens, + let endTokens = reading.lifetimeTokens else { + previous = reading + continue + } + defer { previous = reading } + guard reading.resetsAt == start.resetsAt else { + recordBreak( + at: reading.observedAt, + reason: .allowanceWindowChange + ) + continue + } + guard endTokens >= startTokens else { + recordBreak( + at: reading.observedAt, + reason: .counterDecrease + ) + continue + } + guard start.observedAt >= range.start, + reading.observedAt <= range.end else { continue } + intervals.append(AccountTokenActivityInterval( + start: start.observedAt, + end: reading.observedAt, + tokenDelta: endTokens - startTokens, + method: .lifetimeDelta, + accountPartitionID: accountPartitionID, + limitID: account?.mainLimit?.limitId, + allowanceReset: reading.resetsAt + )) + } + guard let first = intervals.first, + let last = intervals.last else { + if let account, + let fallback = dailyTokenActivity( + account: account, + range: range, + now: now, + accountPartitionID: accountPartitionID, + breaks: breaks + ) { + return fallback + } + return .unavailable( + "No account readings in this range", + range: range, + breaks: breaks + ) + } + var total: Int64 = 0 + for interval in intervals { + let sum = total.addingReportingOverflow(interval.tokenDelta) + guard !sum.overflow else { + return .unavailable( + "Lifetime token reading is invalid", + range: range + ) + } + total = sum.partialValue + } + return AccountTokenActivitySnapshot( + state: .partial, + tokens: total, + method: .lifetimeDelta, + interval: DateInterval(start: first.start, end: last.end), + reason: nil, + range: range, + intervals: intervals, + breaks: breaks + ) + } + + private static func dailyTokenActivity( + account: UsageSnapshot, + range: DateInterval, + now: Date, + accountPartitionID: String?, + breaks: [AccountTokenActivityBreak] = [] + ) -> AccountTokenActivitySnapshot? { + let intervals = account.tokenHistory.compactMap { + day -> AccountTokenActivityInterval? in + let end = day.date.addingTimeInterval(86_400) + guard day.completeness == .complete, + day.tokens >= 0, + day.date >= range.start, + end <= range.end, + end <= now else { return nil } + return AccountTokenActivityInterval( + start: day.date, + end: end, + tokenDelta: day.tokens, + method: .dailyBuckets, + accountPartitionID: accountPartitionID, + limitID: account.mainLimit?.limitId, + allowanceReset: nil + ) + }.sorted { $0.start < $1.start } + guard let first = intervals.first, + let last = intervals.last else { return nil } + var total: Int64 = 0 + for interval in intervals { + let sum = total.addingReportingOverflow(interval.tokenDelta) + guard !sum.overflow else { return nil } + total = sum.partialValue + } + let isContinuous = zip(intervals, intervals.dropFirst()) + .allSatisfy { $0.0.end == $0.1.start } + return AccountTokenActivitySnapshot( + state: first.start == range.start + && last.end == range.end + && isContinuous ? .exact : .partial, + tokens: total, + method: .dailyBuckets, + interval: DateInterval(start: first.start, end: last.end), + reason: nil, + range: range, + intervals: intervals, + breaks: breaks + ) + } + private static func dailyTokenActivity( account: UsageSnapshot, window: UsageWindow @@ -1248,6 +1676,8 @@ enum UsageIntelligenceEngine { account: UsageSnapshot?, samples: [UsageSample], forecast: Forecast?, + recentMovement: RecentAccountMovement?, + sourceState: UsageSourceState, safetyBuffer: Double ) -> UsageChartSnapshot { guard let account, let weeklyLimit = account.mainLimit else { @@ -1270,11 +1700,41 @@ enum UsageIntelligenceEngine { account: account, samples: samples ) - guard let forecast else { + let historicalReference: UsageForecastReference? = if case .available = sourceState { + chartHistoricalReference( + samples: samples, + excluding: window.resetsAt, + remaining: window.remainingPercent, + daysLeft: max( + window.resetsAt.timeIntervalSince(account.fetchedAt) + / 86_400, + 0 + ) + ) + } else { + nil + } + guard let forecast, let recentMovement else { + let reference = historicalReference.map { + UsageChartReferenceSeries( + source: $0.source, + points: projection( + reading: UsageSample( + observedAt: account.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt + ), + window: window, + rate: $0.percentPerDay, + remainingAtReset: $0.remainingAtReset + ) + ) + } return UsageChartSnapshot( observedSource: .account, target: target, currentProjection: [], + reference: reference, currentAllowanceReset: window.resetsAt, allowanceWindows: allowanceWindows, currentRunsFaster: false, @@ -1286,22 +1746,13 @@ enum UsageIntelligenceEngine { ? forecast.historicalReference : nil let chartReference = strictAccountReference - ?? chartHistoricalReference( - samples: samples, - excluding: window.resetsAt, - remaining: window.remainingPercent, - daysLeft: max( - window.resetsAt.timeIntervalSince(account.fetchedAt) - / 86_400, - 0 - ) - ) + ?? historicalReference ?? forecast.historicalReference let reference = chartReference.map { UsageChartReferenceSeries( source: $0.source, points: projection( - account: account, + reading: recentMovement.latest, window: window, rate: $0.percentPerDay, remainingAtReset: $0.remainingAtReset @@ -1315,7 +1766,7 @@ enum UsageIntelligenceEngine { observedSource: .account, target: target, currentProjection: projection( - account: account, + reading: recentMovement.latest, window: window, rate: forecast.currentPercentPerDay, remainingAtReset: forecast.expectedRemainingAtReset @@ -1426,26 +1877,26 @@ enum UsageIntelligenceEngine { } private static func projection( - account: UsageSnapshot, + reading: UsageSample, window: UsageWindow, rate: Double, remainingAtReset: Double ) -> [UsageChartPoint] { let current = UsageChartPoint( - date: account.fetchedAt, - remaining: window.remainingPercent + date: reading.observedAt, + remaining: reading.remainingPercent ) guard rate > 0 else { return [ current, UsageChartPoint( date: window.resetsAt, - remaining: window.remainingPercent + remaining: reading.remainingPercent ) ] } - let exhaustion = account.fetchedAt.addingTimeInterval( - window.remainingPercent / rate * 86_400 + let exhaustion = reading.observedAt.addingTimeInterval( + reading.remainingPercent / rate * 86_400 ) let endpoint = exhaustion < window.resetsAt ? UsageChartPoint(date: exhaustion, remaining: 0) @@ -1487,6 +1938,130 @@ enum UsageIntelligenceEngine { : .fresh } + private static func recentAccountMovement( + account: UsageSnapshot?, + samples: [UsageSample], + sourceState: UsageSourceState, + now: Date, + accountEpochStartedAt: Date? + ) -> (movement: RecentAccountMovement?, reason: String?) { + if case let .failed(message) = sourceState { + return (nil, message) + } + guard let account, + let window = account.mainLimit?.window, + window.isValid else { + return (nil, "No current weekly allowance reading") + } + guard now >= window.startsAt else { + return (nil, "Current allowance window has not started") + } + guard now < window.resetsAt else { + return (nil, "Current allowance window unavailable") + } + + let current = UsageSample( + observedAt: account.fetchedAt, + remainingPercent: window.remainingPercent, + resetsAt: window.resetsAt, + comparisonBreak: accountEpochStartedAt == account.fetchedAt + ) + let rollingStart = max( + now.addingTimeInterval(-86_400), + window.startsAt, + accountEpochStartedAt ?? .distantPast + ) + let grouped = Dictionary( + grouping: (samples + [current]).filter { + $0.hasValidObservationMetadata + && $0.observedAt >= rollingStart + && $0.observedAt <= account.fetchedAt + && $0.observedAt <= now + }, + by: \.observedAt + ) + var conflicts = Set() + let readings = grouped.compactMap { date, values -> UsageSample? in + let preferred = values.first { + date == current.observedAt + && $0.remainingPercent == current.remainingPercent + && $0.resetsAt == current.resetsAt + } ?? values.min { + if $0.resetsAt != $1.resetsAt { + return $0.resetsAt < $1.resetsAt + } + return $0.remainingPercent < $1.remainingPercent + } + guard let preferred else { return nil } + if values.contains(where: { + $0.remainingPercent != preferred.remainingPercent + || $0.resetsAt != preferred.resetsAt + }) { + conflicts.insert(date) + } + return UsageSample( + observedAt: preferred.observedAt, + remainingPercent: preferred.remainingPercent, + resetsAt: preferred.resetsAt, + lifetimeTokens: preferred.lifetimeTokens, + comparisonBreak: values.contains { $0.comparisonBreak } + ) + }.sorted { $0.observedAt < $1.observedAt } + + var segment: [UsageSample] = [] + var latestBreak: RecentMovementBreak? + for reading in readings { + let accountChanged = reading.comparisonBreak + && accountEpochStartedAt == reading.observedAt + if conflicts.contains(reading.observedAt) + || reading.comparisonBreak + || segment.last.map({ + $0.resetsAt != reading.resetsAt + || reading.remainingPercent + > $0.remainingPercent + + UsageHistoryPolicy.correctionTolerance + }) == true { + segment = [reading] + latestBreak = accountChanged ? .accountChange : .resetOrCorrection + } else { + segment.append(reading) + } + } + + guard let first = segment.first, + let latest = segment.last, + segment.count >= 2, + latest.observedAt > first.observedAt else { + switch latestBreak { + case .accountChange: + return (nil, "Account changed — waiting for a second reading") + case .resetOrCorrection: + return ( + nil, + "Waiting for a second account reading after reset or correction" + ) + case nil: + return (nil, "At least two compatible account readings are needed") + } + } + let elapsedDays = latest.observedAt.timeIntervalSince(first.observedAt) + / 86_400 + let percentPerDay = max( + (first.remainingPercent - latest.remainingPercent) / elapsedDays, + 0 + ) + guard percentPerDay.isFinite else { + return (nil, "At least two compatible account readings are needed") + } + return ( + RecentAccountMovement( + latest: latest, + percentPerDay: percentPerDay + ), + nil + ) + } + private static func evidence( account: UsageSnapshot?, samples: [UsageSample], @@ -1498,7 +2073,7 @@ enum UsageIntelligenceEngine { return UsageEvidence( coverage: .unavailable, confidence: .unavailable, - reason: "Weekly usage unavailable", + reason: "No current weekly allowance reading", policyVersion: CurrentUsagePolicy.version ) } @@ -1507,7 +2082,9 @@ enum UsageIntelligenceEngine { return UsageEvidence( coverage: .notApplicable, confidence: .unavailable, - reason: "Weekly window ended", + reason: now >= window.resetsAt + ? "Current allowance window unavailable" + : "Current allowance window has not started", policyVersion: CurrentUsagePolicy.version ) } @@ -1635,12 +2212,11 @@ enum UsageIntelligenceEngine { private static func runway( window: UsageWindow, - forecast: Forecast, - now: Date + movement: RecentAccountMovement ) -> UsageRunway { - guard forecast.currentPercentPerDay > 0 else { return .throughReset } - let exhaustsAt = now.addingTimeInterval( - window.remainingPercent / forecast.currentPercentPerDay * 86_400 + guard movement.percentPerDay > 0 else { return .throughReset } + let exhaustsAt = movement.latest.observedAt.addingTimeInterval( + movement.latest.remainingPercent / movement.percentPerDay * 86_400 ) return exhaustsAt < window.resetsAt ? .exhausts( @@ -1674,12 +2250,14 @@ enum UsageIntelligenceEngine { window: UsageWindow, forecast: Forecast, safetyBuffer: Double, - now: Date + movement: RecentAccountMovement ) -> String { switch forecast.status { case .slowDown: - let timeLeft = window.resetsAt.timeIntervalSince(now) - let timeToEmpty = window.remainingPercent + let timeLeft = window.resetsAt.timeIntervalSince( + movement.latest.observedAt + ) + let timeToEmpty = movement.latest.remainingPercent / max(forecast.safetyPercentPerDay, 0.01) * 86_400 let early = max(timeLeft - timeToEmpty, 0) return early > 0 @@ -1694,23 +2272,32 @@ enum UsageIntelligenceEngine { } private static func suggestedPace( - forecast: Forecast, - reset: Date, + account: UsageSnapshot?, + sourceState: UsageSourceState, + safetyBuffer: Double, now: Date - ) -> String { - let value = reset.timeIntervalSince(now) <= 86_400 - ? forecast.recommendedPercentPerDay / 24 - : forecast.recommendedPercentPerDay - let unit = reset.timeIntervalSince(now) <= 86_400 ? "an hour" : "a day" - return "Up to \(oneDecimal(value))% \(unit)" - } - - private static func oneDecimal(_ value: Double) -> String { - value.formatted( - .number - .precision(.fractionLength(1)) - .locale(Locale(identifier: "en_US")) + ) -> (percentPerDay: Double?, reason: String?) { + if case let .failed(message) = sourceState { + return (nil, message) + } + guard let window = account?.mainLimit?.window, window.isValid else { + return (nil, "Weekly usage unavailable") + } + guard now >= window.startsAt else { + return (nil, "Current allowance window has not started") + } + let secondsToReset = window.resetsAt.timeIntervalSince(now) + guard secondsToReset > 0, secondsToReset.isFinite else { + return (nil, "Current allowance window unavailable") + } + let value = max( + (window.remainingPercent - safetyBuffer) + / (secondsToReset / 86_400), + 0 ) + return value.isFinite + ? (value, nil) + : (nil, "Current allowance window unavailable") } private static func durationText(_ seconds: TimeInterval) -> String { @@ -1722,3 +2309,12 @@ enum UsageIntelligenceEngine { return "\(hours) \(hours == 1 ? "hour" : "hours")" } } + +private func formattedSuggestedPace(_ percentPerDay: Double) -> String { + let value = percentPerDay.formatted( + .number + .precision(.fractionLength(1)) + .locale(Locale(identifier: "en_US")) + ) + return "Up to \(value) percentage points a day" +} diff --git a/Sources/CodexLimits/UsageModels.swift b/Sources/CodexLimits/UsageModels.swift index f9c5e7d..79e7789 100644 --- a/Sources/CodexLimits/UsageModels.swift +++ b/Sources/CodexLimits/UsageModels.swift @@ -69,12 +69,16 @@ struct UsageSample: Codable, Equatable, Hashable, Sendable { let lifetimeTokens: Int64? let comparisonBreak: Bool - var isValid: Bool { + var hasValidObservationMetadata: Bool { observedAt.isSupportedUsageDate && resetsAt.isSupportedUsageDate && observedAt <= resetsAt && remainingPercent.isFinite && (0 ... 100).contains(remainingPercent) + } + + var isValid: Bool { + hasValidObservationMetadata && (lifetimeTokens.map { $0 >= 0 } ?? true) } diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift index c9b5d6a..c52c021 100644 --- a/Sources/CodexLimits/UsageMonitor.swift +++ b/Sources/CodexLimits/UsageMonitor.swift @@ -144,7 +144,9 @@ final class UsageMonitor: ObservableObject { } if let data = defaults.data(forKey: Self.stateKey), let state = try? JSONDecoder().decode(StoredState.self, from: data) { - let restoredSamples = state.samples.filter(\.isValid) + let restoredSamples = state.samples.filter( + \.hasValidObservationMetadata + ) accountSnapshot = state.snapshot.flatMap { $0.isValid ? $0 : nil } @@ -918,13 +920,13 @@ final class UsageMonitor: ObservableObject { ) { let input = DeterministicInsightInput( reader: readerSnapshot, - exploration: exploration + exploration: exploration, + now: Date() ) readerSnapshot.insights = DeterministicInsightEngine.evaluate( input, dispositions: dispositions ) - guard evaluationTask != nil else { return } let pending = beginRecalculation( analyticsExploration: exploration, insightDispositions: dispositions diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift index 06819c1..a7736ba 100644 --- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift +++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift @@ -123,45 +123,6 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } - func testAccountTokenRangeSumsOnlyCompleteFullDays() throws { - let formatter = ISO8601DateFormatter() - let interval = DateInterval( - start: try XCTUnwrap( - formatter.date(from: "2026-07-01T12:00:00Z") - ), - end: try XCTUnwrap( - formatter.date(from: "2026-07-04T12:00:00Z") - ) - ) - let days = try [ - ("2026-07-01T00:00:00Z", 100, TokenDayCompleteness.complete), - ("2026-07-02T00:00:00Z", 200, .complete), - ("2026-07-03T00:00:00Z", 300, .complete), - ("2026-07-04T00:00:00Z", 400, .partial) - ].map { - TokenDay( - date: try XCTUnwrap(formatter.date(from: $0.0)), - tokens: Int64($0.1), - completeness: $0.2 - ) - } - - let range = AccountTokenActivityRange( - days: days, - interval: interval - ) - - XCTAssertEqual(range.days.map(\.tokens), [200, 300]) - XCTAssertEqual(range.completeDayCount, 2) - XCTAssertEqual(range.completeTokens, 500) - - let missingDay = AccountTokenActivityRange( - days: days.filter { $0.date != days[2].date }, - interval: interval - ) - XCTAssertNil(missingDay.completeTokens) - } - func testRestoredLocalGraphFallsBackToUsageRemaining() throws { let defaults = try XCTUnwrap( UserDefaults( @@ -277,33 +238,589 @@ final class AnalyticsWorkspaceTests: XCTestCase { XCTAssertNil(reader.localTokenActivity.tokens) } - func testPresetRangeEndsAtLatestObservedTimeAndIsClampedToWindow() { - let end = Date(timeIntervalSince1970: 10 * 86_400) - let bounds = DateInterval( - start: end.addingTimeInterval(-7 * 86_400), - end: end + func testRollingTokenActivityViewDistinguishesObservedZeroAndMissing() { + let now = Date(timeIntervalSince1970: 10 * 86_400) + let account = usageSnapshot(fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + var exploration = AnalyticsExplorationState.initial + exploration.graph = .tokenActivity + exploration.timeRange = .oneDay + let evaluate: ([UsageSample]) -> UsageReaderSnapshot = { samples in + UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ) + } + let first = UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let positive = evaluate([ + first, + UsageSample( + observedAt: now, + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ]) + let zero = evaluate([ + first, + UsageSample( + observedAt: now, + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ]) + let missing = evaluate([first]) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! ) - let latestObserved = end.addingTimeInterval(-5 * 86_400) + store.selectGraph(.tokenActivity) + store.selectTimeRange(.oneDay) + XCTAssertEqual(positive.accountTokenActivity.tokens, 300) + XCTAssertTrue( + positive.accountTokenActivity.accessibilityValue.contains( + "Time without account observations is missing, not zero" + ) + ) + XCTAssertFalse( + positive.accountTokenActivity.accessibilityValue.contains( + "Coverage" + ) + ) + XCTAssertEqual(zero.accountTokenActivity.tokens, 0) + XCTAssertTrue( + zero.accountTokenActivity.accessibilityValue.contains( + "observed zero-token interval" + ) + ) XCTAssertEqual( - AnalyticsTimeRange.oneDay.interval( - within: bounds, - endingAt: latestObserved + missing.accountTokenActivity.accessibilityValue, + "No account readings in this range" + ) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: positive, + store: store, + assistedInsights: CodexAssistedInsightStore(), + now: now + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + + func testUTCBucketIntervalUsesTheRequestedMacTimeZoneForDisplay() throws { + let formatter = ISO8601DateFormatter() + let locale = Locale(identifier: "en_US_POSIX") + let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let winter = DateInterval( + start: try XCTUnwrap(formatter.date(from: "2026-01-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-01-02T00:00:00Z")) + ) + let summer = DateInterval( + start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")) + ) + + let utcText = accountTokenIntervalText( + winter, + timeZone: utc, + locale: locale + ) + let cetText = accountTokenIntervalText( + winter, + timeZone: berlin, + locale: locale + ) + let summerUTCText = accountTokenIntervalText( + summer, + timeZone: utc, + locale: locale + ) + let cestText = accountTokenIntervalText( + summer, + timeZone: berlin, + locale: locale + ) + + XCTAssertNotEqual(utcText, cetText) + XCTAssertNotEqual(summerUTCText, cestText) + XCTAssertEqual(berlin.secondsFromGMT(for: winter.start), 3_600) + XCTAssertEqual(berlin.secondsFromGMT(for: summer.start), 7_200) + XCTAssertEqual(winter.duration, 86_400) + XCTAssertEqual(summer.duration, 86_400) + } + + func testCurrentWindowTokenActivityExposesSoFarCopyAndRenders() throws { + let start = try date("2026-08-01T12:13:00Z") + let now = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "weekly", + name: "Weekly", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) ), - DateInterval( - start: latestObserved.addingTimeInterval(-86_400), - end: latestObserved + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: now + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil ) ) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + store.selectGraph(.tokenActivity) + store.selectTimeRange(.currentWindow) + + XCTAssertEqual(reader.accountTokenActivity.tokens, 300) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("so far") + ) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("Time from now until reset is future and has no observation") + ) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore(), + now: now + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + + func testAccountTokenIntervalSelectionPreservesFactualIdentity() { + let range = DateInterval( + start: Date(timeIntervalSince1970: 1_000), + end: Date(timeIntervalSince1970: 10_000) + ) + let positive = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 3_000), + tokenDelta: 300, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + let zero = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 5_000), + end: Date(timeIntervalSince1970: 6_000), + tokenDelta: 0, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + let daily = AccountTokenActivityInterval( + start: Date(timeIntervalSince1970: 7_000), + end: Date(timeIntervalSince1970: 8_000), + tokenDelta: 900, + method: .dailyBuckets, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: nil + ) + let intervals = [daily, zero, positive] + + XCTAssertEqual( + accountTokenInterval( + at: Date(timeIntervalSince1970: 2_500), + in: intervals, + within: range + ), + positive + ) + XCTAssertEqual( + accountTokenInterval( + at: Date(timeIntervalSince1970: 5_500), + in: intervals, + within: range + ), + zero + ) + XCTAssertNil(accountTokenInterval( + at: Date(timeIntervalSince1970: 4_000), + in: intervals, + within: range + )) + XCTAssertNil(accountTokenInterval( + at: Date(timeIntervalSince1970: 9_000), + in: intervals, + within: range + )) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: nil, + by: 1 + ), + positive + ) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: positive, + by: 1 + ), + zero + ) + XCTAssertEqual( + steppedAccountTokenInterval( + in: intervals, + from: zero, + by: 1 + ), + daily + ) + XCTAssertEqual( + retainedAccountTokenInterval( + daily, + in: intervals, + range: range + ), + daily + ) + XCTAssertNil(retainedAccountTokenInterval( + daily, + in: [positive, zero], + range: range + )) + XCTAssertNil(retainedAccountTokenInterval( + daily, + in: intervals, + range: DateInterval(start: range.start, end: daily.start) + )) + XCTAssertEqual(zero.method.displayName, "Lifetime counter interval") + XCTAssertEqual(daily.method.displayName, "UTC daily bucket") + XCTAssertTrue( + accountTokenIntervalAccessibilityValue(zero) + .contains("0 account tokens. Account.") + ) + XCTAssertTrue( + accountTokenIntervalAccessibilityValue(daily) + .contains("UTC daily bucket") + ) + } + + func testSelectedTokenIntervalFormattingDoesNotChangeIdentity() throws { + let formatter = ISO8601DateFormatter() + let interval = AccountTokenActivityInterval( + start: try XCTUnwrap(formatter.date(from: "2026-07-01T00:00:00Z")), + end: try XCTUnwrap(formatter.date(from: "2026-07-02T00:00:00Z")), + tokenDelta: 900, + method: .dailyBuckets, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: nil + ) + let utc = try XCTUnwrap(TimeZone(identifier: "UTC")) + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let locale = Locale(identifier: "en_US_POSIX") + let dateInterval = DateInterval(start: interval.start, end: interval.end) + + XCTAssertNotEqual( + accountTokenIntervalText( + dateInterval, + timeZone: utc, + locale: locale + ), + accountTokenIntervalText( + dateInterval, + timeZone: berlin, + locale: locale + ) + ) + XCTAssertEqual(interval.tokenDelta, 900) + XCTAssertEqual(interval.id, interval) + } + + func testTokenDisplayAggregationPreservesTotalsGapsAndBreaks() { + let base = Date(timeIntervalSince1970: 1_000) + let reset = base.addingTimeInterval(20_000) + func interval( + _ start: TimeInterval, + _ end: TimeInterval, + _ tokens: Int64, + method: AccountTokenActivityMethod = .lifetimeDelta + ) -> AccountTokenActivityInterval { + AccountTokenActivityInterval( + start: base.addingTimeInterval(start), + end: base.addingTimeInterval(end), + tokenDelta: tokens, + method: method, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: reset + ) + } + let source = [ + interval(0, 10, 10), + interval(10, 20, 20), + interval(30, 40, 30), + interval(40, 50, 40), + interval(50, 60, 50, method: .dailyBuckets), + interval(60, 70, 0, method: .dailyBuckets), + interval(70, 80, 0, method: .dailyBuckets) + ] + let breakAt40 = AccountTokenActivityBreak( + timestamp: base.addingTimeInterval(40), + reason: .counterDecrease + ) + let coarse = displayedAccountTokenIntervals( + source.reversed(), + breaks: [breakAt40], + maximumMarks: 2 + ) + let fine = displayedAccountTokenIntervals( + source, + breaks: [breakAt40], + maximumMarks: 100 + ) + let sourceTotal = source.reduce(Int64(0)) { $0 + $1.tokenDelta } + + XCTAssertEqual( + coarse.reduce(Int64(0)) { $0 + $1.tokenDelta }, + sourceTotal + ) + XCTAssertEqual( + fine.reduce(Int64(0)) { $0 + $1.tokenDelta }, + sourceTotal + ) + XCTAssertEqual( + coarse, + displayedAccountTokenIntervals( + source, + breaks: [breakAt40], + maximumMarks: 2 + ) + ) + XCTAssertGreaterThanOrEqual(fine.count, coarse.count) + XCTAssertEqual(fine.count, source.count) + XCTAssertEqual(coarse.first?.sourceIntervals.count, 2) + XCTAssertFalse(coarse.contains { + $0.start < base.addingTimeInterval(30) + && $0.end > base.addingTimeInterval(20) + }) + XCTAssertFalse(coarse.contains { + $0.start < breakAt40.timestamp + && $0.end > breakAt40.timestamp + }) + XCTAssertFalse(coarse.contains { + Set($0.sourceIntervals.map(\.method)).count > 1 + }) + XCTAssertFalse(coarse.contains { + $0.sourceIntervals.contains { $0.tokenDelta == 0 } + && $0.sourceIntervals.contains { $0.tokenDelta > 0 } + }) + XCTAssertEqual(source.count, 7) + } + + func testAggregatedTokenSelectionAndAccessibilityRemainTruthful() { + let range = DateInterval( + start: Date(timeIntervalSince1970: 1_000), + end: Date(timeIntervalSince1970: 2_000) + ) + func sourceInterval(_ index: Int) -> AccountTokenActivityInterval { + AccountTokenActivityInterval( + start: range.start.addingTimeInterval(Double(index) * 100), + end: range.start.addingTimeInterval(Double(index + 1) * 100), + tokenDelta: Int64((index + 1) * 100), + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + ) + } + let source = [0, 1, 2].map(sourceInterval) + let display = displayedAccountTokenIntervals( + source, + breaks: [], + maximumMarks: 1 + ) + let zoomedRange = DateInterval( + start: source[0].start, + end: source[1].end + ) + let zoomed = displayedAccountTokenIntervals( + source.filter { + $0.start >= zoomedRange.start && $0.end <= zoomedRange.end + }, + breaks: [], + maximumMarks: 3 + ) + let selected = accountTokenDisplayInterval( + at: range.start.addingTimeInterval(150), + in: display, + within: range + ) + + XCTAssertEqual(display.count, 1) + XCTAssertEqual(selected?.tokenDelta, 600) + XCTAssertEqual(selected?.sourceIntervals.count, 3) + XCTAssertEqual(zoomed.count, 2) + XCTAssertTrue(zoomed.allSatisfy { !$0.isAggregated }) + XCTAssertEqual(source.count, 3) + XCTAssertEqual( + steppedAccountTokenDisplayInterval( + in: display, + from: nil, + by: 1 + ), + selected + ) + XCTAssertTrue( + accountTokenDisplayIntervalAccessibilityValue(selected!) + .contains("Combined from 3 observed periods") + ) + XCTAssertNil(accountTokenDisplayInterval( + at: range.start.addingTimeInterval(500), + in: display, + within: range + )) + + let zero = AccountTokenActivityDisplayInterval( + sourceIntervals: [AccountTokenActivityInterval( + start: range.start, + end: range.start.addingTimeInterval(100), + tokenDelta: 0, + method: .lifetimeDelta, + accountPartitionID: "account-a", + limitID: "weekly", + allowanceReset: range.end + )] + ) + XCTAssertTrue( + accountTokenDisplayIntervalAccessibilityValue(zero) + .contains("Observed zero-token interval") + ) + } + + func testRollingPresetsEndAtInjectedNowDespiteStaleObservation() throws { + let now = try date("2026-08-03T09:08:00Z") + let latestObserved = now.addingTimeInterval(-6 * 3_600) + let bounds = DateInterval( + start: now.addingTimeInterval(-12 * 3_600), + end: latestObserved + ) + + for (range, duration) in [ + (AnalyticsTimeRange.oneDay, 86_400.0), + (.threeDays, 259_200.0), + (.fourWeeks, 2_419_200.0), + (.twelveWeeks, 7_257_600.0) + ] { + let resolved = range.interval(within: bounds, now: now) + XCTAssertEqual(resolved.start, now.addingTimeInterval(-duration)) + XCTAssertEqual(resolved.end, now) + XCTAssertEqual(resolved.duration, duration) + } + XCTAssertNotEqual( + AnalyticsTimeRange.oneDay.interval(within: bounds, now: now).end, + latestObserved + ) XCTAssertEqual( AnalyticsTimeRange.currentWindow.interval( within: bounds, - endingAt: latestObserved + now: now ), bounds ) } + func testRollingPresetDatesKeepElapsedDurationAcrossTimezonesAndDST() throws { + let berlin = try XCTUnwrap(TimeZone(identifier: "Europe/Berlin")) + let losAngeles = try XCTUnwrap( + TimeZone(identifier: "America/Los_Angeles") + ) + + for now in [ + try date("2026-03-29T10:08:00Z"), + try date("2026-10-25T11:08:00Z") + ] { + let interval = AnalyticsTimeRange.oneDay.interval( + within: DateInterval( + start: now.addingTimeInterval(-2 * 86_400), + end: now.addingTimeInterval(3_600) + ), + now: now + ) + XCTAssertEqual(interval.start, now.addingTimeInterval(-86_400)) + XCTAssertEqual(interval.end, now) + XCTAssertEqual(interval.duration, 86_400) + XCTAssertNotEqual( + boundaryLabel(interval.start, timeZone: berlin), + boundaryLabel(interval.end, timeZone: berlin) + ) + } + + let now = try date("2026-08-03T09:08:00Z") + let interval = AnalyticsTimeRange.oneDay.interval( + within: DateInterval( + start: now.addingTimeInterval(-2 * 86_400), + end: now.addingTimeInterval(3_600) + ), + now: now + ) + XCTAssertEqual(interval.end, now) + XCTAssertEqual(interval.duration, 86_400) + XCTAssertNotEqual( + boundaryLabel(interval.end, timeZone: berlin), + boundaryLabel(interval.end, timeZone: losAngeles) + ) + } + func testSelectedRangeIsClampedAndRejectsTinySelections() { let bounds = DateInterval( start: Date(timeIntervalSince1970: 1_000), @@ -334,6 +851,189 @@ final class AnalyticsWorkspaceTests: XCTestCase { ) } + func testSelectedRangeStaysFixedAfterRefresh() throws { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: try date("2026-08-02T08:15:00Z"), + end: try date("2026-08-02T14:45:00Z") + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: try date("2026-08-01T00:00:00Z"), + end: try date("2026-08-03T00:00:00Z") + ) + ) + + XCTAssertEqual( + store.effectiveRange( + within: DateInterval( + start: try date("2026-08-01T00:00:00Z"), + end: try date("2026-08-04T00:00:00Z") + ), + now: try date("2026-08-03T12:00:00Z") + ), + chosen + ) + } + + func testSelectedRangeIgnoresAdvancingNowAndPresetsClearIt() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let bounds = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 20_000) + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange(chosen, within: bounds) + + XCTAssertEqual( + store.effectiveRange( + within: bounds, + now: Date(timeIntervalSince1970: 15_000) + ), + chosen + ) + + store.selectTimeRange(.oneDay) + XCTAssertNil(store.state.visibleRange) + store.selectVisibleRange(chosen, within: bounds) + store.resetVisibleRange() + XCTAssertEqual(store.state.timeRange, .currentWindow) + XCTAssertNil(store.state.visibleRange) + } + + func testSelectedRangeRestoresExactBoundaries() { + let suite = "AnalyticsWorkspaceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + AnalyticsWorkspaceStore(defaults: defaults).selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + + let restored = AnalyticsWorkspaceStore(defaults: defaults) + XCTAssertEqual(restored.state.timeRange, .selected) + XCTAssertEqual(restored.state.visibleRange, chosen) + } + + func testSelectedRangeClampsOneSideWithoutMovingTheOther() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + + XCTAssertEqual( + store.effectiveRange( + within: DateInterval( + start: Date(timeIntervalSince1970: 4_000), + end: Date(timeIntervalSince1970: 10_000) + ), + now: .now + ), + DateInterval( + start: Date(timeIntervalSince1970: 4_000), + end: Date(timeIntervalSince1970: 8_000) + ) + ) + } + + func testSelectedRangeUsesBoundsWhenItNoLongerIntersects() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + store.selectVisibleRange( + chosen, + within: DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + ) + let refreshedBounds = DateInterval( + start: Date(timeIntervalSince1970: 10_000), + end: Date(timeIntervalSince1970: 20_000) + ) + + XCTAssertEqual( + store.effectiveRange(within: refreshedBounds, now: .now), + refreshedBounds + ) + XCTAssertEqual(store.state.visibleRange, chosen) + } + + func testZoomInAndOutContinueFromSelectedRange() { + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + let bounds = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 10_000) + ) + let chosen = DateInterval( + start: Date(timeIntervalSince1970: 2_000), + end: Date(timeIntervalSince1970: 8_000) + ) + let anchor = Date(timeIntervalSince1970: 5_000) + store.selectVisibleRange(chosen, within: bounds) + store.zoom( + factor: 2, + anchor: anchor, + currentRange: store.effectiveRange(within: bounds, now: .now), + within: bounds + ) + + let zoomed = DateInterval( + start: Date(timeIntervalSince1970: 3_500), + end: Date(timeIntervalSince1970: 6_500) + ) + XCTAssertEqual(store.state.visibleRange, zoomed) + + store.zoom( + factor: 0.5, + anchor: anchor, + currentRange: store.effectiveRange(within: bounds, now: .now), + within: bounds + ) + XCTAssertEqual(store.state.visibleRange, chosen) + } + func testZoomKeepsRangeInsideWindowAndAroundAnchor() { let bounds = DateInterval( start: Date(timeIntervalSince1970: 0), @@ -369,11 +1069,11 @@ final class AnalyticsWorkspaceTests: XCTestCase { start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: 200_000) ) - let latestObserved = Date(timeIntervalSince1970: 150_000) + let now = Date(timeIntervalSince1970: 150_000) store.selectTimeRange(.oneDay) let visible = store.effectiveRange( within: bounds, - endingAt: latestObserved + now: now ) store.zoom( @@ -604,7 +1304,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { store.selectTimeRange(.fourWeeks) XCTAssertEqual( - store.effectiveRange(within: bounds, endingAt: observedAt), + store.effectiveRange(within: bounds, now: observedAt), DateInterval( start: observedAt.addingTimeInterval(-28 * 86_400), end: observedAt @@ -613,7 +1313,7 @@ final class AnalyticsWorkspaceTests: XCTestCase { store.selectTimeRange(.twelveWeeks) XCTAssertEqual( - store.effectiveRange(within: bounds, endingAt: observedAt), + store.effectiveRange(within: bounds, now: observedAt), DateInterval(start: oldest, end: observedAt) ) } @@ -936,6 +1636,71 @@ final class AnalyticsWorkspaceTests: XCTestCase { } } + func testExpiredCurrentWindowRendersUnavailableAndHistoricalUsage() throws { + let start = try date("2026-08-01T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let observedAt = try date("2026-08-03T12:13:00Z") + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: UsageSnapshot( + mainLimit: LimitReading( + limitId: "weekly", + name: "Weekly", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: observedAt + ), + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: try date("2026-08-09T12:13:00Z"), + previousStatus: nil + ) + ) + let store = AnalyticsWorkspaceStore( + defaults: UserDefaults( + suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)" + )! + ) + + XCTAssertNil(reader.weeklyUsageRemaining) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore() + ), + size: CGSize(width: 640, height: 780) + ) + ) + + store.selectTimeRange(.fourWeeks) + XCTAssertTrue( + renders( + AnalyticsWorkspaceBody( + reader: reader, + store: store, + assistedInsights: CodexAssistedInsightStore() + ), + size: CGSize(width: 640, height: 780) + ) + ) + } + func testUsagePerTokenComparisonRendersAtSmallAndLargeSizes() { let suiteName = "AnalyticsWorkspaceTests-usage-per-token-\(UUID().uuidString)" @@ -978,6 +1743,22 @@ final class AnalyticsWorkspaceTests: XCTestCase { } } + private func date(_ value: String) throws -> Date { + try XCTUnwrap(ISO8601DateFormatter().date(from: value)) + } + + private func boundaryLabel( + _ date: Date, + timeZone: TimeZone + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.timeZone = timeZone + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + private func renders( _ view: Content, size: CGSize diff --git a/Tests/CodexLimitsTests/DeterministicInsightTests.swift b/Tests/CodexLimitsTests/DeterministicInsightTests.swift index 4032c3a..2d99cc9 100644 --- a/Tests/CodexLimitsTests/DeterministicInsightTests.swift +++ b/Tests/CodexLimitsTests/DeterministicInsightTests.swift @@ -113,7 +113,7 @@ final class DeterministicInsightTests: XCTestCase { (AnalyticsTimeRange.oneDay, 86_400.0), (.threeDays, 3 * 86_400.0), (.fourWeeks, 28 * 86_400.0), - (.twelveWeeks, 35 * 86_400.0) + (.twelveWeeks, 84 * 86_400.0) ] { var exploration = AnalyticsExplorationState.initial exploration.timeRange = range @@ -121,18 +121,58 @@ final class DeterministicInsightTests: XCTestCase { DeterministicInsightInput.effectiveRange( usagePerToken: usage, observedInterval: nil, - fetchedAt: current.interval.end, + now: current.interval.end, exploration: exploration ) ) XCTAssertEqual( resolved.duration, - min(duration, 35 * 86_400), + duration, accuracy: 0.1 ) } } + func testPresetInsightRangeEndsAtNowWhenEvidenceIsStale() throws { + let usage = insightInput(multiplier: 1.4).usagePerToken + let latestObserved = try XCTUnwrap(usage.current?.interval.end) + let now = latestObserved.addingTimeInterval(6 * 3_600) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let resolved = try XCTUnwrap( + DeterministicInsightInput.effectiveRange( + usagePerToken: usage, + observedInterval: nil, + now: now, + exploration: exploration + ) + ) + + XCTAssertEqual(resolved.start, now.addingTimeInterval(-86_400)) + XCTAssertEqual(resolved.end, now) + } + + func testSelectedInsightRangeKeepsEmptyTimeAfterStaleEvidence() throws { + let usage = insightInput(multiplier: 1.4).usagePerToken + let latestObserved = try XCTUnwrap(usage.current?.interval.end) + let now = latestObserved.addingTimeInterval(6 * 3_600) + let selected = DateInterval(start: latestObserved, end: now) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .selected + exploration.visibleRange = selected + + XCTAssertEqual( + DeterministicInsightInput.effectiveRange( + usagePerToken: usage, + observedInterval: nil, + now: now, + exploration: exploration + ), + selected + ) + } + func testStaleLowCoverageAndUnavailableSourceWithholdConclusion() { let stale = insightSnapshot( multiplier: 1.4, diff --git a/Tests/CodexLimitsTests/UsageHistoryTests.swift b/Tests/CodexLimitsTests/UsageHistoryTests.swift index 43bb09d..444dc16 100644 --- a/Tests/CodexLimitsTests/UsageHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageHistoryTests.swift @@ -108,7 +108,33 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(state.samples.first?.comparisonBreak, true) } - func testNegativeLifetimeTokenReadingIsRejectedDuringNormalization() async throws { + func testConflictingLifetimeCountersKeepBothReadings() async throws { + let root = temporaryDirectory() + let observedAt = Date(timeIntervalSince1970: 1_900_000) + let resetsAt = Date(timeIntervalSince1970: 2_000_000) + let history = UsageHistory( + localDirectory: root, + installationID: "writer-a" + ) + _ = await history.load() + _ = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: resetsAt, + lifetimeTokens: 1_200 + )) + + let state = await history.record(UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: resetsAt, + lifetimeTokens: 1_500 + )) + + XCTAssertEqual(state.samples.map(\.lifetimeTokens), [1_200, 1_500]) + } + + func testNegativeLifetimeTokenReadingIsPreservedAsABreak() async throws { let root = temporaryDirectory() let history = UsageHistory( localDirectory: root, @@ -125,7 +151,7 @@ final class UsageHistoryTests: XCTestCase { ) ) - XCTAssertTrue(state.samples.isEmpty) + XCTAssertEqual(state.samples.map(\.lifetimeTokens), [-1]) } func testDailyFileRestoreKeepsValidSamplesBesideAnInvalidSample() async throws { @@ -1556,12 +1582,14 @@ final class UsageHistoryTests: XCTestCase { let firstSample = UsageSample( observedAt: Date(timeIntervalSince1970: 1_900_000), remainingPercent: 82, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_000 ) let secondSample = UsageSample( observedAt: Date(timeIntervalSince1970: 1_900_060), remainingPercent: 81, - resetsAt: reset + resetsAt: reset, + lifetimeTokens: 1_200 ) _ = await firstWriter.load() @@ -1582,6 +1610,10 @@ final class UsageHistoryTests: XCTestCase { XCTAssertEqual(firstState.samples, [firstSample, secondSample]) XCTAssertEqual(secondState.samples, [firstSample, secondSample]) + XCTAssertEqual( + firstState.samples.compactMap(\.lifetimeTokens), + [1_000, 1_200] + ) XCTAssertNil(firstState.errorMessage) XCTAssertNil(secondState.errorMessage) } diff --git a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift index 01b9c1e..7b2790c 100644 --- a/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift +++ b/Tests/CodexLimitsTests/UsageIntelligenceEngineTests.swift @@ -73,14 +73,929 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual(reader.accountTokenActivity.tokens, 600) XCTAssertEqual(reader.accountTokenActivity.method, .lifetimeDelta) XCTAssertEqual( reader.accountTokenActivity.interval, DateInterval(start: boundary.observedAt, end: now) ) - XCTAssertNil(reader.accountTokenActivity.reason) + XCTAssertNil(reader.accountTokenActivity.reason) + } + + func testRollingDayUsesContainedLifetimeIntervals() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-4 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountPartitionID: "account-a", + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual( + activity.range, + DateInterval( + start: now.addingTimeInterval(-86_400), + end: now + ) + ) + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.method, .lifetimeDelta) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [200, 300]) + XCTAssertEqual( + activity.interval, + DateInterval( + start: readings[0].observedAt, + end: readings[2].observedAt + ) + ) + XCTAssertNil(activity.reason) + } + + func testRollingDayStaysAtNowWhenLatestReadingIsStale() { + let now = Date(timeIntervalSince1970: 2_000_000) + let observedAt = now.addingTimeInterval(-6 * 3_600) + let account = makeSnapshot(remaining: 80, fetchedAt: observedAt) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: observedAt.addingTimeInterval(-4 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: observedAt, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.range?.end, now) + XCTAssertEqual(activity.range?.duration, 86_400) + XCTAssertEqual(activity.tokens, 300) + XCTAssertEqual(activity.intervals.last?.end, observedAt) + } + + func testRollingDayExcludesCrossingIntervalsAndIncludesExactBoundaries() { + let now = Date(timeIntervalSince1970: 2_000_000) + let start = now.addingTimeInterval(-86_400) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: start.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.intervals.count, 2) + XCTAssertEqual(activity.intervals.first?.start, start) + XCTAssertEqual(activity.intervals.last?.end, now) + } + + func testRollingDayKeepsLongAndZeroIntervalsFactual() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-23 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: readings, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 0) + XCTAssertEqual(activity.intervals.count, 1) + XCTAssertEqual(activity.intervals.first?.tokenDelta, 0) + XCTAssertEqual(activity.intervals.first?.duration, 22 * 3_600) + } + + func testRollingDayNamesMissingCompleteIntervals() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let onlyReading = UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .oneDay + + let beforeRange = [ + UsageSample( + observedAt: now.addingTimeInterval(-30 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 800 + ), + UsageSample( + observedAt: now.addingTimeInterval(-25 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 900 + ) + ] + let crossingEnd = [ + onlyReading, + UsageSample( + observedAt: now.addingTimeInterval(60), + remainingPercent: 75, + resetsAt: reset, + lifetimeTokens: 1_100 + ) + ] + + for samples in [[onlyReading], beforeRange, crossingEnd] { + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertNil(activity.tokens) + XCTAssertTrue(activity.intervals.isEmpty) + XCTAssertEqual( + activity.reason, + "No account readings in this range" + ) + } + } + + func testRollingDayMergesInterleavedReadingsWithoutDoubleCounting() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-6 * 3_600) + let first = UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let middle = UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ) + let sameCounter = UsageSample( + observedAt: start.addingTimeInterval(4 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_200 + ) + let last = UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + let singleMac = rollingDayActivity( + account: account, + samples: [first, last], + now: now + ) + let merged = rollingDayActivity( + account: account, + samples: [first, middle, middle, sameCounter, last], + now: now + ) + let reversed = rollingDayActivity( + account: account, + samples: [first, middle, middle, sameCounter, last].reversed(), + now: now + ) + + XCTAssertEqual(singleMac.tokens, 600) + XCTAssertEqual(singleMac.intervals.map(\.tokenDelta), [600]) + XCTAssertEqual(merged.tokens, singleMac.tokens) + XCTAssertEqual(merged.intervals.map(\.tokenDelta), [200, 0, 400]) + XCTAssertEqual(merged, reversed) + } + + func testRollingDayOrdersEqualTimestampsDeterministically() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-3 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_100 + ), + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 84, + resetsAt: reset, + lifetimeTokens: 1_150 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let ordered = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + XCTAssertEqual( + ordered, + rollingDayActivity( + account: account, + samples: readings.reversed(), + now: now + ) + ) + XCTAssertEqual( + ordered.breaks.map(\.reason), + [.conflictingObservation] + ) + XCTAssertTrue(ordered.intervals.isEmpty) + } + + func testRollingDayDoesNotJoinAcrossAllowanceResetsOrAccounts() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-4 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 88, + resetsAt: reset.addingTimeInterval(7 * 86_400), + lifetimeTokens: 1_100 + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + let firstAccount = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountPartitionID: "account-a" + ) + let secondAccount = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountPartitionID: "account-b" + ) + + XCTAssertEqual(firstAccount.tokens, 100) + XCTAssertEqual(firstAccount.intervals.count, 1) + XCTAssertEqual(firstAccount.intervals.first?.start, readings[2].observedAt) + XCTAssertEqual( + firstAccount.breaks.map(\.reason), + [.allowanceWindowChange, .allowanceWindowChange] + ) + XCTAssertEqual( + firstAccount.intervals.map(\.accountPartitionID), + ["account-a"] + ) + XCTAssertEqual( + secondAccount.intervals.map(\.accountPartitionID), + ["account-b"] + ) + } + + func testRollingDayPreservesIntervalsOnBothSidesOfCounterDecrease() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-4 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_500 + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_400 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.tokens, 700) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [500, 200]) + XCTAssertEqual( + activity.breaks, + [AccountTokenActivityBreak( + timestamp: readings[2].observedAt, + reason: .counterDecrease + )] + ) + XCTAssertTrue(activity.intervals.allSatisfy { $0.tokenDelta >= 0 }) + XCTAssertFalse(activity.intervals.contains { + $0.start < readings[2].observedAt + && $0.end >= readings[2].observedAt + }) + } + + func testRollingDayBreaksAtInvalidAndCorrectionReadings() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let start = now.addingTimeInterval(-5 * 3_600) + let readings = [ + UsageSample( + observedAt: start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: start.addingTimeInterval(3_600), + remainingPercent: 88, + resetsAt: reset, + lifetimeTokens: nil + ), + UsageSample( + observedAt: start.addingTimeInterval(2 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: start.addingTimeInterval(3 * 3_600), + remainingPercent: 83, + resetsAt: reset, + lifetimeTokens: 1_300, + comparisonBreak: true + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 81, + resetsAt: reset, + lifetimeTokens: 1_400 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.tokens, 200) + XCTAssertEqual(activity.intervals.map(\.tokenDelta), [100, 100]) + XCTAssertEqual( + activity.breaks.map(\.reason), + [.invalidCounter, .correction] + ) + } + + func testRollingDayBreaksAtAccountObservationEpoch() { + let now = Date(timeIntervalSince1970: 2_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let epoch = now.addingTimeInterval(-2 * 3_600) + let readings = [ + UsageSample( + observedAt: epoch.addingTimeInterval(-3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: epoch.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + accountEpochStartedAt: epoch + ) + + XCTAssertEqual(activity.tokens, 100) + XCTAssertEqual(activity.breaks.map(\.reason), [.accountChange]) + XCTAssertEqual(activity.breaks.first?.timestamp, epoch) + } + + func testRollingDayLifetimeIntervalsTakePrecedenceOverDailyBuckets() throws { + let now = try date("2026-08-03T00:00:00Z") + let bucketStart = try date("2026-08-02T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: bucketStart, + tokens: 900, + completeness: .complete + )] + ) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: bucketStart.addingTimeInterval(6 * 3_600), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: bucketStart.addingTimeInterval(12 * 3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.method, .lifetimeDelta) + XCTAssertEqual(activity.tokens, 300) + XCTAssertEqual(activity.intervals.count, 1) + XCTAssertEqual(activity.intervals.first?.method, .lifetimeDelta) + } + + func testRollingDayFallsBackToACompleteUTCDailyBucket() throws { + let now = try date("2026-08-03T00:00:00Z") + let bucketStart = try date("2026-08-02T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: bucketStart, + tokens: 900, + completeness: .complete + )] + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now + ) + + XCTAssertEqual(activity.method, .dailyBuckets) + XCTAssertEqual(activity.tokens, 900) + XCTAssertEqual(activity.sourceDescription, "Codex UTC daily token totals") + XCTAssertEqual( + activity.interval, + DateInterval(start: bucketStart, end: now) + ) + XCTAssertEqual(activity.intervals.map(\.method), [.dailyBuckets]) + } + + func testCompleteContiguousUTCDaysExactlyCoverAThreeDayRange() throws { + let now = try date("2026-08-04T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [1, 2, 3].map { day in + TokenDay( + date: now.addingTimeInterval(TimeInterval(-day * 86_400)), + tokens: Int64(day * 100), + completeness: .complete + ) + } + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now, + timeRange: .threeDays + ) + + XCTAssertEqual(activity.state, .exact) + XCTAssertEqual(activity.tokens, 600) + XCTAssertEqual(activity.intervals.count, 3) + } + + func testUTCFallbackPreservesDetectedCounterBreaks() throws { + let now = try date("2026-08-03T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [TokenDay( + date: now.addingTimeInterval(-86_400), + tokens: 900, + completeness: .complete + )] + ) + let reset = account.mainLimit!.window.resetsAt + let readings = [ + UsageSample( + observedAt: now.addingTimeInterval(-7_200), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 2_000 + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now + ) + + XCTAssertEqual(activity.method, .dailyBuckets) + XCTAssertEqual(activity.breaks.map(\.reason), [.counterDecrease]) + } + + func testRollingDayExcludesPartialUTCDailyBuckets() throws { + let now = try date("2026-08-03T12:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [ + TokenDay( + date: try date("2026-08-02T00:00:00Z"), + tokens: 800, + completeness: .complete + ), + TokenDay( + date: try date("2026-08-03T00:00:00Z"), + tokens: 900, + completeness: .complete + ) + ] + ) + + let activity = rollingDayActivity( + account: account, + samples: [], + now: now + ) + + XCTAssertNil(activity.tokens) + XCTAssertTrue(activity.intervals.isEmpty) + XCTAssertEqual(activity.reason, "No account readings in this range") + } + + func testEveryRollingTokenRangeUsesExactContainedIntervals() { + let now = Date(timeIntervalSince1970: 10_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let cases: [(AnalyticsTimeRange, TimeInterval)] = [ + (.threeDays, 3 * 86_400), + (.fourWeeks, 28 * 86_400), + (.twelveWeeks, 84 * 86_400) + ] + + for (timeRange, duration) in cases { + let rangeStart = now.addingTimeInterval(-duration) + let readings = [ + UsageSample( + observedAt: rangeStart.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: rangeStart, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: rangeStart.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: now, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + timeRange: timeRange + ) + + XCTAssertEqual( + activity.range, + DateInterval(start: rangeStart, end: now), + timeRange.rawValue + ) + XCTAssertEqual(activity.range?.duration, duration, timeRange.rawValue) + XCTAssertEqual(activity.tokens, 600, timeRange.rawValue) + XCTAssertEqual( + activity.interval, + DateInterval(start: rangeStart, end: now), + timeRange.rawValue + ) + XCTAssertEqual(activity.intervals.count, 2, timeRange.rawValue) + XCTAssertFalse(activity.intervals.contains { + $0.start < rangeStart + }, timeRange.rawValue) + XCTAssertFalse(activity.intervals.contains { + $0.end > now + }, timeRange.rawValue) + } + } + + func testSelectedTokenRangeKeepsExactPersistedBoundariesAcrossRefresh() { + let now = Date(timeIntervalSince1970: 10_000_000) + let account = makeSnapshot(remaining: 80, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let selected = DateInterval( + start: now.addingTimeInterval(-10 * 86_400), + end: now.addingTimeInterval(-5 * 86_400) + ) + let readings = [ + UsageSample( + observedAt: selected.start.addingTimeInterval(-60), + remainingPercent: 95, + resetsAt: reset, + lifetimeTokens: 900 + ), + UsageSample( + observedAt: selected.start, + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: selected.start.addingTimeInterval(3_600), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_200 + ), + UsageSample( + observedAt: selected.end, + remainingPercent: 80, + resetsAt: reset, + lifetimeTokens: 1_500 + ), + UsageSample( + observedAt: selected.end.addingTimeInterval(60), + remainingPercent: 79, + resetsAt: reset, + lifetimeTokens: 1_600 + ) + ] + + let activity = rollingDayActivity( + account: account, + samples: readings, + now: now, + timeRange: .selected, + visibleRange: selected + ) + let refreshed = rollingDayActivity( + account: account, + samples: readings, + now: now.addingTimeInterval(86_400), + timeRange: .selected, + visibleRange: selected + ) + + XCTAssertEqual(activity.range, selected) + XCTAssertEqual(activity.tokens, 500) + XCTAssertEqual(activity.interval, selected) + XCTAssertEqual(activity.intervals.count, 2) + XCTAssertEqual(refreshed.range, selected) + XCTAssertEqual(refreshed.intervals, activity.intervals) + XCTAssertEqual(refreshed.tokens, activity.tokens) + } + + func testTokenSourcePrecedenceIsEvaluatedInsideEachRange() throws { + let now = try date("2026-08-03T00:00:00Z") + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + tokenHistory: [ + TokenDay( + date: try date("2026-08-01T00:00:00Z"), + tokens: 100, + completeness: .complete + ), + TokenDay( + date: try date("2026-08-02T00:00:00Z"), + tokens: 200, + completeness: .complete + ) + ] + ) + let reset = account.mainLimit!.window.resetsAt + let lifetimeReadings = [ + UsageSample( + observedAt: try date("2026-07-23T00:00:00Z"), + remainingPercent: 90, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: try date("2026-07-24T00:00:00Z"), + remainingPercent: 85, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ] + + let threeDays = rollingDayActivity( + account: account, + samples: lifetimeReadings, + now: now, + timeRange: .threeDays + ) + let twelveWeeks = rollingDayActivity( + account: account, + samples: lifetimeReadings, + now: now, + timeRange: .twelveWeeks + ) + + XCTAssertEqual(threeDays.method, .dailyBuckets) + XCTAssertEqual(threeDays.tokens, 300) + XCTAssertEqual(threeDays.intervals.count, 2) + XCTAssertEqual(twelveWeeks.method, .lifetimeDelta) + XCTAssertEqual(twelveWeeks.tokens, 300) + XCTAssertEqual(twelveWeeks.intervals.count, 1) } func testReaderPublishesBoundedCurrentUsagePerTokenFacts() throws { @@ -568,13 +1483,64 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual( reader.accountTokenActivity.interval, DateInterval(start: boundary.observedAt, end: observedAt) ) } + func testPreservedLifetimeReadingDoesNotInheritANewerAllowanceReset() { + let now = Date(timeIntervalSince1970: 2_000_000) + let observedAt = now.addingTimeInterval(-6 * 86_400) + let oldReset = now.addingTimeInterval(-4 * 86_400) + let account = makeSnapshot( + remaining: 80, + fetchedAt: now, + accountFacts: AccountFacts( + lifetimeTokens: 1_200, + peakDailyTokens: nil, + longestRunningTurnSeconds: nil, + currentStreakDays: nil, + longestStreakDays: nil, + credits: nil, + spendControl: nil, + lifetimeTokensObservedAt: observedAt + ) + ) + let samples = [ + UsageSample( + observedAt: now.addingTimeInterval(-7 * 86_400), + remainingPercent: 90, + resetsAt: oldReset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: observedAt, + remainingPercent: 85, + resetsAt: oldReset, + lifetimeTokens: 1_200 + ) + ] + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .twelveWeeks + + let activity = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + analyticsExploration: exploration + ) + ).accountTokenActivity + + XCTAssertEqual(activity.tokens, 200) + XCTAssertTrue(activity.breaks.isEmpty) + } + func testCompleteDailyBucketsProducePartialAccountTokenActivity() throws { let fetchedAt = try XCTUnwrap( ISO8601DateFormatter().date(from: "2026-07-28T12:00:00Z") @@ -648,10 +1614,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) ) - XCTAssertEqual( - reader.accountTokenActivity.reason, - "Only complete daily token totals are available" - ) + XCTAssertNil(reader.accountTokenActivity.reason) } func testAlignedCompleteDailyBucketsProduceExactAccountTokenActivity() throws { @@ -689,7 +1652,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertEqual(reader.accountTokenActivity.state, .exact) + XCTAssertEqual(reader.accountTokenActivity.state, .partial) XCTAssertEqual(reader.accountTokenActivity.tokens, 500) XCTAssertEqual(reader.accountTokenActivity.method, .dailyBuckets) XCTAssertEqual( @@ -738,8 +1701,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.state, .unavailable) XCTAssertNil(reader.accountTokenActivity.tokens) XCTAssertEqual( - reader.accountTokenActivity.reason, - "Lifetime token counter decreased" + reader.accountTokenActivity.breaks.map(\.reason), + [.counterDecrease] ) } @@ -779,8 +1742,8 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.accountTokenActivity.state, .unavailable) XCTAssertNil(reader.accountTokenActivity.tokens) XCTAssertEqual( - reader.accountTokenActivity.reason, - "Lifetime token reading is invalid" + reader.accountTokenActivity.breaks.map(\.reason), + [.invalidCounter] ) } @@ -818,7 +1781,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { reader.guidance?.message, "At this pace, your limit may run out 23 hours early." ) - XCTAssertEqual(reader.guidance?.suggestedPace, "Up to 8.5% a day") + XCTAssertEqual( + reader.guidance?.suggestedPace, + "Up to 8.5 percentage points a day" + ) XCTAssertNotNil(reader.guidance?.runway) XCTAssertNil(reader.guidance?.remainingAtResetRange) XCTAssertNil(reader.guidance?.caveat) @@ -924,12 +1890,19 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.guidance) XCTAssertTrue(reader.chart.currentProjection.isEmpty) XCTAssertTrue(reader.chart.historicalProjection.isEmpty) - XCTAssertEqual(reader.guidanceTitle, "Not enough data") + XCTAssertEqual( + reader.guidanceTitle, + "Couldn’t read Codex usage. Try refreshing again." + ) XCTAssertEqual( reader.guidanceMessage, "Couldn’t read Codex usage. Try refreshing again." ) - XCTAssertEqual(reader.suggestedPaceText, "Not enough data") + XCTAssertNil(reader.suggestedPacePercentPerDay) + XCTAssertEqual( + reader.suggestedPaceText, + "Couldn’t read Codex usage. Try refreshing again." + ) } func testReaderFormatsFreshnessFromSuppliedTime() { @@ -976,7 +1949,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.evidence.coverage, .low) XCTAssertEqual(reader.evidence.confidence, .low) XCTAssertEqual(reader.evidence.reason, "Less than half of this weekly window is observed") - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertFalse(reader.chart.currentProjection.isEmpty) } @@ -1029,7 +2002,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) XCTAssertEqual(reader.evidence.confidence, .low) - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertEqual(reader.chart.currentProjection.count, 2) XCTAssertEqual(reader.chart.historicalProjection.count, 2) XCTAssertTrue(reader.chart.estimatedBackfill.isEmpty) @@ -1170,7 +2143,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } - func testUnknownCorrectionWithholdsGuidance() { + func testCorrectionRestoresGuidanceAfterSecondReading() { let now = Date(timeIntervalSince1970: 5_900_000) let account = makeSnapshot(remaining: 70, fetchedAt: now) var samples = denseSamples( @@ -1200,7 +2173,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertEqual(reader.evidence.coverage, .low) XCTAssertEqual(reader.evidence.confidence, .low) XCTAssertEqual(reader.evidence.reason, "Unknown reset or correction") - XCTAssertNil(reader.guidance) + XCTAssertNotNil(reader.guidance) XCTAssertEqual(reader.chart.observedSegments.count, 2) XCTAssertEqual(reader.chart.observedSegments.flatMap { $0 }, reader.chart.observed) } @@ -1304,7 +2277,10 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertNil(reader.weeklyUsageRemaining) XCTAssertEqual(reader.evidence.coverage, .unavailable) - XCTAssertEqual(reader.evidence.reason, "Weekly usage unavailable") + XCTAssertEqual( + reader.evidence.reason, + "No current weekly allowance reading" + ) XCTAssertNil(reader.guidance) XCTAssertTrue(reader.chart.observed.isEmpty) } @@ -1429,14 +2405,315 @@ final class UsageIntelligenceEngineTests: XCTestCase { account: account, samples: [ UsageSample( - observedAt: beforeEpoch, + observedAt: beforeEpoch, + remainingPercent: 80, + resetsAt: reset + ), + UsageSample( + observedAt: afterEpoch, + remainingPercent: 72, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountEpochStartedAt: now.addingTimeInterval(-3_600) + ) + ) + + XCTAssertTrue( + reader.chart.observed.contains { $0.date == beforeEpoch } + ) + } + + func testSparseHistoryWithholdsGuidance() { + let now = Date(timeIntervalSince1970: 6_000_000) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: makeSnapshot(remaining: 55, fetchedAt: now), + samples: [], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ) + + XCTAssertEqual(reader.evidence.coverage, .low) + XCTAssertEqual(reader.evidence.confidence, .low) + XCTAssertEqual(reader.evidence.reason, "Not enough account history") + XCTAssertNil(reader.guidance) + XCTAssertEqual( + reader.guidanceTitle, + "At least two compatible account readings are needed" + ) + } + + func testRecentMovementUsesTwoReadingsFromLessThanTwentyFourHours() throws { + let now = Date(timeIntervalSince1970: 6_005_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let first = UsageSample( + observedAt: now.addingTimeInterval(-6 * 3_600), + remainingPercent: 75, + resetsAt: account.mainLimit!.window.resetsAt + ) + + let reader = makeReader(account: account, samples: [first], now: now) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual(forecast.currentPercentPerDay, 20, accuracy: 0.000_001) + XCTAssertEqual(reader.chart.currentProjection.first?.date, now) + XCTAssertEqual(reader.chart.currentProjection.first?.remaining, 70) + } + + func testRecentMovementUsesExactRollingDayAndExcludesOlderReadings() throws { + let now = Date(timeIntervalSince1970: 6_006_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let boundary = UsageSample( + observedAt: now.addingTimeInterval(-86_400), + remainingPercent: 90, + resetsAt: reset + ) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-25 * 3_600), + remainingPercent: 100, + resetsAt: reset + ), + boundary + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual(forecast.currentPercentPerDay, 20, accuracy: 0.000_001) + } + + func testLongCompatibleIntervalIsFactualCurrentMovement() throws { + let now = Date(timeIntervalSince1970: 6_007_000) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let gap = 8 * 3_600 + 36 * 60 + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-Double(gap)), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + + XCTAssertEqual( + forecast.currentPercentPerDay, + 10 / (Double(gap) / 86_400), + accuracy: 0.000_001 + ) + XCTAssertNotNil(reader.guidance) + XCTAssertEqual(reader.chart.currentProjection.count, 2) + } + + func testZeroRecentMovementLastsThroughResetAndProjectsFlat() throws { + let now = Date(timeIntervalSince1970: 6_008_000) + let account = makeSnapshot(remaining: 50, fetchedAt: now) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(reader.guidance?.runway, .throughReset) + XCTAssertEqual(reader.runwayText, "Limit should last through reset") + XCTAssertEqual(reader.guidance?.forecast.currentPercentPerDay, 0) + XCTAssertEqual( + reader.chart.currentProjection.last, + UsageChartPoint( + date: account.mainLimit!.window.resetsAt, + remaining: 50 + ) + ) + } + + func testZeroRecentMovementKeepsPrimaryGuidanceConsistentWithFastHistory() { + let day: TimeInterval = 86_400 + let now = Date(timeIntervalSince1970: 6_008_500) + let account = makeSnapshot( + remaining: 50, + fetchedAt: now, + tokenHistory: (-33 ... -6).map { + TokenDay( + date: now.addingTimeInterval(Double($0) * day), + tokens: 400 + ) + } + (-5 ... -1).map { + TokenDay( + date: now.addingTimeInterval(Double($0) * day), + tokens: 100 + ) + } + ) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(reader.guidance?.forecast.currentPercentPerDay, 0) + XCTAssertEqual(reader.guidance?.status, .roomToUseMore) + XCTAssertEqual(reader.guidance?.runway, .throughReset) + XCTAssertEqual( + reader.chart.currentProjection.last?.remaining, + 50 + ) + } + + func testPastEstimateRemainsWhenCurrentMovementNeedsAnotherReading() { + let now = Date(timeIntervalSince1970: 6_008_750) + let account = makeSnapshot(remaining: 70, fetchedAt: now) + let priorReset = account.mainLimit!.window.startsAt + let history = weeklySamples( + start: priorReset.addingTimeInterval(-12 * 3_600), + end: priorReset, + reset: priorReset, + startRemaining: 80, + endRemaining: 60, + startTokens: 1_000, + endTokens: 2_000 + ) + let reader = makeReader(account: account, samples: history, now: now) + + XCTAssertNil(reader.guidance) + XCTAssertTrue(reader.chart.currentProjection.isEmpty) + XCTAssertEqual(reader.chart.historicalProjection.count, 2) + } + + func testAllowanceBreaksNeedOneThenTwoNewReadings() { + let now = Date(timeIntervalSince1970: 6_009_000) + let account = makeSnapshot( + remaining: 70, + fetchedAt: now, + resetAfter: 7 * 86_400 - 4 * 3_600 + ) + let reset = account.mainLimit!.window.resetsAt + let previousReset = account.mainLimit!.window.startsAt + + let afterResetWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: previousReset, + remainingPercent: 40, + resetsAt: previousReset + ) + ], + now: now + ) + let afterResetRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: previousReset, + remainingPercent: 40, + resetsAt: previousReset + ), + UsageSample( + observedAt: previousReset.addingTimeInterval(3_600), + remainingPercent: 80, + resetsAt: reset + ) + ], + now: now + ) + let afterCorrectionWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + comparisonBreak: true + ) + ], + now: now + ) + let afterCorrectionRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset, + comparisonBreak: true + ) + ], + now: now + ) + let afterIncreaseWaiting = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 50, + resetsAt: reset + ) + ], + now: now + ) + let afterIncreaseRestored = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-2 * 3_600), + remainingPercent: 50, + resetsAt: reset + ), + UsageSample( + observedAt: now.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: reset + ) + ], + now: now + ) + let accountChanged = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountEpochStartedAt: now + ) + ) + let accountRecovered = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-3_600), remainingPercent: 80, - resetsAt: reset - ), - UsageSample( - observedAt: afterEpoch, - remainingPercent: 72, - resetsAt: reset + resetsAt: reset, + comparisonBreak: true ) ], safetyBuffer: 3, @@ -1447,30 +2724,247 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) ) - XCTAssertTrue( - reader.chart.observed.contains { $0.date == beforeEpoch } + XCTAssertEqual( + afterResetWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterResetRestored.guidance) + XCTAssertEqual( + afterCorrectionWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterCorrectionRestored.guidance) + XCTAssertEqual( + afterIncreaseWaiting.guidanceUnavailableReason, + "Waiting for a second account reading after reset or correction" + ) + XCTAssertNotNil(afterIncreaseRestored.guidance) + XCTAssertEqual( + accountChanged.guidanceUnavailableReason, + "Account changed — waiting for a second reading" ) + XCTAssertNotNil(accountRecovered.guidance) } - func testSparseHistoryWithholdsGuidance() { - let now = Date(timeIntervalSince1970: 6_000_000) - let reader = UsageIntelligenceEngine.evaluate( - UsageIntelligenceInput( - account: makeSnapshot(remaining: 55, fetchedAt: now), - samples: [], - safetyBuffer: 3, - sourceState: .available, - now: now, - previousStatus: nil + func testRunwayAndCurrentEstimateSharePaceAndLatestReading() throws { + let now = Date(timeIntervalSince1970: 6_009_500) + let account = makeSnapshot(remaining: 20, fetchedAt: now) + let reader = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: now.addingTimeInterval(-12 * 3_600), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + let forecast = try XCTUnwrap(reader.guidance?.forecast) + let firstProjection = try XCTUnwrap(reader.chart.currentProjection.first) + let lastProjection = try XCTUnwrap(reader.chart.currentProjection.last) + guard case let .exhausts(runwayEnd, _) = reader.guidance?.runway else { + return XCTFail("Expected current pace to exhaust the allowance") + } + + XCTAssertEqual(firstProjection.date, account.fetchedAt) + XCTAssertEqual(firstProjection.remaining, 20) + XCTAssertEqual(forecast.currentPercentPerDay, 120, accuracy: 0.000_001) + XCTAssertEqual(lastProjection.date, runwayEnd) + } + + func testRefreshFailureRecoversWithoutExtendingObservedFacts() { + let now = Date(timeIntervalSince1970: 6_009_750) + let fetchedAt = now.addingTimeInterval(-10 * 60) + let account = makeSnapshot(remaining: 70, fetchedAt: fetchedAt) + let samples = [ + UsageSample( + observedAt: fetchedAt.addingTimeInterval(-3_600), + remainingPercent: 80, + resetsAt: account.mainLimit!.window.resetsAt ) + ] + let failed = makeReader( + account: account, + samples: samples, + sourceState: .failed("Account source failed"), + now: now ) + let recovered = makeReader( + account: account, + samples: samples, + now: now + ) + + XCTAssertNil(failed.guidance) + XCTAssertEqual(failed.guidanceTitle, "Account source failed") + XCTAssertEqual(failed.chart.observed.last?.date, fetchedAt) + XCTAssertTrue(failed.chart.currentProjection.isEmpty) + XCTAssertNil(failed.suggestedPacePercentPerDay) + XCTAssertNotNil(recovered.guidance) + XCTAssertFalse(recovered.chart.currentProjection.isEmpty) + XCTAssertNotNil(recovered.suggestedPacePercentPerDay) + } - XCTAssertEqual(reader.evidence.coverage, .low) - XCTAssertEqual(reader.evidence.confidence, .low) - XCTAssertEqual(reader.evidence.reason, "Not enough account history") + func testProductionGapShapeStillProducesCurrentGuidance() throws { + let now = Date(timeIntervalSince1970: 6_009_900) + let account = makeSnapshot(remaining: 60, fetchedAt: now) + let reset = account.mainLimit!.window.resetsAt + let gapStart = now.addingTimeInterval(-(47 * 3_600 + 27 * 60)) + let denseStart = gapStart.addingTimeInterval(8 * 3_600 + 36 * 60) + var samples = [ + UsageSample( + observedAt: gapStart, + remainingPercent: 92, + resetsAt: reset + ) + ] + samples += stride( + from: denseStart.timeIntervalSince1970, + to: now.timeIntervalSince1970, + by: 20 * 60 + ).map { timestamp in + let progress = (timestamp - denseStart.timeIntervalSince1970) + / now.timeIntervalSince(denseStart) + return UsageSample( + observedAt: Date(timeIntervalSince1970: timestamp), + remainingPercent: 85 - 25 * progress, + resetsAt: reset + ) + } + let maximumGap = zip(samples, samples.dropFirst()) + .map { $1.observedAt.timeIntervalSince($0.observedAt) } + .max() ?? 0 + let reader = makeReader(account: account, samples: samples, now: now) + let withoutHistory = makeReader(account: account, now: now) + + XCTAssertGreaterThan(maximumGap, 6 * 3_600) + XCTAssertGreaterThan(now.timeIntervalSince(denseStart), 24 * 3_600) + XCTAssertNotNil(reader.guidance) + XCTAssertFalse(reader.chart.currentProjection.isEmpty) + XCTAssertEqual( + try XCTUnwrap(reader.suggestedPacePercentPerDay), + try XCTUnwrap(withoutHistory.suggestedPacePercentPerDay), + accuracy: 0.000_001 + ) + } + + func testSuggestedPaceNeedsOnlyCurrentReadingAndUsesExactFractionalDay() throws { + let now = Date(timeIntervalSince1970: 6_010_000) + let reader = makeReader( + account: makeSnapshot( + remaining: 23, + fetchedAt: now, + resetAfter: 12 * 60 * 60 + ), + now: now + ) + + let pace = try XCTUnwrap(reader.suggestedPacePercentPerDay) + XCTAssertEqual(pace, 40, accuracy: 0.000_001) + XCTAssertTrue(pace.isFinite) + XCTAssertGreaterThanOrEqual(pace, 0) + XCTAssertEqual( + reader.suggestedPaceText, + "Up to 40.0 percentage points a day" + ) XCTAssertNil(reader.guidance) } + func testSuggestedPaceIgnoresLongAccountHistoryGap() throws { + let now = Date(timeIntervalSince1970: 6_020_000) + let account = makeSnapshot(remaining: 40, fetchedAt: now) + let withoutHistory = makeReader(account: account, now: now) + let withLongGap = makeReader( + account: account, + samples: [ + UsageSample( + observedAt: account.mainLimit!.window.startsAt, + remainingPercent: 100, + resetsAt: account.mainLimit!.window.resetsAt + ), + UsageSample( + observedAt: now, + remainingPercent: 40, + resetsAt: account.mainLimit!.window.resetsAt + ) + ], + now: now + ) + + XCTAssertEqual(withLongGap.evidence.coverage, .low) + XCTAssertEqual( + try XCTUnwrap(withLongGap.suggestedPacePercentPerDay), + try XCTUnwrap(withoutHistory.suggestedPacePercentPerDay), + accuracy: 0.000_001 + ) + } + + func testSuggestedPaceClampsAllowanceAtOrBelowBufferToZero() throws { + let now = Date(timeIntervalSince1970: 6_030_000) + let cases: [(remaining: Double, expected: Double)] = [ + (100, 48.5), + (3, 0), + (2, 0), + (0, 0) + ] + + for testCase in cases { + let reader = makeReader( + account: makeSnapshot( + remaining: testCase.remaining, + fetchedAt: now + ), + now: now + ) + let pace = try XCTUnwrap(reader.suggestedPacePercentPerDay) + + XCTAssertEqual(pace, testCase.expected, accuracy: 0.000_001) + XCTAssertTrue(pace.isFinite) + XCTAssertGreaterThanOrEqual(pace, 0) + } + } + + func testSuggestedPaceUsesSpecificUnavailableReasons() { + let now = Date(timeIntervalSince1970: 6_040_000) + let sourceError = "Couldn’t read Codex usage. Try refreshing again." + let missing = makeReader(account: nil, now: now) + let atReset = makeReader( + account: makeSnapshot( + remaining: 50, + fetchedAt: now.addingTimeInterval(-2 * 86_400) + ), + now: now + ) + let expired = makeReader( + account: makeSnapshot( + remaining: 50, + fetchedAt: now.addingTimeInterval(-3 * 86_400) + ), + now: now + ) + let failed = makeReader( + account: makeSnapshot(remaining: 50, fetchedAt: now), + sourceState: .failed(sourceError), + now: now + ) + + XCTAssertNil(missing.suggestedPacePercentPerDay) + XCTAssertEqual(missing.suggestedPaceText, "Weekly usage unavailable") + XCTAssertNil(atReset.suggestedPacePercentPerDay) + XCTAssertEqual( + atReset.suggestedPaceText, + "Current allowance window unavailable" + ) + XCTAssertNil(expired.suggestedPacePercentPerDay) + XCTAssertEqual( + expired.suggestedPaceText, + "Current allowance window unavailable" + ) + XCTAssertNil(failed.suggestedPacePercentPerDay) + XCTAssertEqual(failed.suggestedPaceText, sourceError) + } + func testCompleteCoverageNeedsTheWholeWindowWithoutMaterialGaps() { let step: TimeInterval = 30 * 60 let now = Date(timeIntervalSince1970: 7_000_000) @@ -1540,6 +3034,225 @@ final class UsageIntelligenceEngineTests: XCTestCase { XCTAssertTrue(ended.chart.historicalProjection.isEmpty) } + func testCurrentWindowUsesFullAllowanceFrameAndStopsObservedSeries() throws { + let start = try date("2026-08-01T12:13:00Z") + let latestObservation = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [ + TokenDay( + date: latestObservation.addingTimeInterval(-86_400), + tokens: 1_000, + completeness: .complete + ) + ], + emergencyResetCount: 0, + fetchedAt: latestObservation + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: latestObservation, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: latestObservation, + previousStatus: nil + ) + ) + + XCTAssertEqual(reader.interval?.startsAt, start) + XCTAssertEqual(reader.interval?.resetsAt, reset) + XCTAssertEqual(reader.chart.target.map(\.date), [start, reset]) + XCTAssertEqual(reader.chart.observed.map(\.date).max(), latestObservation) + XCTAssertFalse(reader.chart.observed.contains { $0.date > latestObservation }) + XCTAssertEqual( + reader.accountTokenActivity.range, + DateInterval(start: start, end: reset) + ) + XCTAssertEqual(reader.accountTokenActivity.tokens, 300) + XCTAssertEqual( + reader.accountTokenActivity.interval, + DateInterval(start: start, end: latestObservation) + ) + XCTAssertEqual(reader.accountTokenActivity.intervals.count, 1) + XCTAssertTrue( + reader.accountTokenActivity.currentWindowAccessibilityValue + .contains("300 account tokens so far") + ) + + let refreshedWithoutReading = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ), + UsageSample( + observedAt: latestObservation, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_300 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: latestObservation.addingTimeInterval(3_600), + previousStatus: nil + ) + ) + XCTAssertEqual( + refreshedWithoutReading.accountTokenActivity.intervals, + reader.accountTokenActivity.intervals + ) + } + + func testCurrentWindowDistinguishesZeroFromNoInterval() throws { + let start = try date("2026-08-01T12:13:00Z") + let now = try date("2026-08-03T12:13:00Z") + let reset = try date("2026-08-08T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: now + ) + let first = UsageSample( + observedAt: start, + remainingPercent: 100, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + let zero = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + first, + UsageSample( + observedAt: now, + remainingPercent: 70, + resetsAt: reset, + lifetimeTokens: 1_000 + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ).accountTokenActivity + let missing = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [first], + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil + ) + ).accountTokenActivity + + XCTAssertEqual(zero.tokens, 0) + XCTAssertEqual(zero.intervals.first?.tokenDelta, 0) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("observed zero-token interval") + ) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("missing, not zero") + ) + XCTAssertTrue( + zero.currentWindowAccessibilityValue + .contains("future and has no observation") + ) + XCTAssertNil(missing.tokens) + XCTAssertEqual(missing.reason, "No account readings in this range") + } + + func testExpiredCurrentWindowIsUnavailableButKeepsHistoricalSamples() throws { + let reset = try date("2026-08-08T12:13:00Z") + let historicalObservation = try date("2026-08-03T12:13:00Z") + let account = UsageSnapshot( + mainLimit: LimitReading( + limitId: "codex", + name: "Codex", + window: UsageWindow( + remainingPercent: 70, + resetsAt: reset, + durationMinutes: 10_080 + ) + ), + otherLimits: [], + tokenHistory: [], + emergencyResetCount: 0, + fetchedAt: historicalObservation + ) + let reader = UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: [ + UsageSample( + observedAt: historicalObservation, + remainingPercent: 75, + resetsAt: reset + ) + ], + safetyBuffer: 3, + sourceState: .available, + now: try date("2026-08-09T12:13:00Z"), + previousStatus: nil + ) + ) + + XCTAssertNil(reader.weeklyUsageRemaining) + XCTAssertNil(reader.interval) + XCTAssertEqual(reader.evidence.reason, "Current allowance window unavailable") + XCTAssertEqual(reader.guidanceTitle, "Current allowance window unavailable") + XCTAssertTrue(reader.chart.allObserved.contains { + $0.date == historicalObservation + }) + XCTAssertEqual( + reader.accountTokenActivity.reason, + "Current allowance window unavailable" + ) + XCTAssertTrue(reader.accountTokenActivity.intervals.isEmpty) + } + func testQuietDenseHistoryLeavesRoomToUseMore() { let now = Date(timeIntervalSince1970: 9_000_000) let account = makeSnapshot(remaining: 40, fetchedAt: now) @@ -2162,6 +3875,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { private func makeSnapshot( remaining: Double, fetchedAt: Date, + resetAfter: TimeInterval = 2 * 86_400, tokenHistory: [TokenDay] = [], accountFacts: AccountFacts? = nil, emergencyResetCount: Int = 0, @@ -2170,7 +3884,7 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) -> UsageSnapshot { let window = UsageWindow( remainingPercent: remaining, - resetsAt: fetchedAt.addingTimeInterval(2 * 86_400), + resetsAt: fetchedAt.addingTimeInterval(resetAfter), durationMinutes: 10_080 ) return UsageSnapshot( @@ -2185,6 +3899,29 @@ final class UsageIntelligenceEngineTests: XCTestCase { ) } + private func makeReader( + account: UsageSnapshot?, + samples: [UsageSample] = [], + safetyBuffer: Double = 3, + sourceState: UsageSourceState = .available, + now: Date + ) -> UsageReaderSnapshot { + UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: samples, + safetyBuffer: safetyBuffer, + sourceState: sourceState, + now: now, + previousStatus: nil + ) + ) + } + + private func date(_ value: String) throws -> Date { + try XCTUnwrap(ISO8601DateFormatter().date(from: value)) + } + private func timingFact( id: String, taskID: String, @@ -2265,6 +4002,33 @@ final class UsageIntelligenceEngineTests: XCTestCase { } } + private func rollingDayActivity( + account: UsageSnapshot, + samples: S, + now: Date, + accountPartitionID: String? = nil, + accountEpochStartedAt: Date? = nil, + timeRange: AnalyticsTimeRange = .oneDay, + visibleRange: DateInterval? = nil + ) -> AccountTokenActivitySnapshot where S.Element == UsageSample { + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = timeRange + exploration.visibleRange = visibleRange + return UsageIntelligenceEngine.evaluate( + UsageIntelligenceInput( + account: account, + samples: Array(samples), + safetyBuffer: 3, + sourceState: .available, + now: now, + previousStatus: nil, + accountPartitionID: accountPartitionID, + accountEpochStartedAt: accountEpochStartedAt, + analyticsExploration: exploration + ) + ).accountTokenActivity + } + private func weeklySamples( start: Date, end: Date, diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift index bb79e6f..1f2ea65 100644 --- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift +++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift @@ -410,7 +410,11 @@ final class UsageMonitorHistoryTests: XCTestCase { XCTAssertNil(monitor.readerSnapshot.accountTokenActivity.tokens) XCTAssertEqual( monitor.readerSnapshot.accountTokenActivity.reason, - "No lifetime token reading at the weekly boundary" + "No account readings in this range" + ) + XCTAssertEqual( + monitor.readerSnapshot.accountTokenActivity.breaks.map(\.reason), + [.accountChange] ) defaults.removeObject( @@ -720,6 +724,51 @@ final class UsageMonitorHistoryTests: XCTestCase { XCTAssertEqual(monitor.readerSnapshot.menuBarText, "80%") } + func testPreferenceChangeRecalculatesTokenRangeWhenIdle() async throws { + let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let evaluationStarted = expectation( + description: "account evaluation started" + ) + let evaluator = BlockingUsageEvaluator(started: evaluationStarted) + let fetchedAt = Date(timeIntervalSince1970: 1_900_000) + let source = FetchSequence([ + makeFetchResult( + identity: "user@example.com", + fetchedAt: fetchedAt, + remaining: 80 + ) + ]) + let monitor = UsageMonitor( + defaults: defaults, + historyDirectory: temporaryDirectory(), + startsAutomatically: false, + fetchUsage: { try await source.next() }, + evaluateUsage: { evaluator.evaluate($0) } + ) + let refresh = Task { await monitor.refresh() } + await fulfillment(of: [evaluationStarted], timeout: 2) + evaluator.release() + await refresh.value + let initialCalls = evaluator.callCount + var exploration = AnalyticsExplorationState.initial + exploration.timeRange = .threeDays + + monitor.analyticsPreferencesDidChange( + exploration: exploration, + dispositions: [:] + ) + let deadline = ContinuousClock.now + .seconds(2) + while evaluator.callCount == initialCalls, + ContinuousClock.now < deadline { + await Task.yield() + } + + XCTAssertGreaterThan(evaluator.callCount, initialCalls) + XCTAssertEqual(evaluator.lastExploration, exploration) + } + func testFailedRefreshRetainsTheLastReaderSnapshot() async throws { let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))