diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f285c0de..885f695a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ - AWS Bedrock: treat Cost Explorer's temporary data-unavailable response as zero usage instead of an HTTP 400 error (#1324). Thanks @enesteve0! - Provider switcher: inset quota bars inside fixed-height segments so icons, labels, and selected pills remain vertically centered. - Doubao: show an unavailable quota state when Ark omits trustworthy request-limit data instead of reporting 100% left. +- Weekly pace: use configured work days for standard weekly pace calculations while leaving historical Codex pacing unchanged (#1451, 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 991d0cc5f1..7f918e3355 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -173,6 +173,22 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + static func standardWeeklyPace(input: Input, window: RateWindow) -> UsagePace? { + if let weeklyPace = input.weeklyPace { + return weeklyPace + } + return Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) + } + + private static func displayableWeeklyPace(_ pace: UsagePace?) -> UsagePace? { + guard let pace else { return nil } + return pace.expectedUsedPercent >= 3 || pace.etaSeconds == 0 ? pace : nil + } + static func cursorBillingCyclePaceDetail( window: RateWindow, input: Input, @@ -181,10 +197,12 @@ 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) - guard let resolved, - resolved.expectedUsedPercent >= 3 - else { return nil } + let resolved = pace ?? UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek) + guard let resolved = Self.displayableWeeklyPace(resolved) else { return nil } return Self.weeklyPaceDetail( window: window, now: input.now, @@ -285,8 +303,11 @@ extension UsageMenuCardView.Model { now: input.now, showUsed: input.usageBarsShowUsed) case 10080: - let pace = UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) - .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil } + let pace = Self.displayableWeeklyPace(UsagePace.weekly( + window: window, + now: input.now, + defaultWindowMinutes: 10080, + workDays: input.workDaysPerWeek)) return Self.weeklyPaceDetail( window: window, now: input.now, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 060034e016..a7e1213340 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1573,9 +1573,7 @@ extension UsageMenuCardView.Model { paceDetail = Self.weeklyPaceDetail( window: window, now: input.now, - pace: input.weeklyPace - ?? UsagePace.weekly(window: window, now: input.now, defaultWindowMinutes: 10080) - .flatMap { $0.expectedUsedPercent >= 3 ? $0 : nil }, + pace: Self.standardWeeklyPace(input: input, window: window), showUsed: input.usageBarsShowUsed) } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 93dd54c841..42d58ff0fb 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -491,6 +491,8 @@ "show_usage_as_used_subtitle" = "Les barres de progrés s'omplen a mesura que consumeixes la quota (en comptes de mostrar el que queda)."; "show_quota_warning_markers_title" = "Mostra els marcadors d'avís de quota"; "show_quota_warning_markers_subtitle" = "Dibuixa marques de llindar a les barres d'ús quan hi ha avisos de quota configurats."; +"weekly_progress_work_days_title" = "Dies laborables del progrés setmanal"; +"weekly_progress_work_days_subtitle" = "Defineix els dies laborables per als marcadors de les barres d'ús setmanal i els càlculs de ritme."; "show_reset_time_as_clock_title" = "Mostra l'hora de reinici com a rellotge"; "show_reset_time_as_clock_subtitle" = "Mostra les hores de reinici com a valors de rellotge absoluts en comptes de comptes enrere."; "show_provider_changelog_links_title" = "Mostra els enllaços al registre de canvis del proveïdor"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 6a42ac9779..4a5bca7f02 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -503,7 +503,7 @@ "show_quota_warning_markers_title" = "Quotenwarnmarkierungen anzeigen"; "show_quota_warning_markers_subtitle" = "Zeichnen Sie Schwellenwertmarkierungen auf Nutzungsbalken, wenn Kontingentwarnungen konfiguriert sind."; "weekly_progress_work_days_title" = "Wöchentliche Fortschrittsarbeitstage"; -"weekly_progress_work_days_subtitle" = "Zeichnen Sie Häkchenmarkierungen für die Tagesgrenzen auf den Balken für die wöchentliche Nutzung."; +"weekly_progress_work_days_subtitle" = "Legt Arbeitstage für Markierungen in wöchentlichen Nutzungsbalken und Tempo-Berechnungen fest."; "show_reset_time_as_clock_title" = "Reset-Zeit als Uhr anzeigen"; "show_reset_time_as_clock_subtitle" = "Anzeige der Rücksetzzeiten als absolute Uhrwerte statt als Countdown."; "show_provider_changelog_links_title" = "Links zum Änderungsprotokoll des Anbieters anzeigen"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 96520e6673..ff91a812a7 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -504,7 +504,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/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 9af91d3d63..9f8616d67e 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -491,6 +491,8 @@ "show_usage_as_used_subtitle" = "Las barras de progreso se llenan a medida que consumes la cuota (en lugar de mostrar lo restante)."; "show_quota_warning_markers_title" = "Mostrar marcadores de aviso de cuota"; "show_quota_warning_markers_subtitle" = "Dibuja marcas de umbral en las barras de uso cuando hay avisos de cuota configurados."; +"weekly_progress_work_days_title" = "Días laborables del progreso semanal"; +"weekly_progress_work_days_subtitle" = "Define los días laborables para los marcadores de las barras de uso semanal y los cálculos de ritmo."; "show_reset_time_as_clock_title" = "Mostrar la hora de reinicio como reloj"; "show_reset_time_as_clock_subtitle" = "Mostrar las horas de reinicio como valores de reloj absolutos en lugar de cuentas atrás."; "show_provider_changelog_links_title" = "Mostrar enlaces al registro de cambios del proveedor"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 93c4e30ec6..b738771693 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -503,7 +503,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 915b243c82..20b52ca9d3 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -501,7 +501,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/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 270531410e..228d3b3ff5 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -495,7 +495,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 0474ca44c7..96a18d09d5 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -503,7 +503,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 429867f434..67c68bd69c 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -501,7 +501,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 158a325d26..83e2178a69 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -502,7 +502,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/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index d7fbab19a3..d2e58da834 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -503,7 +503,7 @@ "show_quota_warning_markers_title" = "Kota uyarı işaretlerini göster"; "show_quota_warning_markers_subtitle" = "Kota uyarıları yapılandırıldığında kullanım çubuklarına eşik çizgileri çizer."; "weekly_progress_work_days_title" = "Haftalık ilerleme iş günleri"; -"weekly_progress_work_days_subtitle" = "Haftalık kullanım çubuklarına gün sınırı çizgileri çizer."; +"weekly_progress_work_days_subtitle" = "Haftalık kullanım çubuğu işaretleri ve tempo hesaplamaları için iş günlerini ayarlar."; "show_reset_time_as_clock_title" = "Sıfırlama süresini saat olarak göster"; "show_reset_time_as_clock_subtitle" = "Sıfırlama sürelerini geri sayım yerine mutlak saat değerleri olarak göster."; "show_provider_changelog_links_title" = "Sağlayıcı değişiklik günlüğü bağlantılarını göster"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index ee461a5cff..92f2e817fa 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -503,7 +503,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 0a33267da4..db935b7b84 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -501,7 +501,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 8319e2937f..33287a7ec9 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -846,7 +846,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 d28972ae85..882a48162f 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -502,7 +502,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 c8443008be..cda21506b2 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 2c93f0a474..c55b54c890 100644 --- a/Sources/CodexBarCore/UsagePace.swift +++ b/Sources/CodexBarCore/UsagePace.swift @@ -40,7 +40,9 @@ public struct UsagePace: Sendable { public static func weekly( window: RateWindow, now: Date = .init(), - defaultWindowMinutes: Int = 10080) -> UsagePace? + defaultWindowMinutes: Int = 10080, + workDays: Int? = nil, + calendar: Calendar = .current) -> UsagePace? { guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? defaultWindowMinutes @@ -51,7 +53,20 @@ 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, + calendar: calendar) + } 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 +77,29 @@ 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 actual >= 100 { + etaSeconds = 0 + } else if paceElapsed > 0, actual > 0 { + let rate = actual / paceElapsed if rate > 0 { - let remaining = max(0, 100 - actual) + let remaining = 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, + calendar: calendar) } else { etaSeconds = candidate } } - } else if elapsed > 0, actual == 0 { + } else if paceElapsed > 0, actual == 0 { willLastToReset = true } @@ -107,6 +133,101 @@ 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, + calendar: Calendar) -> WorkdayProgress? + { + let windowStart = resetsAt.addingTimeInterval(-duration) + + var totalWorkSeconds: TimeInterval = 0 + var elapsedWorkSeconds: TimeInterval = 0 + var remainingWorkSeconds: TimeInterval = 0 + + var cursor = windowStart + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + 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, + calendar: Calendar) -> TimeInterval? + { + guard requiredWorkSeconds > 0 else { return 0 } + + var remaining = requiredWorkSeconds + var cursor = now + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + 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 nextDayBoundary(after date: Date, calendar: Calendar) -> Date? { + calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) + } + + 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/HistoricalUsagePaceBackfillAuthorityTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift index 4696130715..8de40e9ab4 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift @@ -309,6 +309,7 @@ extension HistoricalUsagePaceTests { copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) settings.historicalTrackingEnabled = true + settings.weeklyProgressWorkDays = 5 let planHistoryStore = testPlanUtilizationHistoryStore( suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") @@ -339,9 +340,54 @@ extension HistoricalUsagePaceTests { store._setCodexHistoricalDatasetForTesting(twoWeeksDataset) let computed = store.weeklyPace(provider: .codex, window: window, now: now) - let linear = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) + let workdayAware = UsagePace.weekly( + window: window, + now: now, + defaultWindowMinutes: 10080, + workDays: 5) #expect(computed != nil) - #expect(abs((computed?.deltaPercent ?? 0) - (linear?.deltaPercent ?? 0)) < 0.001) + #expect(abs((computed?.deltaPercent ?? 0) - (workdayAware?.deltaPercent ?? 0)) < 0.001) + } + + @MainActor + @Test + func `usage store preserves historical Codex pace when work days are configured`() throws { + let suite = "HistoricalUsagePaceTests-workdays-preserve-history-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = 5 + + let now = Date(timeIntervalSince1970: 0) + let duration = TimeInterval(10080 * 60) + let resetsAt = now.addingTimeInterval(duration / 2) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + let weeks = (0..<4).map { index in + HistoricalWeekProfile( + resetsAt: resetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: 10080, + curve: Self.linearCurve(end: 80)) + } + let dataset = CodexHistoricalDataset(weeks: weeks) + let expected = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + store._setCodexHistoricalDatasetForTesting( + dataset, + accountKey: store.codexOwnershipContext().canonicalKey) + + let computed = try #require(store.weeklyPace(provider: .codex, window: window, now: now)) + + #expect(abs(computed.expectedUsedPercent - expected.expectedUsedPercent) < 0.001) + #expect(abs(computed.deltaPercent - expected.deltaPercent) < 0.001) + #expect(computed.etaSeconds == expected.etaSeconds) + #expect(computed.willLastToReset == expected.willLastToReset) + #expect(computed.runOutProbability == expected.runOutProbability) } @MainActor @@ -365,6 +411,41 @@ extension HistoricalUsagePaceTests { #expect(abs((pace?.deltaPercent ?? 0) - (40 - (3.0 / 7.0 * 100.0))) < 0.001) } + @MainActor + @Test + func `usage store applies configured work days to generic weekly pace`() throws { + let suite = "HistoricalUsagePaceTests-generic-workdays-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = 5 + + 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: 11))) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(store.weeklyPace(provider: .zai, window: window, now: now)) + + #expect(abs(pace.expectedUsedPercent - 60) < 0.001) + #expect(abs(pace.deltaPercent) < 0.001) + } + @MainActor @Test func `usage store returns nil pace when generic window lacks explicit duration`() throws { diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 2d93021328..11778b68dc 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -63,6 +63,30 @@ struct LocalizationLanguageCatalogTests { } } + @Test + func `localized catalogs include workday pace setting copy`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let title = catalog["weekly_progress_work_days_title"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = catalog["weekly_progress_work_days_subtitle"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + + #expect(title?.isEmpty == false, "Missing workday title in \(catalogURL.lastPathComponent)") + #expect(subtitle?.isEmpty == false, "Missing workday subtitle in \(catalogURL.lastPathComponent)") + } + } + @Test func `ukrainian localization bundle exists and contains key UI labels`() throws { let root = URL(fileURLWithPath: #filePath) diff --git a/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift new file mode 100644 index 0000000000..b712a0ccb5 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardWorkdayPaceTests { + @Test + func `codex weekly lane shows exhausted pace before first configured workday`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.detailLeftText == "100% in deficit") + #expect(weekly.detailRightText == "Runs out now") + } +} diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index 0de92b0986..cc591709d6 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -76,6 +76,414 @@ 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%. + let calendar = Self.utcCalendar + + // 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, + calendar: calendar)) + + // 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%. + let calendar = Self.utcCalendar + + // 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, + calendar: calendar)) + + // 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 exhausted quota does not last through weekend`() throws { + let calendar = Self.utcCalendar + + 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: 13, + hour: 12))) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware eta excludes non workday elapsed time`() throws { + let calendar = Self.utcCalendar + + 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, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (48 * 3600)) < 1) + } + + @Test + func `workday aware eta maps work time across a weekend`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let now = try Self.date( + year: 2026, + month: 6, + day: 12, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 40 work hours remain at the observed rate: 12 hours Friday, all Monday, then 4 hours Tuesday. + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (88 * 3600)) < 1) + } + + @Test + func `workday aware pace stays flat on non workdays`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let saturday = try Self.date( + year: 2026, + month: 6, + day: 13, + hour: 12, + calendar: calendar) + let sunday = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let saturdayPace = try #require(UsagePace.weekly( + window: window, + now: saturday, + workDays: 5, + calendar: calendar)) + let sundayPace = try #require(UsagePace.weekly( + window: window, + now: sunday, + workDays: 5, + calendar: calendar)) + + #expect(abs(saturdayPace.expectedUsedPercent - 60) < 0.01) + #expect(sundayPace.expectedUsedPercent == saturdayPace.expectedUsedPercent) + } + + @Test + func `zero usage becomes safe only after the first configured workday begins`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 0, + calendar: calendar) + let firstWorkday = try Self.date( + year: 2026, + month: 6, + day: 8, + hour: 0, + calendar: calendar) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let before = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(-1), + workDays: 5, + calendar: calendar)) + let boundary = try #require(UsagePace.weekly( + window: window, + now: firstWorkday, + workDays: 5, + calendar: calendar)) + let after = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(3600), + workDays: 5, + calendar: calendar)) + + #expect(before.expectedUsedPercent == 0) + #expect(before.willLastToReset == false) + #expect(boundary.expectedUsedPercent == 0) + #expect(boundary.willLastToReset == false) + #expect(after.expectedUsedPercent > 0) + #expect(after.willLastToReset == true) + } + + @Test + func `workday aware pace does not declare zero usage safe before first workday`() throws { + let calendar = Self.utcCalendar + + 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, + calendar: calendar)) + + #expect(pace.expectedUsedPercent == 0) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == nil) + } + + @Test + func `workday aware exhausted quota stays exhausted before first workday`() throws { + let calendar = Self.utcCalendar + + 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: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware pace splits a non midnight reset at local day boundaries`() throws { + let calendar = Self.utcCalendar + + 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, + calendar: calendar)) + + // 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 +502,44 @@ 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) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int = 0, + calendar: Calendar) throws -> Date + { + try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: hour, + minute: minute))) + } }