From c359058c71388172bb8c090c5cd0c21145ce14ad Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:44:47 +0800 Subject: [PATCH 1/8] feat: add standalone token activity heatmap --- .../PreferencesSpendDashboardPane.swift | 6 + .../Resources/ar.lproj/Localizable.strings | 10 + .../Resources/ca.lproj/Localizable.strings | 10 + .../Resources/de.lproj/Localizable.strings | 10 + .../Resources/en.lproj/Localizable.strings | 10 + .../Resources/es.lproj/Localizable.strings | 10 + .../Resources/fa.lproj/Localizable.strings | 10 + .../Resources/fr.lproj/Localizable.strings | 10 + .../Resources/gl.lproj/Localizable.strings | 10 + .../Resources/id.lproj/Localizable.strings | 10 + .../Resources/it.lproj/Localizable.strings | 10 + .../Resources/ja.lproj/Localizable.strings | 10 + .../Resources/ko.lproj/Localizable.strings | 10 + .../Resources/nl.lproj/Localizable.strings | 10 + .../Resources/pl.lproj/Localizable.strings | 10 + .../Resources/pt-BR.lproj/Localizable.strings | 10 + .../Resources/ru.lproj/Localizable.strings | 10 + .../Resources/sv.lproj/Localizable.strings | 10 + .../Resources/th.lproj/Localizable.strings | 10 + .../Resources/tr.lproj/Localizable.strings | 10 + .../Resources/uk.lproj/Localizable.strings | 10 + .../Resources/vi.lproj/Localizable.strings | 10 + .../zh-Hans.lproj/Localizable.strings | 10 + .../zh-Hant.lproj/Localizable.strings | 10 + Sources/CodexBar/SpendActivityHeatmap.swift | 593 ++++++++++++++++++ .../CodexBar/SpendDashboardController.swift | 2 +- Sources/CodexBar/SpendDashboardModel.swift | 53 +- .../SpendActivityHeatmapTests.swift | 155 +++++ .../SpendDashboardControllerTests.swift | 2 +- .../SpendDashboardModelTests.swift | 2 +- 30 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBar/SpendActivityHeatmap.swift create mode 100644 Tests/CodexBarTests/SpendActivityHeatmapTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index e55aa37bb4..ea0147e8ee 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -307,6 +307,12 @@ struct SpendDashboardPane: View { } } + if self.settings.costUsageEnabled, !self.controller.model.tokenActivity.isEmpty { + SpendDashboardPanel { + SpendActivityHeatmapView(points: self.controller.model.tokenActivity) + } + } + if self.controller.failedSourceCount > 0 { Label( spendDashboardRefreshFailureText(self.controller.failedSourceCount), diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index c59e9261ea..8ec345d782 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "إعادات الضبط"; "Finish now" = "إنهاء الآن"; "Continue in background" = "المتابعة في الخلفية"; + +"Cumulative" = "تراكمي"; +"Token activity" = "نشاط الرموز"; +"View" = "عرض"; +"in the last year" = "في العام الماضي"; +"No activity in the last 12 months" = "لا يوجد نشاط في آخر 12 شهرًا"; +"Each column = 1 week" = "كل عمود = أسبوع واحد"; +"Running total" = "الإجمالي التراكمي"; +"Less" = "أقل"; +"More" = "أكثر"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a9c28dc98e..3c13cf5e8c 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1381,3 +1381,13 @@ "Resets" = "Reinicis"; "Finish now" = "Finalitza ara"; "Continue in background" = "Continua en segon pla"; + +"Cumulative" = "Acumulat"; +"Token activity" = "Activitat de tokens"; +"View" = "Vista"; +"in the last year" = "l'últim any"; +"No activity in the last 12 months" = "Sense activitat en els últims 12 mesos"; +"Each column = 1 week" = "Cada columna = 1 setmana"; +"Running total" = "Total acumulat"; +"Less" = "Menys"; +"More" = "Més"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 70d0d26c7f..a735ff996d 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1379,3 +1379,13 @@ "Resets" = "Zurücksetzungen"; "Finish now" = "Jetzt abschließen"; "Continue in background" = "Im Hintergrund fortfahren"; + +"Cumulative" = "Kumulativ"; +"Token activity" = "Token-Aktivität"; +"View" = "Ansicht"; +"in the last year" = "im letzten Jahr"; +"No activity in the last 12 months" = "Keine Aktivität in den letzten 12 Monaten"; +"Each column = 1 week" = "Jede Spalte = 1 Woche"; +"Running total" = "Laufende Summe"; +"Less" = "Weniger"; +"More" = "Mehr"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ca4f093501..aea0d7e3fa 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1383,3 +1383,13 @@ "%dm ago" = "%dm ago"; "%dh ago" = "%dh ago"; "%dd ago" = "%dd ago"; + +"Cumulative" = "Cumulative"; +"Token activity" = "Token activity"; +"View" = "View"; +"in the last year" = "in the last year"; +"No activity in the last 12 months" = "No activity in the last 12 months"; +"Each column = 1 week" = "Each column = 1 week"; +"Running total" = "Running total"; +"Less" = "Less"; +"More" = "More"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 1c87172cb6..0da01b26af 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1377,3 +1377,13 @@ "Resets" = "Reinicios"; "Finish now" = "Finalizar ahora"; "Continue in background" = "Continuar en segundo plano"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Actividad de tokens"; +"View" = "Vista"; +"in the last year" = "en el último año"; +"No activity in the last 12 months" = "Sin actividad en los últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Más"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 8c5d961d18..cb280f4c60 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "بازنشانی‌ها"; "Finish now" = "اکنون تمام شود"; "Continue in background" = "ادامه در پس‌زمینه"; + +"Cumulative" = "تجمعی"; +"Token activity" = "فعالیت توکن"; +"View" = "نمایش"; +"in the last year" = "در سال گذشته"; +"No activity in the last 12 months" = "هیچ فعالیتی در ۱۲ ماه گذشته وجود ندارد"; +"Each column = 1 week" = "هر ستون = ۱ هفته"; +"Running total" = "مجموع تجمعی"; +"Less" = "کمتر"; +"More" = "بیشتر"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 4ea93f7e26..ee52a54626 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1378,3 +1378,13 @@ "Resets" = "Réinitialisations"; "Finish now" = "Terminer maintenant"; "Continue in background" = "Continuer en arrière-plan"; + +"Cumulative" = "Cumulé"; +"Token activity" = "Activité des tokens"; +"View" = "Vue"; +"in the last year" = "sur l'année écoulée"; +"No activity in the last 12 months" = "Aucune activité au cours des 12 derniers mois"; +"Each column = 1 week" = "Chaque colonne = 1 semaine"; +"Running total" = "Total cumulé"; +"Less" = "Moins"; +"More" = "Plus"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index bfc0874c80..1542c6d9d9 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1378,3 +1378,13 @@ "Resets" = "Reinicios"; "Finish now" = "Rematar agora"; "Continue in background" = "Continuar en segundo plano"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Actividade de tokens"; +"View" = "Vista"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sen actividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Máis"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 44fc9b9242..90472f635f 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "Reset"; "Finish now" = "Selesaikan sekarang"; "Continue in background" = "Lanjutkan di latar belakang"; + +"Cumulative" = "Kumulatif"; +"Token activity" = "Aktivitas token"; +"View" = "Tampilan"; +"in the last year" = "dalam setahun terakhir"; +"No activity in the last 12 months" = "Tidak ada aktivitas dalam 12 bulan terakhir"; +"Each column = 1 week" = "Setiap kolom = 1 minggu"; +"Running total" = "Total berjalan"; +"Less" = "Sedikit"; +"More" = "Banyak"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 43e9260a1d..7a59c563d6 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "Ripristini"; "Finish now" = "Completa ora"; "Continue in background" = "Continua in background"; + +"Cumulative" = "Cumulativo"; +"Token activity" = "Attività token"; +"View" = "Vista"; +"in the last year" = "nell'ultimo anno"; +"No activity in the last 12 months" = "Nessuna attività negli ultimi 12 mesi"; +"Each column = 1 week" = "Ogni colonna = 1 settimana"; +"Running total" = "Totale progressivo"; +"Less" = "Meno"; +"More" = "Più"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 66c8f11455..a48b002597 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1379,3 +1379,13 @@ "Resets" = "リセット"; "Finish now" = "今すぐ完了"; "Continue in background" = "バックグラウンドで続行"; + +"Cumulative" = "累計"; +"Token activity" = "トークンアクティビティ"; +"View" = "表示"; +"in the last year" = "過去1年"; +"No activity in the last 12 months" = "過去12か月にアクティビティなし"; +"Each column = 1 week" = "各列 = 1週間"; +"Running total" = "累計"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index bf8a6aaabf..27d2e590ff 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1346,3 +1346,13 @@ "Resets" = "재설정"; "Finish now" = "지금 완료"; "Continue in background" = "백그라운드에서 계속"; + +"Cumulative" = "누적"; +"Token activity" = "토큰 활동"; +"View" = "보기"; +"in the last year" = "지난 1년"; +"No activity in the last 12 months" = "지난 12개월 동안 활동 없음"; +"Each column = 1 week" = "각 열 = 1주"; +"Running total" = "누적 합계"; +"Less" = "적음"; +"More" = "많음"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index c499928c92..da826690c7 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1378,3 +1378,13 @@ "Resets" = "Resets"; "Finish now" = "Nu voltooien"; "Continue in background" = "Doorgaan op de achtergrond"; + +"Cumulative" = "Cumulatief"; +"Token activity" = "Token-activiteit"; +"View" = "Weergave"; +"in the last year" = "in het afgelopen jaar"; +"No activity in the last 12 months" = "Geen activiteit in de afgelopen 12 maanden"; +"Each column = 1 week" = "Elke kolom = 1 week"; +"Running total" = "Lopend totaal"; +"Less" = "Minder"; +"More" = "Meer"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 359c165c2b..1bbb52cc2d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "Resety"; "Finish now" = "Zakończ teraz"; "Continue in background" = "Kontynuuj w tle"; + +"Cumulative" = "Łącznie"; +"Token activity" = "Aktywność tokenów"; +"View" = "Widok"; +"in the last year" = "w ciągu ostatniego roku"; +"No activity in the last 12 months" = "Brak aktywności w ciągu ostatnich 12 miesięcy"; +"Each column = 1 week" = "Każda kolumna = 1 tydzień"; +"Running total" = "Suma narastająca"; +"Less" = "Mniej"; +"More" = "Więcej"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 498a44f8b1..17cf43d2b1 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1379,3 +1379,13 @@ "Resets" = "Redefinições"; "Finish now" = "Concluir agora"; "Continue in background" = "Continuar em segundo plano"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Atividade de tokens"; +"View" = "Visualização"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sem atividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada coluna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Mais"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index c8f81499f9..bae5ef1da9 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1380,3 +1380,13 @@ "Resets" = "Сбросы"; "Finish now" = "Завершить сейчас"; "Continue in background" = "Продолжить в фоновом режиме"; + +"Cumulative" = "Накопительно"; +"Token activity" = "Активность токенов"; +"View" = "Вид"; +"in the last year" = "за последний год"; +"No activity in the last 12 months" = "Нет активности за последние 12 месяцев"; +"Each column = 1 week" = "Каждый столбец = 1 неделя"; +"Running total" = "Накопленный итог"; +"Less" = "Меньше"; +"More" = "Больше"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 18e05568ce..1bf4bc3e4f 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1377,3 +1377,13 @@ "Resets" = "Återställningar"; "Finish now" = "Slutför nu"; "Continue in background" = "Fortsätt i bakgrunden"; + +"Cumulative" = "Kumulativt"; +"Token activity" = "Token-aktivitet"; +"View" = "Vy"; +"in the last year" = "det senaste året"; +"No activity in the last 12 months" = "Ingen aktivitet de senaste 12 månaderna"; +"Each column = 1 week" = "Varje kolumn = 1 vecka"; +"Running total" = "Löpande summa"; +"Less" = "Mindre"; +"More" = "Mer"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index e66f0bd1b5..d00b5dd91f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1382,3 +1382,13 @@ "Resets" = "การรีเซ็ต"; "Finish now" = "เสร็จสิ้นตอนนี้"; "Continue in background" = "ทำต่อในเบื้องหลัง"; + +"Cumulative" = "สะสม"; +"Token activity" = "กิจกรรมโทเทน"; +"View" = "มุมมอง"; +"in the last year" = "ในปีที่ผ่านมา"; +"No activity in the last 12 months" = "ไม่มีกิจกรรมในช่วง 12 เดือนที่ผ่านมา"; +"Each column = 1 week" = "แต่ละคอลัมน์ = 1 สัปดาห์"; +"Running total" = "ยอดรวมสะสม"; +"Less" = "น้อย"; +"More" = "มาก"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index f5f134d5a4..97c06b379d 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1380,3 +1380,13 @@ "Resets" = "Sıfırlamalar"; "Finish now" = "Şimdi tamamla"; "Continue in background" = "Arka planda devam et"; + +"Cumulative" = "Kümülatif"; +"Token activity" = "Token etkinliği"; +"View" = "Görünüm"; +"in the last year" = "son yılda"; +"No activity in the last 12 months" = "Son 12 ayda etkinlik yok"; +"Each column = 1 week" = "Her sütun = 1 hafta"; +"Running total" = "Birikimli toplam"; +"Less" = "Az"; +"More" = "Çok"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 6dd0467cc8..bccbc0deaa 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1378,3 +1378,13 @@ "Resets" = "Скидання"; "Finish now" = "Завершити зараз"; "Continue in background" = "Продовжити у фоновому режимі"; + +"Cumulative" = "Наростаючим підсумком"; +"Token activity" = "Активність токенів"; +"View" = "Вигляд"; +"in the last year" = "за останній рік"; +"No activity in the last 12 months" = "Немає активності за останні 12 місяців"; +"Each column = 1 week" = "Кожен стовпець = 1 тиждень"; +"Running total" = "Накопичений підсумок"; +"Less" = "Менше"; +"More" = "Більше"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index da7c7b8225..902f09465b 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1379,3 +1379,13 @@ "Resets" = "Lần đặt lại"; "Finish now" = "Hoàn tất ngay"; "Continue in background" = "Tiếp tục trong nền"; + +"Cumulative" = "Tích lũy"; +"Token activity" = "Hoạt động token"; +"View" = "Chế độ xem"; +"in the last year" = "trong năm qua"; +"No activity in the last 12 months" = "Không có hoạt động trong 12 tháng qua"; +"Each column = 1 week" = "Mỗi cột = 1 tuần"; +"Running total" = "Tổng lũy kế"; +"Less" = "Ít"; +"More" = "Nhiều"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 86f2f29e30..6d204c3531 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1357,3 +1357,13 @@ "Resets" = "重置"; "Finish now" = "立即完成"; "Continue in background" = "转为后台运行"; + +"Cumulative" = "累计"; +"Token activity" = "Token 活动"; +"View" = "视图"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 个月暂无活动"; +"Each column = 1 week" = "每列 = 1 周"; +"Running total" = "累计总量"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ef5520f2df..01f686a4b4 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1409,3 +1409,13 @@ "Resets" = "重設"; "Finish now" = "立即完成"; "Continue in background" = "在背景繼續"; + +"Cumulative" = "累計"; +"Token activity" = "Token 活動"; +"View" = "檢視"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 個月暫無活動"; +"Each column = 1 week" = "每列 = 1 週"; +"Running total" = "累計總量"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift new file mode 100644 index 0000000000..5ee7d6d74e --- /dev/null +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -0,0 +1,593 @@ +import CodexBarCore +import Foundation +import SwiftUI + +enum SpendActivityViewMode: String, CaseIterable, Identifiable { + case daily + case weekly + case cumulative + + var id: Self { + self + } + + var title: String { + switch self { + case .daily: L("Daily") + case .weekly: L("Weekly") + case .cumulative: L("Cumulative") + } + } +} + +struct SpendActivitySeries { + static let weekCount = 52 + static let dayCount = 7 + + let daily: [Int] + let start: Date + let today: Date + let calendar: Calendar + + static func make( + from points: [SpendDashboardModel.TokenActivityPoint], + now: Date = Date(), + calendar: Calendar = .current) -> Self + { + var totals: [Date: Int] = [:] + for point in points { + let day = calendar.startOfDay(for: point.day) + totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(point.totalTokens, 0)) + } + + let today = calendar.startOfDay(for: now) + let weekday = calendar.component(.weekday, from: today) + let thisWeekSunday = calendar.date(byAdding: .day, value: -(weekday - 1), to: today) ?? today + let start = calendar.date( + byAdding: .weekOfYear, + value: -(Self.weekCount - 1), + to: thisWeekSunday) ?? thisWeekSunday + let cellCount = Self.weekCount * Self.dayCount + var daily = [Int](repeating: 0, count: cellCount) + for index in 0.. Date? { + self.calendar.date(byAdding: .day, value: index, to: self.start) + } + + func weekStartDate(at week: Int) -> Date? { + self.calendar.date(byAdding: .day, value: week * Self.dayCount, to: self.start) + } + + private static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? Int.max : result.partialValue + } +} + +enum SpendActivityLevels { + static func dailyLevels(_ values: [Int]) -> [Int] { + let maxValue = values.max() ?? 0 + return values.map { value in + guard value > 0, maxValue > 0 else { return 0 } + let ratio = Double(value) / Double(maxValue) + if ratio > 0.75 { return 4 } + if ratio > 0.5 { return 3 } + if ratio > 0.25 { return 2 } + return 1 + } + } + + static func weeklyTotals(_ daily: [Int]) -> [Int] { + stride(from: 0, to: daily.count, by: SpendActivitySeries.dayCount).map { start in + daily[start.. [Int] { + var sum = 0 + return weekly.map { value in + let result = sum.addingReportingOverflow(value) + sum = result.overflow ? Int.max : result.partialValue + return sum + } + } + + static func color(forLevel level: Int) -> Color { + switch level { + case 4: self.rgb(0x216E39) + case 3: self.rgb(0x30A14E) + case 2: self.rgb(0x40C463) + case 1: self.rgb(0x9BE9A8) + default: self.rgb(0xEBEDF0) + } + } + + static var uniformFill: Color { + self.rgb(0x40C463) + } + + private static func rgb(_ hex: UInt32) -> Color { + Color( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } +} + +struct SpendActivityGridGeometry { + static let weekdayGutterWidth: CGFloat = 40 + static let gridSpacing: CGFloat = 8 + static let tooltipInset: CGFloat = 4 + static let tooltipGap: CGFloat = 7 + static let tooltipWidth: CGFloat = 148 + static let tooltipHeight: CGFloat = 50 + + static func gridFrame(containerWidth: CGFloat, columns: Int = SpendActivitySeries.weekCount) -> CGRect { + let leading = self.weekdayGutterWidth + self.gridSpacing + let width = max(containerWidth - leading, 0) + let pitch = columns > 0 ? width / CGFloat(columns) : 0 + return CGRect(x: leading, y: 0, width: width, height: pitch * CGFloat(SpendActivitySeries.dayCount)) + } + + static func weekdayCenter(row: Int, rowPitch: CGFloat) -> CGFloat { + (CGFloat(row) + 0.5) * rowPitch + } + + static func tooltipCenterX(anchorX: CGFloat, tooltipWidth: CGFloat, gridWidth: CGFloat) -> CGFloat { + let halfWidth = tooltipWidth / 2 + let lower = min(halfWidth + self.tooltipInset, gridWidth / 2) + let upper = max(gridWidth - halfWidth - self.tooltipInset, gridWidth / 2) + return min(max(anchorX, lower), upper) + } + + static func tooltipOriginY(anchorY: CGFloat, tooltipHeight: CGFloat, gridHeight: CGFloat) -> CGFloat { + let below = anchorY + self.tooltipGap + if below + tooltipHeight <= gridHeight { + return below + } + return max(anchorY - tooltipHeight - self.tooltipGap, 0) + } +} + +enum SpendActivityWeekday { + static let labeledRows = [1, 3, 5] + + static func label(for row: Int, locale: Locale? = nil) -> String { + guard self.labeledRows.contains(row) else { return "" } + let formatter = DateFormatter() + formatter.locale = locale ?? codexBarLocalizedResourceLocale() + guard let symbols = formatter.shortStandaloneWeekdaySymbols, symbols.indices.contains(row) else { + return "" + } + return symbols[row] + } +} + +enum SpendActivityDateFormatting { + static func mediumDateString(_ date: Date, locale: Locale? = nil) -> String { + let formatter = DateFormatter() + formatter.locale = locale ?? codexBarLocalizedResourceLocale() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +struct SpendActivityHeatmapView: View { + let points: [SpendDashboardModel.TokenActivityPoint] + let now: Date + + @AppStorage("spendActivityViewMode") private var mode: SpendActivityViewMode = .daily + @State private var series: SpendActivitySeries + + init(points: [SpendDashboardModel.TokenActivityPoint], now: Date = Date()) { + self.points = points + self.now = now + self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now)) + } + + var body: some View { + let hasActivity = (self.series.daily.max() ?? 0) > 0 + let totalTokens = Self.saturatingTotal(self.series.daily) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(L("Token activity")) + .font(.headline) + if hasActivity { + Text("\(UsageFormatter.tokenCountString(totalTokens)) \(L("in the last year"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Picker(L("View"), selection: self.$mode) { + ForEach(SpendActivityViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + + if hasActivity { + switch self.mode { + case .daily: + SpendActivityDailyGrid(series: self.series) + self.dailyLegend + case .weekly: + SpendActivityWeekGrid( + series: self.series, + values: SpendActivityLevels.weeklyTotals(self.series.daily), + cumulative: false) + self.caption(L("Each column = 1 week")) + case .cumulative: + SpendActivityWeekGrid( + series: self.series, + values: SpendActivityLevels.cumulativeTotals( + SpendActivityLevels.weeklyTotals(self.series.daily)), + cumulative: true) + self.caption(L("Running total")) + } + } else { + Text(L("No activity in the last 12 months")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: self.points) { _, points in + self.series = SpendActivitySeries.make(from: points, now: self.now) + } + } + + private var dailyLegend: some View { + HStack(spacing: 4) { + Spacer() + Text(L("Less")) + ForEach(0...4, id: \.self) { level in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.color(forLevel: level)) + .frame(width: 9, height: 9) + } + Text(L("More")) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func caption(_ text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private static func saturatingTotal(_ values: [Int]) -> Int { + values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityDailyGrid: View { + let series: SpendActivitySeries + + @State private var hoveredIndex: Int? + + private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + var body: some View { + let levels = SpendActivityLevels.dailyLevels(self.series.daily) + VStack(alignment: .leading, spacing: 3) { + self.monthRow + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + let cell = max(pitch - 2, 2) + ZStack(alignment: .topLeading) { + self.weekdayLabels(rowPitch: pitch) + ZStack(alignment: .topLeading) { + Canvas { context, _ in + let corner = min(cell * 0.22, 2.5) + for index in 0..<(self.columns * self.rows) where self.isVisibleCell(index) { + let col = index / self.rows + let row = index % self.rows + let rect = CGRect( + x: CGFloat(col) * pitch + (pitch - cell) / 2, + y: CGFloat(row) * pitch + (pitch - cell) / 2, + width: cell, + height: cell) + context.fill( + RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), + with: .color(SpendActivityLevels.color(forLevel: levels[index]))) + } + } + self.hoverHighlight(cell: cell, pitch: pitch) + self.tooltip(size: gridFrame.size, pitch: pitch) + } + .frame(width: gridFrame.width, height: gridFrame.height) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoveredIndex = self.cellIndex(at: location, pitch: pitch) + case .ended: + self.hoveredIndex = nil + } + } + .offset(x: gridFrame.minX) + } + } + .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Token activity")) + .accessibilityValue(UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily))) + } + + private var monthRow: some View { + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + ZStack(alignment: .topLeading) { + ForEach(self.monthMarkers(pitch: pitch)) { marker in + Text(marker.label) + .font(.caption2) + .foregroundStyle(.tertiary) + .offset(x: gridFrame.minX + marker.offset) + } + } + } + .frame(height: 14) + } + + private func weekdayLabels(rowPitch: CGFloat) -> some View { + ForEach(SpendActivityWeekday.labeledRows, id: \.self) { row in + Text(SpendActivityWeekday.label(for: row)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .frame(width: SpendActivityGridGeometry.weekdayGutterWidth, alignment: .trailing) + .position( + x: SpendActivityGridGeometry.weekdayGutterWidth / 2, + y: SpendActivityGridGeometry.weekdayCenter(row: row, rowPitch: rowPitch)) + } + } + + @ViewBuilder + private func hoverHighlight(cell: CGFloat, pitch: CGFloat) -> some View { + if let index = self.hoveredIndex { + let col = index / self.rows + let row = index % self.rows + RoundedRectangle(cornerRadius: min(cell * 0.22, 2.5), style: .continuous) + .stroke(Color.primary.opacity(0.7), lineWidth: 1.5) + .frame(width: cell, height: cell) + .position( + x: CGFloat(col) * pitch + pitch / 2, + y: CGFloat(row) * pitch + pitch / 2) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private func tooltip(size: CGSize, pitch: CGFloat) -> some View { + if let index = self.hoveredIndex, let date = self.series.date(at: index) { + let col = index / self.rows + let row = index % self.rows + let anchorX = CGFloat(col) * pitch + pitch / 2 + let anchorY = CGFloat(row) * pitch + pitch / 2 + let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) + let originY = SpendActivityGridGeometry.tooltipOriginY( + anchorY: anchorY, + tooltipHeight: SpendActivityGridGeometry.tooltipHeight, + gridHeight: size.height) + SpendActivityTooltip( + title: UsageFormatter.tokenCountString(self.series.daily[index]), + subtitle: SpendActivityDateFormatting.mediumDateString(date), + width: width) + .position( + x: SpendActivityGridGeometry.tooltipCenterX( + anchorX: anchorX, + tooltipWidth: width, + gridWidth: size.width), + y: originY + SpendActivityGridGeometry.tooltipHeight / 2) + .allowsHitTesting(false) + } + } + + private func cellIndex(at location: CGPoint, pitch: CGFloat) -> Int? { + guard pitch > 0 else { return nil } + let col = Int(location.x / pitch) + let row = Int(location.y / pitch) + guard col >= 0, col < self.columns, row >= 0, row < self.rows else { return nil } + let index = col * self.rows + row + return self.isVisibleCell(index) ? index : nil + } + + private func isVisibleCell(_ index: Int) -> Bool { + guard let date = self.series.date(at: index) else { return false } + return date <= self.series.today + } + + private struct MonthMarker: Identifiable { + let id: Int + let offset: CGFloat + let label: String + } + + private func monthMarkers(pitch: CGFloat) -> [MonthMarker] { + let formatter = DateFormatter() + formatter.locale = codexBarLocalizedResourceLocale() + formatter.dateFormat = "MMM" + var markers: [MonthMarker] = [] + var lastLabel = "" + for col in 0.. Int { + values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityWeekGrid: View { + let series: SpendActivitySeries + let values: [Int] + let cumulative: Bool + + @State private var hoverLocation: CGPoint? + + private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + var body: some View { + let maxValue = self.values.max() ?? 0 + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + let cell = max(pitch - 2, 2) + ZStack(alignment: .topLeading) { + Canvas { context, _ in + let corner = min(cell * 0.22, 2.5) + for col in 0.. 0 + ? Int((Double(value) / Double(maxValue) * Double(self.rows)).rounded()) + : 0 + let filled = value > 0 ? max(rawFill, 1) : 0 + for row in 0..= self.rows - filled + ? SpendActivityLevels.uniformFill + : SpendActivityLevels.color(forLevel: 0))) + } + } + } + self.tooltip(size: gridFrame.size, pitch: pitch) + } + .frame(width: gridFrame.width, height: gridFrame.height) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoverLocation = self.column(at: location, pitch: pitch) == nil ? nil : location + case .ended: + self.hoverLocation = nil + } + } + .offset(x: gridFrame.minX) + } + .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Token activity")) + .accessibilityValue(UsageFormatter.tokenCountString(self.accessibilityTokenTotal)) + } + + @ViewBuilder + private func tooltip(size: CGSize, pitch: CGFloat) -> some View { + if let location = self.hoverLocation, + let col = self.column(at: location, pitch: pitch), + col < self.values.count, + let weekStart = self.series.weekStartDate(at: col) + { + let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) + let originY = SpendActivityGridGeometry.tooltipOriginY( + anchorY: location.y, + tooltipHeight: SpendActivityGridGeometry.tooltipHeight, + gridHeight: size.height) + SpendActivityTooltip( + title: UsageFormatter.tokenCountString(self.values[col]), + subtitle: SpendActivityDateFormatting.mediumDateString(weekStart), + width: width) + .position( + x: SpendActivityGridGeometry.tooltipCenterX( + anchorX: CGFloat(col) * pitch + pitch / 2, + tooltipWidth: width, + gridWidth: size.width), + y: originY + SpendActivityGridGeometry.tooltipHeight / 2) + .allowsHitTesting(false) + } + } + + private func column(at location: CGPoint, pitch: CGFloat) -> Int? { + guard pitch > 0 else { return nil } + let col = Int(location.x / pitch) + guard col >= 0, col < self.columns, self.isVisible(col) else { return nil } + return col + } + + private func isVisible(_ column: Int) -> Bool { + guard let start = self.series.weekStartDate(at: column) else { return false } + return start <= self.series.today + } + + private var accessibilityTokenTotal: Int { + if self.cumulative { return self.values.last ?? 0 } + return self.values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityTooltip: View { + let title: String + let subtitle: String + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(self.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 8) + .frame( + width: self.width, + height: SpendActivityGridGeometry.tooltipHeight, + alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.3)) + } + } +} diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index f56a80d75d..a50bc4dd75 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -121,7 +121,7 @@ enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - static let scanDays = 30 + static let scanDays = 365 @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 96d3b5db9a..2fc2a9d61f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -61,6 +61,15 @@ struct SpendDashboardModel: Equatable, Sendable { } } + struct TokenActivityPoint: Identifiable, Equatable, Sendable { + let day: Date + let totalTokens: Int + + var id: Date { + self.day + } + } + enum ModelHistoryCompleteness: Equatable, Sendable { case complete case incomplete @@ -84,6 +93,17 @@ struct SpendDashboardModel: Equatable, Sendable { let requestedDays: Int let groups: [CurrencyGroup] + let tokenActivity: [TokenActivityPoint] + + init( + requestedDays: Int, + groups: [CurrencyGroup], + tokenActivity: [TokenActivityPoint] = []) + { + self.requestedDays = requestedDays + self.groups = groups + self.tokenActivity = tokenActivity + } static func build( inputs: [ProviderInput], @@ -118,7 +138,13 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: calculationCalendar) } .sorted { $0.currencyCode < $1.currencyCode } - return Self(requestedDays: days, groups: groups) + return Self( + requestedDays: days, + groups: groups, + tokenActivity: Self.tokenActivity( + inputs: inputs, + now: now, + calendar: calculationCalendar)) } private struct ClassifiedInput { @@ -542,6 +568,31 @@ struct SpendDashboardModel: Equatable, Sendable { } } + private static func tokenActivity( + inputs: [ProviderInput], + now: Date, + calendar: Calendar) -> [TokenActivityPoint] + { + let bounds = Self.bounds(days: 365, now: now, calendar: calendar) + var totals: [Date: Int] = [:] + for input in inputs { + for entry in input.snapshot.daily { + guard let day = Self.day( + entry.date, + provider: input.provider, + displayCalendar: calendar), + bounds.contains(day), + let tokens = Self.nonnegative(entry.totalTokens) + else { continue } + let addition = (totals[day] ?? 0).addingReportingOverflow(tokens) + totals[day] = addition.overflow ? Int.max : addition.partialValue + } + } + return totals.keys.sorted().map { day in + TokenActivityPoint(day: day, totalTokens: totals[day] ?? 0) + } + } + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift new file mode 100644 index 0000000000..2f007f6d42 --- /dev/null +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -0,0 +1,155 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendActivityHeatmapTests { + @Test + func `daily levels keep exact boundaries and tolerate Int max`() { + #expect(SpendActivityLevels.dailyLevels([0, 1, 25, 26, 50, 51, 75, 76, 100]) == [ + 0, 1, 1, 2, 2, 3, 3, 4, 4, + ]) + #expect(SpendActivityLevels.dailyLevels([Int.max, Int.max / 2]) == [4, 2]) + } + + @Test + func `weekly and cumulative totals saturate instead of overflowing`() { + let daily = [Int.max, 1, 0, 0, 0, 0, 0, 2] + let weekly = SpendActivityLevels.weeklyTotals(daily) + #expect(weekly == [Int.max, 2]) + #expect(SpendActivityLevels.cumulativeTotals(weekly) == [Int.max, Int.max]) + } + + @Test + func `series uses a fixed Sunday first 52 week window`() throws { + let calendar = Self.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 5))) + let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let futureDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) + let series = SpendActivitySeries.make( + from: [ + .init(day: previousDay, totalTokens: 10), + .init(day: now, totalTokens: 20), + .init(day: futureDay, totalTokens: 30), + ], + now: now, + calendar: calendar) + + #expect(series.daily.count == 52 * 7) + #expect(calendar.component(.weekday, from: series.start) == 1) + #expect(series.daily.reduce(0, +) == 30) + #expect(series.date(at: series.daily.count - 1)! > series.today) + } + + @Test + func `token activity spans a year without widening the spend chart`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let oldDay = "2025-08-01" + let first = Self.snapshot(entries: [ + Self.entry(day: oldDay, cost: 2, tokens: 40), + Self.entry(day: "2026-07-16", cost: 3, tokens: 10), + Self.entry(day: "2026-08-01", cost: 4, tokens: 100), + ]) + let second = Self.snapshot(entries: [ + Self.entry(day: oldDay, cost: 1, tokens: 60), + Self.entry(day: "2025-07-15", cost: 1, tokens: 200), + Self.entry(day: "invalid", cost: 1, tokens: 300), + Self.entry(day: "2026-07-15", cost: 1, tokens: -1), + ]) + let model = SpendDashboardModel.build( + inputs: [ + .init(id: "first", provider: .claude, displayName: "Claude", snapshot: first), + .init(id: "second", provider: .openai, displayName: "OpenAI", snapshot: second), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) + #expect(model.tokenActivity == [ + .init(day: oldDate, totalTokens: 100), + .init(day: now, totalTokens: 10), + ]) + #expect(model.groups.first?.dailyPoints.map(\.day) == [now]) + } + + @Test + func `weekday labels and cells share the same row pitch`() { + let frame = SpendActivityGridGeometry.gridFrame(containerWidth: 1088) + let pitch = frame.width / CGFloat(SpendActivitySeries.weekCount) + + #expect(SpendActivityGridGeometry.weekdayCenter(row: 1, rowPitch: pitch) == pitch * 1.5) + #expect(SpendActivityGridGeometry.weekdayCenter(row: 3, rowPitch: pitch) == pitch * 3.5) + #expect(SpendActivityGridGeometry.weekdayCenter(row: 5, rowPitch: pitch) == pitch * 5.5) + #expect(frame.height == pitch * 7) + } + + @Test + func `tooltip stays beside the hovered cell and clamps only at the edge`() { + let gridWidth: CGFloat = 1000 + let width = SpendActivityGridGeometry.tooltipWidth + let centered = SpendActivityGridGeometry.tooltipCenterX( + anchorX: 500, + tooltipWidth: width, + gridWidth: gridWidth) + let trailing = SpendActivityGridGeometry.tooltipCenterX( + anchorX: 995, + tooltipWidth: width, + gridWidth: gridWidth) + + #expect(centered == 500) + #expect(trailing > 900) + #expect(trailing <= gridWidth - width / 2) + #expect(SpendActivityGridGeometry.tooltipOriginY( + anchorY: 10, + tooltipHeight: 50, + gridHeight: 130) > 10) + #expect(SpendActivityGridGeometry.tooltipOriginY( + anchorY: 120, + tooltipHeight: 50, + gridHeight: 130) < 70) + } + + @Test + func `weekday and date formatting follow the selected resource locale`() throws { + let date = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 8, day: 1))) + let english = Locale(identifier: "en_US") + let chinese = Locale(identifier: "zh_Hans") + + #expect(SpendActivityWeekday.label(for: 1, locale: english) == "Mon") + #expect(SpendActivityWeekday.label(for: 3, locale: english) == "Wed") + #expect(SpendActivityWeekday.label(for: 5, locale: english) == "Fri") + #expect(SpendActivityDateFormatting.mediumDateString(date, locale: english).contains("Aug")) + #expect(!SpendActivityDateFormatting.mediumDateString(date, locale: english).contains("年")) + #expect(SpendActivityDateFormatting.mediumDateString(date, locale: chinese).contains("年")) + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func snapshot(entries: [CostUsageDailyReport.Entry]) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 365, + daily: entries, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func entry(day: String, cost: Double?, tokens: Int?) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: []) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 98018e1821..f8d188142e 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -48,7 +48,7 @@ struct SpendDashboardControllerTests { #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") #expect(contexts.first?.now == now) #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.historyDays == 365) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) } diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 9223e633ca..d4088a763c 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -753,7 +753,7 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) - #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", From 1daf115dc861a4f7041e350aae98a2310f31e1ba Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:39:25 +0800 Subject: [PATCH 2/8] fix: preserve token activity coverage --- Sources/CodexBar/SpendActivityHeatmap.swift | 193 +++++++++++++++--- Sources/CodexBar/SpendDashboardModel.swift | 99 +++++++-- .../SpendActivityHeatmapTests.swift | 136 ++++++++++-- 3 files changed, 360 insertions(+), 68 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 5ee7d6d74e..f144896bdb 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -25,6 +25,7 @@ struct SpendActivitySeries { static let dayCount = 7 let daily: [Int] + let isCovered: [Bool] let start: Date let today: Date let calendar: Calendar @@ -35,9 +36,16 @@ struct SpendActivitySeries { calendar: Calendar = .current) -> Self { var totals: [Date: Int] = [:] + var unknownDays: Set = [] for point in points { let day = calendar.startOfDay(for: point.day) - totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(point.totalTokens, 0)) + guard let totalTokens = point.totalTokens else { + totals.removeValue(forKey: day) + unknownDays.insert(day) + continue + } + guard !unknownDays.contains(day) else { continue } + totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(totalTokens, 0)) } let today = calendar.startOfDay(for: now) @@ -49,13 +57,16 @@ struct SpendActivitySeries { to: thisWeekSunday) ?? thisWeekSunday let cellCount = Self.weekCount * Self.dayCount var daily = [Int](repeating: 0, count: cellCount) + var isCovered = [Bool](repeating: false, count: cellCount) for index in 0.. Date? { @@ -66,12 +77,60 @@ struct SpendActivitySeries { self.calendar.date(byAdding: .day, value: week * Self.dayCount, to: self.start) } - private static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { + var visibleDayCount: Int { + self.daily.indices.filter(self.isVisible).count + } + + var coveredDayCount: Int { + self.daily.indices.count(where: { self.isVisible($0) && self.isCovered[$0] }) + } + + var hasUnknownCoverage: Bool { + self.coveredDayCount < self.visibleDayCount + } + + func weeklyActivity() -> SpendActivityAggregateSeries { + var values: [Int] = [] + var coverage: [Bool] = [] + for start in stride(from: 0, to: self.daily.count, by: Self.dayCount) { + let indices = start.. Bool { + guard let date = self.date(at: index) else { return false } + return date <= self.today + } + + static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { let result = lhs.addingReportingOverflow(rhs) return result.overflow ? Int.max : result.partialValue } } +struct SpendActivityAggregateSeries: Equatable { + let values: [Int] + let isCovered: [Bool] + + func cumulative() -> Self { + var total = 0 + var coverageIsComplete = true + var cumulativeValues: [Int] = [] + var cumulativeCoverage: [Bool] = [] + for index in self.values.indices { + total = SpendActivitySeries.saturatingAdd(total, self.values[index]) + coverageIsComplete = coverageIsComplete && self.isCovered[index] + cumulativeValues.append(total) + cumulativeCoverage.append(coverageIsComplete) + } + return Self(values: cumulativeValues, isCovered: cumulativeCoverage) + } +} + enum SpendActivityLevels { static func dailyLevels(_ values: [Int]) -> [Int] { let maxValue = values.max() ?? 0 @@ -117,6 +176,10 @@ enum SpendActivityLevels { self.rgb(0x40C463) } + static var unavailableFill: Color { + self.rgb(0xD6DCE5) + } + private static func rgb(_ hex: UInt32) -> Color { Color( red: Double((hex >> 16) & 0xFF) / 255, @@ -199,14 +262,23 @@ struct SpendActivityHeatmapView: View { var body: some View { let hasActivity = (self.series.daily.max() ?? 0) > 0 + let hasUnknownCoverage = self.series.hasUnknownCoverage let totalTokens = Self.saturatingTotal(self.series.daily) + let coverageText = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + let weekly = self.series.weeklyActivity() VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text(L("Token activity")) .font(.headline) if hasActivity { - Text("\(UsageFormatter.tokenCountString(totalTokens)) \(L("in the last year"))") + Text(self.activitySummary(totalTokens: totalTokens, coverageText: coverageText)) + .font(.caption) + .foregroundStyle(.secondary) + } else if hasUnknownCoverage { + Text("\(L("Unavailable")) · \(coverageText)") .font(.caption) .foregroundStyle(.secondary) } @@ -222,7 +294,7 @@ struct SpendActivityHeatmapView: View { .fixedSize() } - if hasActivity { + if hasActivity || hasUnknownCoverage { switch self.mode { case .daily: SpendActivityDailyGrid(series: self.series) @@ -230,16 +302,17 @@ struct SpendActivityHeatmapView: View { case .weekly: SpendActivityWeekGrid( series: self.series, - values: SpendActivityLevels.weeklyTotals(self.series.daily), + activity: weekly, cumulative: false) - self.caption(L("Each column = 1 week")) + self.caption( + L("Each column = 1 week"), + showsUnavailable: weekly.isCovered.contains(false)) case .cumulative: SpendActivityWeekGrid( series: self.series, - values: SpendActivityLevels.cumulativeTotals( - SpendActivityLevels.weeklyTotals(self.series.daily)), + activity: weekly.cumulative(), cumulative: true) - self.caption(L("Running total")) + self.caption(L("Running total"), showsUnavailable: hasUnknownCoverage) } } else { Text(L("No activity in the last 12 months")) @@ -265,15 +338,38 @@ struct SpendActivityHeatmapView: View { .frame(width: 9, height: 9) } Text(L("More")) + if self.series.hasUnknownCoverage { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.unavailableFill) + .frame(width: 9, height: 9) + .padding(.leading, 6) + Text(L("Unavailable")) + } } .font(.caption2) .foregroundStyle(.secondary) } - private func caption(_ text: String) -> some View { - Text(text) - .font(.caption2) - .foregroundStyle(.secondary) + private func caption(_ text: String, showsUnavailable: Bool) -> some View { + HStack(spacing: 4) { + Text(text) + Spacer() + if showsUnavailable { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.unavailableFill) + .frame(width: 9, height: 9) + Text(L("Unavailable")) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func activitySummary(totalTokens: Int, coverageText: String) -> String { + let total = UsageFormatter.tokenCountString(totalTokens) + return self.series.hasUnknownCoverage + ? "\(total) · \(coverageText)" + : "\(total) \(L("in the last year"))" } private static func saturatingTotal(_ values: [Int]) -> Int { @@ -313,9 +409,12 @@ private struct SpendActivityDailyGrid: View { y: CGFloat(row) * pitch + (pitch - cell) / 2, width: cell, height: cell) + let fill = self.series.isCovered[index] + ? SpendActivityLevels.color(forLevel: levels[index]) + : SpendActivityLevels.unavailableFill context.fill( RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), - with: .color(SpendActivityLevels.color(forLevel: levels[index]))) + with: .color(fill)) } } self.hoverHighlight(cell: cell, pitch: pitch) @@ -338,7 +437,7 @@ private struct SpendActivityDailyGrid: View { } .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) - .accessibilityValue(UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily))) + .accessibilityValue(self.accessibilityValue) } private var monthRow: some View { @@ -398,7 +497,9 @@ private struct SpendActivityDailyGrid: View { tooltipHeight: SpendActivityGridGeometry.tooltipHeight, gridHeight: size.height) SpendActivityTooltip( - title: UsageFormatter.tokenCountString(self.series.daily[index]), + title: self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable"), subtitle: SpendActivityDateFormatting.mediumDateString(date), width: width) .position( @@ -455,11 +556,20 @@ private struct SpendActivityDailyGrid: View { return result.overflow ? Int.max : result.partialValue } } + + private var accessibilityValue: String { + let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) + guard self.series.hasUnknownCoverage else { return total } + let coverage = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + return "\(total) · \(coverage)" + } } private struct SpendActivityWeekGrid: View { let series: SpendActivitySeries - let values: [Int] + let activity: SpendActivityAggregateSeries let cumulative: Bool @State private var hoverLocation: CGPoint? @@ -468,7 +578,10 @@ private struct SpendActivityWeekGrid: View { private let rows = SpendActivitySeries.dayCount var body: some View { - let maxValue = self.values.max() ?? 0 + let maxValue = self.activity.values.enumerated() + .filter { self.activity.isCovered[$0.offset] } + .map(\.element) + .max() ?? 0 GeometryReader { proxy in let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) let pitch = gridFrame.width / CGFloat(self.columns) @@ -476,13 +589,21 @@ private struct SpendActivityWeekGrid: View { ZStack(alignment: .topLeading) { Canvas { context, _ in let corner = min(cell * 0.22, 2.5) - for col in 0.. 0 ? Int((Double(value) / Double(maxValue) * Double(self.rows)).rounded()) : 0 let filled = value > 0 ? max(rawFill, 1) : 0 for row in 0..= self.rows - filled { + SpendActivityLevels.uniformFill + } else { + SpendActivityLevels.color(forLevel: 0) + } let rect = CGRect( x: CGFloat(col) * pitch + (pitch - cell) / 2, y: CGFloat(row) * pitch + (pitch - cell) / 2, @@ -490,9 +611,7 @@ private struct SpendActivityWeekGrid: View { height: cell) context.fill( RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), - with: .color(row >= self.rows - filled - ? SpendActivityLevels.uniformFill - : SpendActivityLevels.color(forLevel: 0))) + with: .color(fill)) } } } @@ -513,14 +632,14 @@ private struct SpendActivityWeekGrid: View { .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) - .accessibilityValue(UsageFormatter.tokenCountString(self.accessibilityTokenTotal)) + .accessibilityValue(self.accessibilityValue) } @ViewBuilder private func tooltip(size: CGSize, pitch: CGFloat) -> some View { if let location = self.hoverLocation, let col = self.column(at: location, pitch: pitch), - col < self.values.count, + col < self.activity.values.count, let weekStart = self.series.weekStartDate(at: col) { let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) @@ -529,7 +648,9 @@ private struct SpendActivityWeekGrid: View { tooltipHeight: SpendActivityGridGeometry.tooltipHeight, gridHeight: size.height) SpendActivityTooltip( - title: UsageFormatter.tokenCountString(self.values[col]), + title: self.activity.isCovered[col] + ? UsageFormatter.tokenCountString(self.activity.values[col]) + : L("Unavailable"), subtitle: SpendActivityDateFormatting.mediumDateString(weekStart), width: width) .position( @@ -555,12 +676,24 @@ private struct SpendActivityWeekGrid: View { } private var accessibilityTokenTotal: Int { - if self.cumulative { return self.values.last ?? 0 } - return self.values.reduce(0) { total, value in + if self.cumulative { return self.activity.values.last ?? 0 } + return self.activity.values.reduce(0) { total, value in let result = total.addingReportingOverflow(value) return result.overflow ? Int.max : result.partialValue } } + + private var accessibilityValue: String { + let total = UsageFormatter.tokenCountString(self.accessibilityTokenTotal) + let hasUnavailable = self.activity.isCovered.enumerated().contains { index, covered in + self.isVisible(index) && !covered + } + guard hasUnavailable else { return total } + let coverage = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + return "\(total) · \(coverage)" + } } private struct SpendActivityTooltip: View { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 2fc2a9d61f..00a06b1eb0 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -63,7 +63,9 @@ struct SpendDashboardModel: Equatable, Sendable { struct TokenActivityPoint: Identifiable, Equatable, Sendable { let day: Date - let totalTokens: Int + /// `nil` means at least one included source cannot establish coverage for this day. + /// This must stay distinct from a proven zero so the heatmap does not fabricate inactivity. + let totalTokens: Int? var id: Date { self.day @@ -95,6 +97,8 @@ struct SpendDashboardModel: Equatable, Sendable { let groups: [CurrencyGroup] let tokenActivity: [TokenActivityPoint] + static let tokenActivityDayCount = 365 + init( requestedDays: Int, groups: [CurrencyGroup], @@ -204,6 +208,25 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowed = false } + private struct TokenActivityInputSummary { + let coveredInterval: ClosedRange? + let totalsByDay: [Date: Int] + let invalidDays: Set + let hasCompleteHistory: Bool + let isGloballyInvalid: Bool + + func tokens(on day: Date) -> Int? { + guard self.coveredInterval?.contains(day) == true, + !self.isGloballyInvalid, + !self.invalidDays.contains(day) + else { return nil } + if let tokens = self.totalsByDay[day] { + return tokens + } + return self.hasCompleteHistory ? 0 : nil + } + } + private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], @@ -573,24 +596,68 @@ struct SpendDashboardModel: Equatable, Sendable { now: Date, calendar: Calendar) -> [TokenActivityPoint] { - let bounds = Self.bounds(days: 365, now: now, calendar: calendar) - var totals: [Date: Int] = [:] - for input in inputs { - for entry in input.snapshot.daily { - guard let day = Self.day( - entry.date, - provider: input.provider, - displayCalendar: calendar), - bounds.contains(day), - let tokens = Self.nonnegative(entry.totalTokens) - else { continue } - let addition = (totals[day] ?? 0).addingReportingOverflow(tokens) - totals[day] = addition.overflow ? Int.max : addition.partialValue + guard !inputs.isEmpty else { return [] } + let bounds = Self.bounds(days: Self.tokenActivityDayCount, now: now, calendar: calendar) + let summaries = inputs.map { + Self.tokenActivityInputSummary(input: $0, bounds: bounds, calendar: calendar) + } + return (0.., + calendar: Calendar) -> TokenActivityInputSummary + { + let coveredInterval = Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) + var totalsByDay: [Date: Int] = [:] + var invalidDays: Set = [] + var hasUnplacedTokens = false + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { + hasUnplacedTokens = hasUnplacedTokens || !Self.hasProvenZeroTokens(entry) + continue + } + guard sourceCoverage.contains(day) else { continue } + guard let tokens = Self.nonnegative(entry.totalTokens) else { + invalidDays.insert(day) + continue + } + guard !invalidDays.contains(day) else { continue } + let addition = (totalsByDay[day] ?? 0).addingReportingOverflow(tokens) + if addition.overflow { + totalsByDay.removeValue(forKey: day) + invalidDays.insert(day) + } else { + totalsByDay[day] = addition.partialValue + } } + + let hasCompleteHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory + return TokenActivityInputSummary( + coveredInterval: coveredInterval, + totalsByDay: totalsByDay, + invalidDays: invalidDays, + hasCompleteHistory: hasCompleteHistory, + isGloballyInvalid: hasUnplacedTokens || aggregateIsInconsistent) } private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 2f007f6d42..6f51c5f95e 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -36,41 +36,127 @@ struct SpendActivityHeatmapTests { calendar: calendar) #expect(series.daily.count == 52 * 7) + #expect(series.isCovered.count == 52 * 7) #expect(calendar.component(.weekday, from: series.start) == 1) #expect(series.daily.reduce(0, +) == 30) + #expect(series.coveredDayCount == 2) #expect(series.date(at: series.daily.count - 1)! > series.today) } @Test - func `token activity spans a year without widening the spend chart`() throws { + func `mixed provider activity marks days outside common coverage unavailable`() throws { let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) let oldDay = "2025-08-01" - let first = Self.snapshot(entries: [ - Self.entry(day: oldDay, cost: 2, tokens: 40), - Self.entry(day: "2026-07-16", cost: 3, tokens: 10), - Self.entry(day: "2026-08-01", cost: 4, tokens: 100), - ]) - let second = Self.snapshot(entries: [ - Self.entry(day: oldDay, cost: 1, tokens: 60), - Self.entry(day: "2025-07-15", cost: 1, tokens: 200), - Self.entry(day: "invalid", cost: 1, tokens: 300), - Self.entry(day: "2026-07-15", cost: 1, tokens: -1), - ]) + let annual = Self.snapshot( + entries: [ + Self.entry(day: oldDay, cost: 2, tokens: 40), + Self.entry(day: "2026-07-16", cost: 3, tokens: 10), + ], + historyDays: 365, + last30DaysTokens: 50) + let recent = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 60), + ], + historyDays: 30, + last30DaysTokens: 60) let model = SpendDashboardModel.build( inputs: [ - .init(id: "first", provider: .claude, displayName: "Claude", snapshot: first), - .init(id: "second", provider: .openai, displayName: "OpenAI", snapshot: second), + .init(id: "annual", provider: .claude, displayName: "Claude", snapshot: annual), + .init(id: "recent", provider: .openai, displayName: "OpenAI", snapshot: recent), ], requestedDays: 30, now: now, calendar: Self.calendar) let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) - #expect(model.tokenActivity == [ - .init(day: oldDate, totalTokens: 100), - .init(day: now, totalTokens: 10), - ]) - #expect(model.groups.first?.dailyPoints.map(\.day) == [now]) + #expect(model.tokenActivity.count == SpendDashboardModel.tokenActivityDayCount) + #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == nil) + #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 70) + #expect(model.groups.first?.dailyPoints.allSatisfy { $0.day == now } == true) + } + + @Test + func `covered empty history is zero while unestablished history remains unavailable`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let covered = SpendDashboardModel.build( + inputs: [ + .init( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(entries: [], historyDays: 365, last30DaysTokens: 0)), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + let unavailable = SpendDashboardModel.build( + inputs: [ + .init( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + entries: [], + historyDays: 365, + last30DaysTokens: nil, + historyCoverageIsEstablished: false)), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(covered.tokenActivity.allSatisfy { $0.totalTokens == 0 }) + #expect(unavailable.tokenActivity.allSatisfy { $0.totalTokens == nil }) + } + + @Test + func `weekly and cumulative activity preserve unavailable coverage`() throws { + let start = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 5))) + let today = try #require(Self.calendar.date(byAdding: .day, value: 13, to: start)) + var covered = [Bool](repeating: true, count: SpendActivitySeries.weekCount * 7) + covered[2] = false + let series = SpendActivitySeries( + daily: [Int](repeating: 1, count: covered.count), + isCovered: covered, + start: start, + today: today, + calendar: Self.calendar) + + let weekly = series.weeklyActivity() + #expect(weekly.values.prefix(2) == [7, 7]) + #expect(weekly.isCovered.prefix(2) == [false, true]) + #expect(weekly.cumulative().isCovered.prefix(2) == [false, false]) + } + + @Test + func `annual aggregation output stays fixed with many full year providers`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let formatter = DateFormatter() + formatter.calendar = Self.calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let entries = try (0.. CostUsageTokenSnapshot { + private static func snapshot( + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 365, + last30DaysTokens: Int?, + historyCoverageIsEstablished: Bool = true) -> CostUsageTokenSnapshot + { CostUsageTokenSnapshot( sessionTokens: nil, sessionCostUSD: nil, - last30DaysTokens: nil, + last30DaysTokens: last30DaysTokens, last30DaysCostUSD: nil, currencyCode: "USD", - historyDays: 365, + historyDays: historyDays, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: entries, updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) } From 7a52015f51ed58a25dca0bbdeffd3fe022e22ee4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:10:09 +0800 Subject: [PATCH 3/8] fix: complete activity heatmap navigation --- Sources/CodexBar/SpendActivityHeatmap.swift | 137 ++++++++++++++++-- .../SpendActivityHeatmapTests.swift | 40 ++++- 2 files changed, 159 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index f144896bdb..c75039682a 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -21,12 +21,14 @@ enum SpendActivityViewMode: String, CaseIterable, Identifiable { } struct SpendActivitySeries { - static let weekCount = 52 + static let weekCount = 53 static let dayCount = 7 + static let rangeDayCount = 365 let daily: [Int] let isCovered: [Bool] let start: Date + let rangeStart: Date let today: Date let calendar: Calendar @@ -49,24 +51,35 @@ struct SpendActivitySeries { } let today = calendar.startOfDay(for: now) - let weekday = calendar.component(.weekday, from: today) - let thisWeekSunday = calendar.date(byAdding: .day, value: -(weekday - 1), to: today) ?? today + let rangeStart = calendar.date( + byAdding: .day, + value: -(Self.rangeDayCount - 1), + to: today) ?? today + let rangeStartWeekday = calendar.component(.weekday, from: rangeStart) let start = calendar.date( - byAdding: .weekOfYear, - value: -(Self.weekCount - 1), - to: thisWeekSunday) ?? thisWeekSunday + byAdding: .day, + value: -(rangeStartWeekday - 1), + to: rangeStart) ?? rangeStart let cellCount = Self.weekCount * Self.dayCount var daily = [Int](repeating: 0, count: cellCount) var isCovered = [Bool](repeating: false, count: cellCount) for index in 0.. Date? { @@ -101,9 +114,9 @@ struct SpendActivitySeries { return SpendActivityAggregateSeries(values: values, isCovered: coverage) } - private func isVisible(_ index: Int) -> Bool { + func isVisible(_ index: Int) -> Bool { guard let date = self.date(at: index) else { return false } - return date <= self.today + return self.rangeStart...self.today ~= date } static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { @@ -223,6 +236,32 @@ struct SpendActivityGridGeometry { } } +enum SpendActivityGridMove { + case left + case right + case up + case down +} + +enum SpendActivityGridNavigation { + static func candidate(from current: Int, move: SpendActivityGridMove, rows: Int) -> Int? { + guard rows > 0 else { return nil } + let row = current % rows + return switch move { + case .left: + current - rows + case .right: + current + rows + case .up where row > 0: + current - 1 + case .down where row < rows - 1: + current + 1 + default: + nil + } + } +} + enum SpendActivityWeekday { static let labeledRows = [1, 3, 5] @@ -384,6 +423,8 @@ private struct SpendActivityDailyGrid: View { let series: SpendActivitySeries @State private var hoveredIndex: Int? + @State private var keyboardIndex: Int? + @FocusState private var isKeyboardFocused: Bool private let columns = SpendActivitySeries.weekCount private let rows = SpendActivitySeries.dayCount @@ -435,9 +476,27 @@ private struct SpendActivityDailyGrid: View { } .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) } + .focusable() + .focused(self.$isKeyboardFocused) + .onMoveCommand(perform: self.moveKeyboardSelection) + .onChange(of: self.isKeyboardFocused) { _, isFocused in + if isFocused, self.keyboardIndex == nil { + self.keyboardIndex = self.lastVisibleIndex + } + } .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + self.moveKeyboardSelectionChronologically(by: 1) + case .decrement: + self.moveKeyboardSelectionChronologically(by: -1) + @unknown default: + break + } + } } private var monthRow: some View { @@ -471,7 +530,7 @@ private struct SpendActivityDailyGrid: View { @ViewBuilder private func hoverHighlight(cell: CGFloat, pitch: CGFloat) -> some View { - if let index = self.hoveredIndex { + if let index = self.activeIndex { let col = index / self.rows let row = index % self.rows RoundedRectangle(cornerRadius: min(cell * 0.22, 2.5), style: .continuous) @@ -486,7 +545,7 @@ private struct SpendActivityDailyGrid: View { @ViewBuilder private func tooltip(size: CGSize, pitch: CGFloat) -> some View { - if let index = self.hoveredIndex, let date = self.series.date(at: index) { + if let index = self.activeIndex, let date = self.series.date(at: index) { let col = index / self.rows let row = index % self.rows let anchorX = CGFloat(col) * pitch + pitch / 2 @@ -522,8 +581,46 @@ private struct SpendActivityDailyGrid: View { } private func isVisibleCell(_ index: Int) -> Bool { - guard let date = self.series.date(at: index) else { return false } - return date <= self.series.today + self.series.isVisible(index) + } + + private var activeIndex: Int? { + if let hoveredIndex { return hoveredIndex } + return self.keyboardIndex + } + + private var lastVisibleIndex: Int? { + self.series.daily.indices.last(where: self.series.isVisible) + } + + private func moveKeyboardSelection(_ direction: MoveCommandDirection) { + guard let current = self.keyboardIndex ?? self.lastVisibleIndex else { return } + let move: SpendActivityGridMove? = switch direction { + case .left: + .left + case .right: + .right + case .up: + .up + case .down: + .down + default: + nil + } + guard let move else { return } + let candidate = SpendActivityGridNavigation.candidate(from: current, move: move, rows: self.rows) + guard let candidate, self.isVisibleCell(candidate) else { return } + self.keyboardIndex = candidate + } + + private func moveKeyboardSelectionChronologically(by offset: Int) { + guard let current = self.keyboardIndex else { + self.keyboardIndex = self.lastVisibleIndex + return + } + let candidate = current + offset + guard self.isVisibleCell(candidate) else { return } + self.keyboardIndex = candidate } private struct MonthMarker: Identifiable { @@ -558,6 +655,12 @@ private struct SpendActivityDailyGrid: View { } private var accessibilityValue: String { + if let index = self.activeIndex, let date = self.series.date(at: index) { + let value = self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable") + return "\(SpendActivityDateFormatting.mediumDateString(date)): \(value)" + } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } let coverage = spendDashboardCoverageText( @@ -672,7 +775,11 @@ private struct SpendActivityWeekGrid: View { private func isVisible(_ column: Int) -> Bool { guard let start = self.series.weekStartDate(at: column) else { return false } - return start <= self.series.today + let end = self.series.calendar.date( + byAdding: .day, + value: SpendActivitySeries.dayCount - 1, + to: start) ?? start + return start <= self.series.today && end >= self.series.rangeStart } private var accessibilityTokenTotal: Int { diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 6f51c5f95e..7d7932cecb 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -21,7 +21,7 @@ struct SpendActivityHeatmapTests { } @Test - func `series uses a fixed Sunday first 52 week window`() throws { + func `series uses a Sunday aligned 53 week container for exactly 365 visible days`() throws { let calendar = Self.calendar let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 5))) let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) @@ -35,14 +35,37 @@ struct SpendActivityHeatmapTests { now: now, calendar: calendar) - #expect(series.daily.count == 52 * 7) - #expect(series.isCovered.count == 52 * 7) + #expect(series.daily.count == 53 * 7) + #expect(series.isCovered.count == 53 * 7) #expect(calendar.component(.weekday, from: series.start) == 1) + #expect(series.visibleDayCount == 365) #expect(series.daily.reduce(0, +) == 30) #expect(series.coveredDayCount == 2) #expect(series.date(at: series.daily.count - 1)! > series.today) } + @Test(arguments: Array(2...8)) + func `oldest annual day remains visible for every ending weekday`(augustDay: Int) throws { + let calendar = Self.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: augustDay))) + let rangeStart = try #require(calendar.date( + byAdding: .day, + value: -(SpendActivitySeries.rangeDayCount - 1), + to: now)) + let points = try (0.. Date: Sat, 1 Aug 2026 21:23:26 +0800 Subject: [PATCH 4/8] fix: expose daily activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index c75039682a..db169a47e4 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -484,9 +484,19 @@ private struct SpendActivityDailyGrid: View { self.keyboardIndex = self.lastVisibleIndex } } - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityChildren { + ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in + if let date = self.series.date(at: index) { + Color.clear + .accessibilityElement() + .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) + .accessibilityValue(self.accessibilityTokenValue(at: index)) + } + } + } .accessibilityAdjustableAction { direction in switch direction { case .increment: @@ -594,7 +604,9 @@ private struct SpendActivityDailyGrid: View { } private func moveKeyboardSelection(_ direction: MoveCommandDirection) { + self.hoveredIndex = nil guard let current = self.keyboardIndex ?? self.lastVisibleIndex else { return } + self.keyboardIndex = current let move: SpendActivityGridMove? = switch direction { case .left: .left @@ -614,6 +626,7 @@ private struct SpendActivityDailyGrid: View { } private func moveKeyboardSelectionChronologically(by offset: Int) { + self.hoveredIndex = nil guard let current = self.keyboardIndex else { self.keyboardIndex = self.lastVisibleIndex return @@ -656,10 +669,7 @@ private struct SpendActivityDailyGrid: View { private var accessibilityValue: String { if let index = self.activeIndex, let date = self.series.date(at: index) { - let value = self.series.isCovered[index] - ? UsageFormatter.tokenCountString(self.series.daily[index]) - : L("Unavailable") - return "\(SpendActivityDateFormatting.mediumDateString(date)): \(value)" + return "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } @@ -668,6 +678,12 @@ private struct SpendActivityDailyGrid: View { requested: self.series.visibleDayCount) return "\(total) · \(coverage)" } + + private func accessibilityTokenValue(at index: Int) -> String { + self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable") + } } private struct SpendActivityWeekGrid: View { From 5a085f80a1d448ed977164a914b3ce3c6647468e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:38:24 +0800 Subject: [PATCH 5/8] fix: publish daily accessibility semantics --- Sources/CodexBar/SpendActivityHeatmap.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index db169a47e4..9b07ebe42e 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -490,8 +490,7 @@ private struct SpendActivityDailyGrid: View { .accessibilityChildren { ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in if let date = self.series.date(at: index) { - Color.clear - .accessibilityElement() + Text(self.accessibilityTokenValue(at: index)) .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) .accessibilityValue(self.accessibilityTokenValue(at: index)) } From 8cebcbce00860e8aa36b4687c7cc813d6afebfd0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:17 +0800 Subject: [PATCH 6/8] fix: expose dates in activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 9b07ebe42e..86f71ef9e4 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -490,9 +490,7 @@ private struct SpendActivityDailyGrid: View { .accessibilityChildren { ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in if let date = self.series.date(at: index) { - Text(self.accessibilityTokenValue(at: index)) - .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) - .accessibilityValue(self.accessibilityTokenValue(at: index)) + Text(self.accessibilityDescription(at: index, date: date)) } } } @@ -668,7 +666,7 @@ private struct SpendActivityDailyGrid: View { private var accessibilityValue: String { if let index = self.activeIndex, let date = self.series.date(at: index) { - return "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" + return self.accessibilityDescription(at: index, date: date) } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } @@ -678,6 +676,10 @@ private struct SpendActivityDailyGrid: View { return "\(total) · \(coverage)" } + private func accessibilityDescription(at index: Int, date: Date) -> String { + "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" + } + private func accessibilityTokenValue(at index: Int) -> String { self.series.isCovered[index] ? UsageFormatter.tokenCountString(self.series.daily[index]) From 5290ca5b001619fc3c5dc078869219457fbc320b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:55:59 +0800 Subject: [PATCH 7/8] fix: expose weekly activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 86f71ef9e4..3febded346 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -750,9 +750,16 @@ private struct SpendActivityWeekGrid: View { .offset(x: gridFrame.minX) } .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityChildren { + ForEach(self.activity.values.indices.filter(self.isVisible), id: \.self) { index in + if let weekStart = self.series.weekStartDate(at: index) { + Text(self.accessibilityDescription(at: index, weekStart: weekStart)) + } + } + } } @ViewBuilder @@ -818,6 +825,13 @@ private struct SpendActivityWeekGrid: View { requested: self.series.visibleDayCount) return "\(total) · \(coverage)" } + + private func accessibilityDescription(at index: Int, weekStart: Date) -> String { + let value = self.activity.isCovered[index] + ? UsageFormatter.tokenCountString(self.activity.values[index]) + : L("Unavailable") + return "\(SpendActivityDateFormatting.mediumDateString(weekStart)): \(value)" + } } private struct SpendActivityTooltip: View { From 0eb7afec79240cd4bbf45d887c70d808c87ac59e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 13:59:34 -0700 Subject: [PATCH 8/8] feat: derive token activity from shared cache --- CHANGELOG.md | 3 + Sources/CodexBar/SpendActivityHeatmap.swift | 30 +++++-- .../CodexBar/SpendDashboardController.swift | 41 ++++++++-- Sources/CodexBar/SpendDashboardModel.swift | 41 ++++++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 76 +++++++++++++++++ Sources/CodexBarCore/CostUsageModels.swift | 16 ++++ .../CostUsageFetcherCacheSnapshotTests.swift | 74 +++++++++++++++++ .../SpendActivityHeatmapTests.swift | 81 ++++++++++++++++++- .../SpendDashboardControllerTests.swift | 2 +- .../SpendDashboardModelTests.swift | 2 +- 10 files changed, 344 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1b8fb424..9b46113116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.47.1 — Unreleased +### Added +- Usage & Spend: add an accessible daily, weekly, and cumulative token-activity heatmap backed by the existing persistent cost scan cache (#2548). Thanks @Yuxin-Qiao! + ### Changed - Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029! diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 3febded346..7b6325819d 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -150,9 +150,15 @@ enum SpendActivityLevels { return values.map { value in guard value > 0, maxValue > 0 else { return 0 } let ratio = Double(value) / Double(maxValue) - if ratio > 0.75 { return 4 } - if ratio > 0.5 { return 3 } - if ratio > 0.25 { return 2 } + if ratio > 0.75 { + return 4 + } + if ratio > 0.5 { + return 3 + } + if ratio > 0.25 { + return 2 + } return 1 } } @@ -286,6 +292,12 @@ enum SpendActivityDateFormatting { } } +enum SpendActivityAccessibility { + static func description(date: Date, value: String, locale: Locale? = nil) -> String { + "\(SpendActivityDateFormatting.mediumDateString(date, locale: locale)): \(value)" + } +} + struct SpendActivityHeatmapView: View { let points: [SpendDashboardModel.TokenActivityPoint] let now: Date @@ -592,7 +604,9 @@ private struct SpendActivityDailyGrid: View { } private var activeIndex: Int? { - if let hoveredIndex { return hoveredIndex } + if let hoveredIndex { + return hoveredIndex + } return self.keyboardIndex } @@ -677,7 +691,7 @@ private struct SpendActivityDailyGrid: View { } private func accessibilityDescription(at index: Int, date: Date) -> String { - "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" + SpendActivityAccessibility.description(date: date, value: self.accessibilityTokenValue(at: index)) } private func accessibilityTokenValue(at index: Int) -> String { @@ -807,7 +821,9 @@ private struct SpendActivityWeekGrid: View { } private var accessibilityTokenTotal: Int { - if self.cumulative { return self.activity.values.last ?? 0 } + if self.cumulative { + return self.activity.values.last ?? 0 + } return self.activity.values.reduce(0) { total, value in let result = total.addingReportingOverflow(value) return result.overflow ? Int.max : result.partialValue @@ -830,7 +846,7 @@ private struct SpendActivityWeekGrid: View { let value = self.activity.isCovered[index] ? UsageFormatter.tokenCountString(self.activity.values[index]) : L("Unavailable") - return "\(SpendActivityDateFormatting.mediumDateString(weekStart)): \(value)" + return SpendActivityAccessibility.description(date: weekStart, value: value) } } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a50bc4dd75..da85f3b114 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -120,8 +120,11 @@ struct CodexSpendSnapshotLoadContext: Sendable { enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias CodexActivityLoader = @Sendable (CodexSpendSnapshotLoadContext) async + -> CostUsageTokenActivityCache? - static let scanDays = 365 + static let scanDays = 30 + static let activityDays = 365 @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -250,14 +253,23 @@ enum SpendDashboardSource { } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + await self.load( + request, + codexSnapshotLoader: { context in try await self.loadCodexSnapshot(context) }, + codexActivityLoader: { context in await self.loadCodexActivity(context) }) } static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + { + await self.load(request, codexSnapshotLoader: codexSnapshotLoader, codexActivityLoader: { _ in nil }) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: CodexSnapshotLoader, + codexActivityLoader: CodexActivityLoader) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -280,6 +292,15 @@ enum SpendDashboardSource { refreshPricingInBackground: false, includePiSessions: false)) try Task.checkCancellation() + let tokenActivityCache = await codexActivityLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRoot, + now: request.now, + force: false, + historyDays: Self.activityDays, + refreshPricingInBackground: false, + includePiSessions: false)) + try Task.checkCancellation() guard self.codexAuthFingerprintMatches(account) else { failedSourceIDs.insert(sourceID) invalidatedSourceIDs.insert(sourceID) @@ -290,7 +311,8 @@ enum SpendDashboardSource { provider: .codex, displayName: account.displayName, modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, - snapshot: snapshot)) + snapshot: snapshot, + tokenActivityCache: tokenActivityCache)) } catch is CancellationError { failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) return SpendDashboardLoadResult( @@ -329,6 +351,15 @@ enum SpendDashboardSource { includePiSessions: context.includePiSessions) } + private static func loadCodexActivity( + _ context: CodexSpendSnapshotLoadContext) async -> CostUsageTokenActivityCache? + { + await CostUsageFetcher(cacheRoot: context.cacheRoot).loadCachedCodexTokenActivity( + now: context.now, + codexHomePath: context.account.homePath, + maximumDays: context.historyDays) + } + @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 00a06b1eb0..2a0fc09f79 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -8,19 +8,22 @@ struct SpendDashboardModel: Equatable, Sendable { let displayName: String let modelProviderName: String let snapshot: CostUsageTokenSnapshot + let tokenActivityCache: CostUsageTokenActivityCache? init( id: String? = nil, provider: UsageProvider, displayName: String, modelProviderName: String? = nil, - snapshot: CostUsageTokenSnapshot) + snapshot: CostUsageTokenSnapshot, + tokenActivityCache: CostUsageTokenActivityCache? = nil) { self.id = id ?? provider.rawValue self.provider = provider self.displayName = displayName self.modelProviderName = modelProviderName ?? displayName self.snapshot = snapshot + self.tokenActivityCache = tokenActivityCache } } @@ -622,20 +625,19 @@ struct SpendDashboardModel: Equatable, Sendable { bounds: ClosedRange, calendar: Calendar) -> TokenActivityInputSummary { - let coveredInterval = Self.coverageInterval( + let coveredInterval = Self.tokenActivityCoverageInterval( input: input, bounds: bounds, displayCalendar: calendar) - let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) var totalsByDay: [Date: Int] = [:] var invalidDays: Set = [] var hasUnplacedTokens = false - for entry in input.snapshot.daily { + for entry in input.tokenActivityCache?.daily ?? input.snapshot.daily { guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { hasUnplacedTokens = hasUnplacedTokens || !Self.hasProvenZeroTokens(entry) continue } - guard sourceCoverage.contains(day) else { continue } + guard coveredInterval?.contains(day) == true else { continue } guard let tokens = Self.nonnegative(entry.totalTokens) else { invalidDays.insert(day) continue @@ -650,8 +652,11 @@ struct SpendDashboardModel: Equatable, Sendable { } } - let hasCompleteHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) - let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory + let hasCompleteHistory = input.tokenActivityCache != nil + || Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let aggregateIsInconsistent = input.tokenActivityCache == nil + && input.snapshot.last30DaysTokens != nil + && !hasCompleteHistory return TokenActivityInputSummary( coveredInterval: coveredInterval, totalsByDay: totalsByDay, @@ -660,6 +665,28 @@ struct SpendDashboardModel: Equatable, Sendable { isGloballyInvalid: hasUnplacedTokens || aggregateIsInconsistent) } + private static func tokenActivityCoverageInterval( + input: ProviderInput, + bounds: ClosedRange, + displayCalendar: Calendar) -> ClosedRange? + { + guard let cache = input.tokenActivityCache else { + return self.coverageInterval(input: input, bounds: bounds, displayCalendar: displayCalendar) + } + guard let start = Self.day( + cache.coverageSinceKey, + provider: input.provider, + displayCalendar: displayCalendar), + let end = Self.day( + cache.coverageUntilKey, + provider: input.provider, + displayCalendar: displayCalendar) + else { return nil } + let overlapStart = max(bounds.lowerBound, start) + let overlapEnd = min(bounds.upperBound, end) + return overlapStart <= overlapEnd ? overlapStart...overlapEnd : nil + } + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e15afb6d77..50c4e5df81 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -86,6 +86,18 @@ public struct CostUsageFetcher: Sendable { scannerOptions: self.scannerOptionsOverride()) } + package func loadCachedCodexTokenActivity( + now: Date = Date(), + codexHomePath: String? = nil, + maximumDays: Int = 365) async -> CostUsageTokenActivityCache? + { + await Self.loadCachedCodexTokenActivity( + now: now, + codexHomePath: codexHomePath, + maximumDays: maximumDays, + scannerOptions: self.scannerOptionsOverride()) + } + package func loadCachedCodexTokenSnapshotResult( now: Date = Date(), codexHomePath: String? = nil, @@ -663,6 +675,70 @@ public struct CostUsageFetcher: Sendable { scannerOptions: overrideScannerOptions)?.snapshot } + static func loadCachedCodexTokenActivity( + now: Date = Date(), + codexHomePath: String? = nil, + maximumDays: Int = 365, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async + -> CostUsageTokenActivityCache? + { + let cachedActivity: CostUsageTokenActivityCache?? = try? await CostUsageScanExecutor.run { _ in + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: codexHomePath) + let days = max(1, min(365, maximumDays)) + let since = options.calendar.date(byAdding: .day, value: -(days - 1), to: now) ?? now + let requestedRange = CostUsageScanner.CostUsageDayRange( + since: since, + until: now, + calendar: options.calendar) + let roots = CostUsageScanner.codexSessionsRoots(options: options) + let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) + let cache = CostUsageScanner.codexCache( + CostUsageCacheIO.loadCodexForMigration( + cacheRoot: options.cacheRoot, + calendar: options.calendar).cache, + scopedTo: roots) + guard cache.timeZoneIdentifier == options.calendar.timeZone.identifier, + cache.roots == rootsFingerprint, + cache.codexScanCatchUpPending != true, + !cache.files.values.contains(where: { $0.codexScanComplete == false }), + let cachedSince = cache.scanSinceKey, + let cachedUntil = cache.scanUntilKey + else { return nil } + + let coverageSince = max(cachedSince, requestedRange.scanSinceKey) + let coverageUntil = min(cachedUntil, requestedRange.scanUntilKey) + guard coverageSince <= coverageUntil else { return nil } + let daily = cache.days.keys + .filter { $0 >= coverageSince && $0 <= coverageUntil } + .sorted() + .map { day -> CostUsageDailyReport.Entry in + var total = 0 + for packed in cache.days[day, default: [:]].values { + for value in [packed[safe: 0] ?? 0, packed[safe: 2] ?? 0] { + let addition = total.addingReportingOverflow(max(0, value)) + total = addition.overflow ? Int.max : addition.partialValue + } + } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: total, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil) + } + return CostUsageTokenActivityCache( + daily: daily, + coverageSinceKey: coverageSince, + coverageUntilKey: coverageUntil) + } + return cachedActivity.flatMap(\.self) + } + static func loadCachedCodexTokenSnapshotResult( now: Date = Date(), codexHomePath: String? = nil, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 88f2760bbb..322f7d59f5 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,21 @@ import Foundation +package struct CostUsageTokenActivityCache: Sendable, Equatable { + package let daily: [CostUsageDailyReport.Entry] + package let coverageSinceKey: String + package let coverageUntilKey: String + + package init( + daily: [CostUsageDailyReport.Entry], + coverageSinceKey: String, + coverageUntilKey: String) + { + self.daily = daily + self.coverageSinceKey = coverageSinceKey + self.coverageUntilKey = coverageUntilKey + } +} + public struct CostUsageWindowSummary: Sendable, Equatable { public let days: Int public let totalTokens: Int? diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 685fea2a03..90ba4ae090 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -3,6 +3,80 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherCacheSnapshotTests { + @Test + func `cached token activity derives buckets and partial coverage from the shared scan cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + var cache = CostUsageCache() + cache.scanSinceKey = "2026-04-06" + cache.scanUntilKey = "2026-04-08" + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + let fixtureDays = [ + "2026-04-06": ["gpt-5.4": [10, 4, 2]], + "2026-04-08": [ + "gpt-5.4": [20, 7, 3], + "gpt-5.3-codex": [5, 1, 1], + ], + ] + cache.files[env.codexSessionsRoot.appendingPathComponent("fixture.jsonl").path] = + CostUsageScanner.makeFileUsage( + mtimeUnixMs: Int64(now.timeIntervalSince1970 * 1000), + size: 1, + days: fixtureDays, + parsedBytes: 1, + codexScanComplete: true) + cache.days = fixtureDays + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: env.cacheRoot, + calendar: options.calendar) + + let activity = await CostUsageFetcher.loadCachedCodexTokenActivity( + now: now, + maximumDays: 365, + scannerOptions: options) + + #expect(activity?.coverageSinceKey == "2026-04-06") + #expect(activity?.coverageUntilKey == "2026-04-08") + #expect(activity?.daily.map(\.date) == ["2026-04-06", "2026-04-08"]) + #expect(activity?.daily.map(\.totalTokens) == [12, 29]) + } + + @Test + func `empty shared scan cache preserves established coverage`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + var cache = CostUsageCache() + cache.scanSinceKey = "2026-04-08" + cache.scanUntilKey = "2026-04-08" + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: env.cacheRoot, + calendar: options.calendar) + + let activity = await CostUsageFetcher.loadCachedCodexTokenActivity( + now: now, + maximumDays: 365, + scannerOptions: options) + + #expect(activity?.coverageSinceKey == "2026-04-08") + #expect(activity?.coverageUntilKey == "2026-04-08") + #expect(activity?.daily.isEmpty == true) + } + @Test func `cached codex token snapshot loads from existing cache without rescanning`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 7d7932cecb..bf290f7017 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -131,6 +131,67 @@ struct SpendActivityHeatmapTests { #expect(unavailable.tokenActivity.allSatisfy { $0.totalTokens == nil }) } + @Test + func `shared cache activity uses its wider partial coverage without widening spend`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let oldDay = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 5, day: 1))) + let snapshot = Self.snapshot( + entries: [Self.entry(day: "2026-07-16", cost: 2, tokens: 10)], + historyDays: 30, + last30DaysTokens: 10) + let cache = CostUsageTokenActivityCache( + daily: [ + Self.entry(day: "2026-05-01", cost: nil, tokens: 40), + Self.entry(day: "2026-07-16", cost: nil, tokens: 10), + ], + coverageSinceKey: "2026-05-01", + coverageUntilKey: "2026-07-16") + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: snapshot, + tokenActivityCache: cache), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(model.requestedDays == 30) + #expect(model.groups.first?.dailyPoints.map(\.day) == [now]) + #expect(model.tokenActivity.first { $0.day == oldDay }?.totalTokens == 40) + } + + @Test + func `empty shared cache marks only its partial range as confirmed zero`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let beforeCoverage = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 7, day: 13))) + let cache = CostUsageTokenActivityCache( + daily: [], + coverageSinceKey: "2026-07-14", + coverageUntilKey: "2026-07-16") + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot( + entries: [], + historyDays: 30, + last30DaysTokens: nil, + historyCoverageIsEstablished: false), + tokenActivityCache: cache), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(model.tokenActivity.first { $0.day == beforeCoverage }?.totalTokens == nil) + #expect(model.tokenActivity.suffix(3).allSatisfy { $0.totalTokens == 0 }) + } + @Test func `weekly and cumulative activity preserve unavailable coverage`() throws { let start = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 5))) @@ -156,6 +217,7 @@ struct SpendActivityHeatmapTests { let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) let formatter = DateFormatter() formatter.calendar = Self.calendar + formatter.timeZone = Self.calendar.timeZone formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" let entries = try (0..