diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c53e7b44..3a420545f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Menu bar: reserve quota-bar space consistently across Overview and provider switcher segments so selection no longer changes segment height (#1445). Thanks @Zihao-Qi! - Cost usage: accept normal models.dev catalog churn while retaining prior model prices as fallbacks, so newly priced models appear without requiring a manual cache reset (#1438). Thanks @tom-rigelblu! - Menu bar: detect Tahoe Control Center proxy windows parked in the blocked offscreen slot during startup recovery, so hidden icons show the existing guidance without weakening menu-bar-manager safeguards (#1440). +- Weekly pace: use configured work days for standard weekly pace calculations while leaving historical Codex pacing unchanged (#1357, fixes #1356). Thanks @pstanton237! - Menu bar: anchor merged provider dropdowns to the status item's trailing edge without marking preserved in-flight refresh content fresh, preventing horizontal drift while keeping deferred updates visible (#1288). Thanks @Yuxin-Qiao! - Antigravity: fall back to the CLI usage server when the desktop app is closed, keep helper sessions owned and bounded without hidden sign-in flows, and show model rows with missing usage as unavailable instead of exhausted (#1313). Thanks @enieuwy! - Cost usage: replace repeated Foundation metadata/root checks with one portable file-stat pass so expired Codex history refreshes stay responsive on very large session archives (#1392). Thanks @TheAngryPit and @ProspectOre! diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index af028f7f2..35cbf9433 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -154,7 +154,11 @@ extension UsageMenuCardView.Model { guard input.provider == .cursor, window.windowMinutes != nil else { return nil } - let resolved = pace ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + let resolved = pace ?? UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek) guard let resolved, resolved.expectedUsedPercent >= 3 else { return nil } @@ -255,7 +259,11 @@ extension UsageMenuCardView.Model { now: input.now, showUsed: input.usageBarsShowUsed) case 10080: - let pace = UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + let pace = UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek) .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil } return Self.weeklyPaceDetail( window: window, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 8b2ce17c9..1b0cd5ed0 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1505,7 +1505,11 @@ extension UsageMenuCardView.Model { window: window, now: input.now, pace: input.weeklyPace - ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) + ?? UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek) .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil }, showUsed: input.usageBarsShowUsed) } diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index cdc0c56b2..46b7e339e 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -499,7 +499,7 @@ "show_quota_warning_markers_title" = "Show quota warning markers"; "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; "weekly_progress_work_days_title" = "Weekly progress work days"; -"weekly_progress_work_days_subtitle" = "Draw day-boundary tick marks on weekly usage bars."; +"weekly_progress_work_days_subtitle" = "Set work days for weekly usage-bar markers and pace calculations."; "show_reset_time_as_clock_title" = "Show reset time as clock"; "show_reset_time_as_clock_subtitle" = "Display reset times as absolute clock values instead of countdowns."; "show_provider_changelog_links_title" = "Show provider changelog links"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index aca685e72..ae4f5da4c 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -498,7 +498,7 @@ "show_quota_warning_markers_title" = "Afficher les marqueurs d'avertissement de quota"; "show_quota_warning_markers_subtitle" = "Dessinez des coches de seuil sur les barres d’utilisation lorsque des avertissements de quota sont configurés."; "weekly_progress_work_days_title" = "Jours de travail hebdomadaires"; -"weekly_progress_work_days_subtitle" = "Dessinez des graduations journalières sur les barres d’utilisation hebdomadaire."; +"weekly_progress_work_days_subtitle" = "Définit les jours ouvrés pour les repères des barres d’utilisation hebdomadaire et le calcul du rythme."; "show_reset_time_as_clock_title" = "Afficher l'heure de réinitialisation sous forme d'horloge"; "show_reset_time_as_clock_subtitle" = "Affichez les temps de réinitialisation sous forme de valeurs d'horloge absolues au lieu de comptes à rebours."; "show_provider_changelog_links_title" = "Afficher les liens du journal des modifications du fournisseur"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7f16a37b2..0eaefdc37 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -497,7 +497,7 @@ "show_quota_warning_markers_title" = "クォータ警告マーカーを表示"; "show_quota_warning_markers_subtitle" = "クォータ警告が設定されている場合、使用量バーにしきい値の目盛りを描画します。"; "weekly_progress_work_days_title" = "週間進捗の作業日"; -"weekly_progress_work_days_subtitle" = "週間使用量バーに日付の区切り目盛りを描画します。"; +"weekly_progress_work_days_subtitle" = "週間使用量バーの目盛りとペース計算に使用する作業日を設定します。"; "show_reset_time_as_clock_title" = "リセット時刻を時計表示"; "show_reset_time_as_clock_subtitle" = "リセット時刻をカウントダウンではなく絶対時刻で表示します。"; "show_provider_changelog_links_title" = "プロバイダの変更履歴リンクを表示"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 67cc5e473..8cb713aef 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -498,7 +498,7 @@ "show_quota_warning_markers_title" = "Toon waarschuwingsmarkeringen voor quota"; "show_quota_warning_markers_subtitle" = "Teken drempelmarkeringen op gebruiksbalken wanneer quotawaarschuwingen zijn geconfigureerd."; "weekly_progress_work_days_title" = "Wekelijkse voortgang werkdagen"; -"weekly_progress_work_days_subtitle" = "Teken daggrensmarkeringen op de wekelijkse gebruiksbalken."; +"weekly_progress_work_days_subtitle" = "Stel werkdagen in voor markeringen op wekelijkse gebruiksbalken en tempoberekeningen."; "show_reset_time_as_clock_title" = "Toon resettijd als klok"; "show_reset_time_as_clock_subtitle" = "Geef resettijden weer als absolute klokwaarden in plaats van aftellingen."; "show_provider_changelog_links_title" = "Toon provider changelog-links"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 98a98f57b..485678e7c 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -497,7 +497,7 @@ "show_quota_warning_markers_title" = "Mostrar marcadores de alerta de cota"; "show_quota_warning_markers_subtitle" = "Desenha marcas de limite nas barras de uso quando os alertas de cota estão configurados."; "weekly_progress_work_days_title" = "Dias úteis no progresso semanal"; -"weekly_progress_work_days_subtitle" = "Desenha marcas de limite de dia nas barras de uso semanal."; +"weekly_progress_work_days_subtitle" = "Define os dias úteis para marcadores das barras de uso semanal e cálculos de ritmo."; "show_reset_time_as_clock_title" = "Mostrar renovação como horário"; "show_reset_time_as_clock_subtitle" = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; "show_provider_changelog_links_title" = "Mostrar links de changelog dos provedores"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ad74f19f3..96eccd64b 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -497,7 +497,7 @@ "show_quota_warning_markers_title" = "Visa kvotvarningsmarkörer"; "show_quota_warning_markers_subtitle" = "Rita tröskelmarkeringar på användningsstaplar när kvotvarningar är konfigurerade."; "weekly_progress_work_days_title" = "Arbetsdagar i veckoförlopp"; -"weekly_progress_work_days_subtitle" = "Rita dagsgränsmarkeringar på veckostaplar."; +"weekly_progress_work_days_subtitle" = "Ställ in arbetsdagar för markeringar i veckostaplar och tempoberäkningar."; "show_reset_time_as_clock_title" = "Visa återställningstid som klockslag"; "show_reset_time_as_clock_subtitle" = "Visa återställningstider som klockslag i stället för nedräkningar."; "show_provider_changelog_links_title" = "Visa länkar till leverantörers ändringsloggar"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 37212d56d..54fc073aa 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -498,7 +498,7 @@ "show_quota_warning_markers_title" = "Показати маркери попередження про квоти"; "show_quota_warning_markers_subtitle" = "Малюйте порогові позначки на панелях використання, коли налаштовано попередження про квоту."; "weekly_progress_work_days_title" = "Щотижневі робочі дні"; -"weekly_progress_work_days_subtitle" = "Позначте межі дня на смугах тижневого використання."; +"weekly_progress_work_days_subtitle" = "Задайте робочі дні для позначок на смугах тижневого використання та розрахунків темпу."; "show_reset_time_as_clock_title" = "Показувати час скидання як годинник"; "show_reset_time_as_clock_subtitle" = "Відображення часу скидання як абсолютних значень годинника замість зворотного відліку."; "show_provider_changelog_links_title" = "Показати посилання журналу змін провайдера"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index a737b0edc..dafdfd487 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -497,7 +497,7 @@ "show_quota_warning_markers_title" = "Hiển thị Hạn mức dấu cảnh báo"; "show_quota_warning_markers_subtitle" = "Vẽ dấu kiểm ngưỡng trên thanh Mức sử dụng khi cảnh báo Hạn mức được định cấu hình."; "weekly_progress_work_days_title" = "Tiến độ ngày làm việc hàng tuần"; -"weekly_progress_work_days_subtitle" = "Vẽ các dấu kiểm ranh giới ngày trên các thanh Mức sử dụng hàng tuần."; +"weekly_progress_work_days_subtitle" = "Đặt ngày làm việc cho các vạch trên thanh sử dụng hằng tuần và phép tính nhịp độ."; "show_reset_time_as_clock_title" = "Hiển thị Đặt lại thời gian dưới dạng đồng hồ"; "show_reset_time_as_clock_subtitle" = "Hiển thị Đặt lại thời gian dưới dạng giá trị đồng hồ tuyệt đối thay vì đếm ngược."; "show_provider_changelog_links_title" = "Hiển thị Nhà cung cấp liên kết nhật ký thay đổi"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 661ad9786..b6e1b1924 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -840,7 +840,7 @@ "Cleanup ideas" = "清理建议"; "%d unreadable item(s) skipped" = "已跳过 %d 个不可读项目"; "weekly_progress_work_days_title" = "工作日刻度线"; -"weekly_progress_work_days_subtitle" = "在每周用量条上显示按天分隔的刻度线。"; +"weekly_progress_work_days_subtitle" = "设置用于每周用量条刻度和进度计算的工作日。"; "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 769076f22..de2fa0546 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -498,7 +498,7 @@ "show_quota_warning_markers_title" = "顯示配額提醒標記"; "show_quota_warning_markers_subtitle" = "設定配額提醒後,在使用量條上繪製門檻刻度標記。"; "weekly_progress_work_days_title" = "每週進度工作日標記"; -"weekly_progress_work_days_subtitle" = "在每週使用量條上繪製日期邊界刻度標記。"; +"weekly_progress_work_days_subtitle" = "設定用於每週用量條刻度與進度計算的工作日。"; "show_reset_time_as_clock_title" = "以時鐘時間顯示重置時間"; "show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; "show_provider_changelog_links_title" = "顯示提供者版本資訊連結"; diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index c8443008b..cda21506b 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -9,6 +9,7 @@ extension UsageStore { func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? + let workDays = self.settings.weeklyProgressWorkDays // Codex can refine pace with historical samples because its dashboard exposes enough weekly history to build // an account-scoped usage curve. Other providers should not need a hard-coded allowlist: if their RateWindow // includes a reset time and window duration, the generic linear pace calculation is already defensible. @@ -22,14 +23,14 @@ extension UsageStore { { resolved = historical } else { - resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } } else { // Generic providers must carry an explicit window duration. Using the 10080-minute fallback for // windows without windowMinutes would fabricate a weekly pace for non-weekly windows // (e.g. Factory monthly with only resetsAt). guard window.windowMinutes != nil else { return nil } - resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080, workDays: workDays) } guard let resolved else { return nil } diff --git a/Sources/CodexBarCore/UsagePace.swift b/Sources/CodexBarCore/UsagePace.swift index 2c93f0a47..52264da46 100644 --- a/Sources/CodexBarCore/UsagePace.swift +++ b/Sources/CodexBarCore/UsagePace.swift @@ -40,7 +40,8 @@ public struct UsagePace: Sendable { public static func weekly( window: RateWindow, now: Date = .init(), - defaultWindowMinutes: Int = 10080) -> UsagePace? + defaultWindowMinutes: Int = 10080, + workDays: Int? = nil) -> UsagePace? { guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? defaultWindowMinutes @@ -51,7 +52,15 @@ public struct UsagePace: Sendable { guard timeUntilReset > 0 else { return nil } guard timeUntilReset <= duration else { return nil } let elapsed = (duration - timeUntilReset).clamped(to: 0...duration) - let expected = ((elapsed / duration) * 100).clamped(to: 0...100) + let workdayProgress: WorkdayProgress? = if let workDays, workDays >= 2, workDays < 7, + minutes == 10080 + { + Self.workdayProgress(now: now, duration: duration, resetsAt: resetsAt, workDays: workDays) + } else { + nil + } + let expected = workdayProgress?.expectedUsedPercent + ?? ((elapsed / duration) * 100).clamped(to: 0...100) let actual = window.usedPercent.clamped(to: 0...100) if elapsed == 0, actual > 0 { return nil @@ -62,18 +71,26 @@ public struct UsagePace: Sendable { var etaSeconds: TimeInterval? var willLastToReset = false - if elapsed > 0, actual > 0 { - let rate = actual / elapsed + let paceElapsed = workdayProgress?.elapsedSeconds ?? elapsed + if paceElapsed > 0, actual > 0 { + let rate = actual / paceElapsed if rate > 0 { let remaining = max(0, 100 - actual) let candidate = remaining / rate - if candidate >= timeUntilReset { + let effectiveTimeUntilReset = workdayProgress?.remainingSeconds ?? timeUntilReset + if candidate >= effectiveTimeUntilReset { willLastToReset = true + } else if let workDays = workdayProgress?.workDays { + etaSeconds = Self.wallClockInterval( + from: now, + to: resetsAt, + consumingWorkSeconds: candidate, + workDays: workDays) } else { etaSeconds = candidate } } - } else if elapsed > 0, actual == 0 { + } else if paceElapsed > 0, actual == 0 { willLastToReset = true } @@ -107,6 +124,93 @@ public struct UsagePace: Sendable { runOutProbability: runOutProbability) } + private struct WorkdayProgress { + let workDays: Int + let totalSeconds: TimeInterval + let elapsedSeconds: TimeInterval + let remainingSeconds: TimeInterval + + var expectedUsedPercent: Double { + ((self.elapsedSeconds / self.totalSeconds) * 100).clamped(to: 0...100) + } + } + + /// Splits the weekly window at local day boundaries so reset offsets do not shift weekday classification. + private static func workdayProgress( + now: Date, + duration: TimeInterval, + resetsAt: Date, + workDays: Int) -> WorkdayProgress? + { + let windowStart = resetsAt.addingTimeInterval(-duration) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + var totalWorkSeconds: TimeInterval = 0 + var elapsedWorkSeconds: TimeInterval = 0 + var remainingWorkSeconds: TimeInterval = 0 + + var cursor = windowStart + while cursor < resetsAt { + let startOfNextDay = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: cursor)!) + let sliceEnd = min(startOfNextDay, resetsAt) + + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let sliceDuration = sliceEnd.timeIntervalSince(cursor) + totalWorkSeconds += sliceDuration + if now > cursor { + elapsedWorkSeconds += min(now, sliceEnd).timeIntervalSince(cursor) + } + if now < sliceEnd { + remainingWorkSeconds += sliceEnd.timeIntervalSince(max(now, cursor)) + } + } + cursor = sliceEnd + } + + guard totalWorkSeconds > 0 else { return nil } + return WorkdayProgress( + workDays: workDays, + totalSeconds: totalWorkSeconds, + elapsedSeconds: elapsedWorkSeconds, + remainingSeconds: remainingWorkSeconds) + } + + private static func wallClockInterval( + from now: Date, + to resetsAt: Date, + consumingWorkSeconds requiredWorkSeconds: TimeInterval, + workDays: Int) -> TimeInterval? + { + guard requiredWorkSeconds > 0 else { return 0 } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + var remaining = requiredWorkSeconds + var cursor = now + while cursor < resetsAt { + let startOfNextDay = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: cursor)!) + let sliceEnd = min(startOfNextDay, resetsAt) + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let available = sliceEnd.timeIntervalSince(cursor) + if remaining <= available { + return cursor.addingTimeInterval(remaining).timeIntervalSince(now) + } + remaining -= available + } + cursor = sliceEnd + } + return nil + } + + private static func isWorkday(_ date: Date, calendar: Calendar, workDays: Int) -> Bool { + let weekday = calendar.component(.weekday, from: date) + let isoWeekday = weekday == 1 ? 7 : weekday - 1 + return isoWeekday <= workDays + } + private static func stage(for delta: Double) -> Stage { let absDelta = abs(delta) if absDelta <= 2 { return .onTrack } diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index 0de92b098..e6649839d 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -76,6 +76,213 @@ struct UsagePaceTests { #expect(pace == nil) } + // MARK: - Workday-aware pace + + @Test + func `workday aware pace shows on track for five day user on friday`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00 (7 days). + // "now" is Friday Jun 12 18:00 → elapsed = 5.75 days. + // 7-day linear: expected ≈ 82.1%, actual = 100% → ~18% deficit. + // 5-day workday: Mon-Thu plus 18 hours Friday → expected = 95%. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // "now" is Friday Jun 12 18:00 (30 hours before reset) + let now = resetsAt.addingTimeInterval(-30 * 3600) + + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace5 = try #require(UsagePace.weekly(window: window, now: now, workDays: 5)) + + // 7-day linear: expected ≈ 82%, actual = 100% → ~18% deficit + #expect(pace7.deltaPercent > 15) + + // 5-day workday: expected = 95%, so 100% actual remains within the on-pace threshold. + #expect(abs(pace5.expectedUsedPercent - 95) < 0.01) + #expect(abs(pace5.deltaPercent) <= 5) + } + + @Test + func `workday aware pace shows on track midweek`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00. + // "now" is Thu Jun 11 00:00 → 3 full workdays (Mon-Wed) elapsed of 5. + // 5-day model: expected ≈ 60%. + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // Thu Jun 11 00:00 (3 days before reset). + let now = resetsAt.addingTimeInterval(-72 * 3600) + + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace5 = try #require(UsagePace.weekly(window: window, now: now, workDays: 5)) + + // 3 full workdays elapsed out of 5 → expected ≈ 60% + #expect(abs(pace5.expectedUsedPercent - 60) < 0.01) + #expect(abs(pace5.deltaPercent) < 0.01) + } + + @Test + func `workday aware eta excludes non workday elapsed time`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: 5)) + + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (48 * 3600)) < 1) + } + + @Test + func `workday aware pace does not declare zero usage safe before first workday`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: 5)) + + #expect(pace.expectedUsedPercent == 0) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == nil) + } + + @Test + func `workday aware pace splits a non midnight reset at local day boundaries`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14, + hour: 20))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: 5)) + + // The weekly window starts Sunday at 20:00. Monday 00:00-12:00 is 12 of + // the week's 120 work hours, so it must contribute 10% despite the reset offset. + #expect(abs(pace.expectedUsedPercent - 10) < 0.01) + #expect(abs(pace.deltaPercent) < 0.01) + } + + @Test + func `workday aware pace falls back to linear when workDays is nil or 7`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceNil = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: 7)) + let paceDefault = try #require(UsagePace.weekly(window: window, now: now)) + + // All should produce identical expected values (linear) + #expect(abs(paceNil.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + #expect(abs(pace7.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + } + + @Test + func `workday aware pace ignores non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 0) + // 300-minute session window — workDays should have no effect + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let paceNoWork = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: nil)) + let paceWork5 = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: 5)) + + #expect(abs(paceNoWork.expectedUsedPercent - paceWork5.expectedUsedPercent) < 0.01) + } + @Test func `session pace computes delta and eta for five hour window`() { let now = Date(timeIntervalSince1970: 0) @@ -94,4 +301,20 @@ struct UsagePaceTests { #expect(pace.stage == .behind) #expect(pace.willLastToReset == true) } + + @Test + func `one work day falls back to linear pace`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceOne = try #require(UsagePace.weekly(window: window, now: now, workDays: 1)) + let paceNil = try #require(UsagePace.weekly(window: window, now: now)) + + // workDays == 1 should fall back to linear pace, identical to workDays: nil + #expect(abs(paceOne.expectedUsedPercent - paceNil.expectedUsedPercent) < 0.01) + } }