From 4d2944a46d83c5d501a057a3a92e9d11003f889e Mon Sep 17 00:00:00 2001 From: Dylan LE FLOUR Date: Fri, 18 Sep 2026 13:58:21 +0200 Subject: [PATCH 1/3] feat: add a usage recap for any week, month or year A recap screen opens from the week/month row on Home: one calendar week, month or year at a time, with Previous and Next to step back through time. The card shows the total, one meter per day (per month in the year view), the change against the previous period, best day, best streak, active days and the individuals graduated in that period. It copies to the clipboard or saves as a PNG; as with the rest of the app, nothing leaves the Mac. Providers only report the current month, so every refresh copies the daily totals into a local ledger in UserDefaults. A day never goes down, which survives rotated logs, and the ledger keeps this year and the last one so the year view has something to compare against. The ledger stores the first day it can vouch for, next to the rows. That is what tells "no usage" from "not recorded yet": days before it, and days still ahead, are drawn as unknown instead of as zeros, and a comparison is only shown when the previous period is covered from its first day. A period still running is compared over the same number of days, so the 18th of the month does not read as a drop against a whole month. The ledger is written after the enrichment phase, where the month series actually arrives; writing it after phase 1 left the first refresh empty. --- README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + Sources/PokeTokenBar/Core/Localization.swift | 53 ++ Sources/PokeTokenBar/Core/UsageRecap.swift | 233 +++++++++ Sources/PokeTokenBar/Core/UsageStore.swift | 22 + Sources/PokeTokenBar/UI/PopoverView.swift | 11 + Sources/PokeTokenBar/UI/UsageRecapView.swift | 476 ++++++++++++++++++ Tests/PokeTokenBarTests/UsageRecapTests.swift | 369 ++++++++++++++ docs/reference/defect-log.md | 20 + scripts/test-gate.sh | 1 + 11 files changed, 1188 insertions(+) create mode 100644 Sources/PokeTokenBar/Core/UsageRecap.swift create mode 100644 Sources/PokeTokenBar/UI/UsageRecapView.swift create mode 100644 Tests/PokeTokenBarTests/UsageRecapTests.swift diff --git a/README.ja.md b/README.ja.md index 64025481..b5138fab 100644 --- a/README.ja.md +++ b/README.ja.md @@ -167,6 +167,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ - **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。 - **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。 - **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。 +- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)、画像のコピーと保存に対応。記録は今年と昨年分をローカルに保存。 ## 対応ツール diff --git a/README.ko.md b/README.ko.md index 088b22ff..eab5a3e5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -167,6 +167,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다 - **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). - **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측. - **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시. +- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업), 이미지 복사·저장 지원. 기록은 올해와 작년분을 로컬에 보관. ## 지원 도구 diff --git a/README.md b/README.md index 1ca8ed26..fbc90cf3 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne - **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). - **Burn-rate forecast** — projects when the current 5h window hits 100%. - **In-app updates** — one-click update check; current version shown in Settings. +- **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations), with copy and save as PNG. History is kept locally for this year and the last. ## Works with diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index b15e3916..b660d2c5 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -165,6 +165,59 @@ struct L { var evolutionScrollNext: String { t("다음 진화 보기", "Show next evolutions", "次の進化を見る", "Ver evoluciones siguientes", "Voir les évolutions suivantes", "Ver próximas evoluções", "Nächste Entwicklungen anzeigen") } var back: String { t("뒤로", "Back", "戻る", "Atrás", "Retour", "Voltar", "Zurück") } + + // MARK: Usage recap + var recapOpen: String { t("사용량 돌아보기", "Usage recap", "使用量のふりかえり", "Resumen de uso", "Récap d'utilisation", "Resumo de uso", "Nutzungsrückblick") } + var recapTokensUnit: String { t("토큰", "tokens", "トークン", "tokens", "tokens", "tokens", "Tokens") } + func recapScopeName(_ scope: RecapScope) -> String { + switch scope { + case .week: t("주간", "Week", "週", "Semana", "Semaine", "Semana", "Woche") + case .month: t("월간", "Month", "月", "Mes", "Mois", "Mês", "Monat") + case .year: t("연간", "Year", "年", "Año", "Année", "Ano", "Jahr") + } + } + /// Shown under a running period, whose chip compares the same number of days. + func recapCompareSoFar(_ scope: RecapScope) -> String { + switch scope { + case .week: t("지난주 같은 기간과 비교", "Compared with the same days last week", "先週の同じ期間と比較", + "Comparado con los mismos días de la semana pasada", "Comparé aux mêmes jours de la semaine dernière", + "Comparado com os mesmos dias da semana passada", "Verglichen mit denselben Tagen der Vorwoche") + case .month: t("지난달 같은 기간과 비교", "Compared with the same days last month", "先月の同じ期間と比較", + "Comparado con los mismos días del mes pasado", "Comparé aux mêmes jours du mois dernier", + "Comparado com os mesmos dias do mês passado", "Verglichen mit denselben Tagen des Vormonats") + case .year: t("작년 같은 기간과 비교", "Compared with the same days last year", "昨年の同じ期間と比較", + "Comparado con los mismos días del año pasado", "Comparé aux mêmes jours de l'an dernier", + "Comparado com os mesmos dias do ano passado", "Verglichen mit denselben Tagen des Vorjahres") + } + } + var recapPrevious: String { t("이전", "Previous", "前へ", "Anterior", "Précédent", "Anterior", "Vorherige") } + var recapNext: String { t("다음", "Next", "次へ", "Siguiente", "Suivant", "Próximo", "Nächste") } + var recapBestDay: String { t("최고의 날", "Best day", "最高の日", "Mejor día", "Meilleur jour", "Melhor dia", "Bester Tag") } + var recapActiveDays: String { t("활동한 날", "Active days", "稼働日", "Días activos", "Jours actifs", "Dias ativos", "Aktive Tage") } + var recapBestStreak: String { t("최장 연속", "Best streak", "最長連続", "Mejor racha", "Meilleure série", "Melhor sequência", "Beste Serie") } + var recapGraduates: String { t("졸업", "Graduated", "卒業", "Graduados", "Diplômés", "Formados", "Abschlüsse") } + var recapNoData: String { t("기록 없음", "no data", "記録なし", "sin datos", "pas de données", "sem dados", "keine Daten") } + func recapDays(_ count: Int) -> String { + t("\(count)일", "\(count)d", "\(count)日", "\(count) d", "\(count) j", "\(count) d", "\(count) T") + } + func recapBestDayLine(_ day: String, _ tokens: String) -> String { + t("최고의 날은 \(day) — \(tokens) 토큰.", + "Your best day was \(day) — \(tokens) tokens.", + "最高の日は \(day)、\(tokens) トークン。", + "Tu mejor día fue el \(day): \(tokens) tokens.", + "Ton meilleur jour, c'était \(day) : \(tokens) tokens.", + "Seu melhor dia foi \(day): \(tokens) tokens.", + "Dein bester Tag war \(day) — \(tokens) Tokens.") + } + var recapNoGraduates: String { + t("이 기간에 졸업한 포켓몬이 없어요.", "No graduation in this period.", "この期間の卒業はありません。", + "Ninguna graduación en este periodo.", "Aucun diplômé sur cette période.", + "Nenhuma formatura neste período.", "Kein Abschluss in diesem Zeitraum.") + } + var recapCopy: String { t("이미지 복사", "Copy image", "画像をコピー", "Copiar imagen", "Copier l'image", "Copiar imagem", "Bild kopieren") } + var recapCopied: String { t("복사됨", "Copied", "コピーしました", "Copiado", "Copié", "Copiado", "Kopiert") } + var recapSave: String { t("저장…", "Save…", "保存…", "Guardar…", "Enregistrer…", "Salvar…", "Speichern…") } + var generalSectionTitle: String { t("일반", "General", "一般", "General", "Général", "Geral", "Allgemein") } var menuBarSectionTitle: String { t("메뉴바에 표시", "Show in menu bar", "メニューバーに表示", "Mostrar en la barra de menús", "Afficher dans la barre des menus", "Mostrar na barra de menus", "In der Menüleiste anzeigen") } var advancedSectionTitle: String { t("고급", "Advanced", "詳細", "Avanzado", "Avancé", "Avançado", "Erweitert") } diff --git a/Sources/PokeTokenBar/Core/UsageRecap.swift b/Sources/PokeTokenBar/Core/UsageRecap.swift new file mode 100644 index 00000000..52764796 --- /dev/null +++ b/Sources/PokeTokenBar/Core/UsageRecap.swift @@ -0,0 +1,233 @@ +import Foundation + +/// Day-by-day token totals kept across months, so a recap survives the 1st of the month. +/// +/// Providers only report the current month (`monthDaily`), so on the 3rd a seven-day window would +/// lose four days and a year would be out of reach. This ledger is a local cache of numbers the app +/// already derived — no new user state, nothing to transfer between devices, and it rebuilds itself +/// from the next refresh. +struct UsageLedger: Equatable, Sendable { + static let defaultsKey = "dailyTokenLedger" + static let coverageKey = "dailyTokenLedgerSince" + + private(set) var tokensByDay: [String: Int] = [:] + /// The first day the ledger vouches for. From there on a missing row means "no usage"; before it + /// the app was not recording, which a recap must not present as a quiet stretch. + private(set) var coveredSince: String? + + func tokens(on day: String) -> Int { tokensByDay[day] ?? 0 } + + func covers(_ day: String) -> Bool { coveredSince.map { day >= $0 } ?? false } + + /// Records a refresh. A day never goes down: a rotated or pruned log makes the provider report + /// less than it did yesterday, and the recap would lose days that really happened. Today keeps + /// climbing all day, so taking the larger of the two is also what the live total does. + mutating func merge(_ series: [DailyUsage]) { + for day in series where day.totalTokens > 0 { + tokensByDay[day.date] = max(tokensByDay[day.date] ?? 0, day.totalTokens) + } + // The series lists every day since the 1st, empty ones included, so the whole stretch was + // scanned: that is what makes a zero a real zero. + if let first = series.map(\.date).min(), coveredSince.map({ first < $0 }) ?? true { + coveredSince = first + } + } + + /// Drops what no recap can reach any more. Day keys are `yyyy-MM-dd`, which sorts + /// lexicographically the same way it sorts chronologically. + mutating func prune(before cutoff: String) { + tokensByDay = tokensByDay.filter { $0.key >= cutoff } + // The dropped days are unknown again, not empty. + if let since = coveredSince, since < cutoff { coveredSince = cutoff } + } + + static func load(from defaults: UserDefaults) -> UsageLedger { + var ledger = UsageLedger() + // Hand-edited defaults are data, not truth: keep what parses as a day and drop the rest. + // A junk key that sorted below every real day would otherwise backdate the coverage. + let formatter = LocalUsageReader.localDayFormatter() + if let stored = defaults.dictionary(forKey: defaultsKey) as? [String: Int] { + ledger.tokensByDay = stored.filter { formatter.date(from: $0.key) != nil && $0.value > 0 } + } + if let since = defaults.string(forKey: coverageKey), formatter.date(from: since) != nil { + ledger.coveredSince = since + } + return ledger + } + + func save(to defaults: UserDefaults) { + defaults.set(tokensByDay, forKey: Self.defaultsKey) + defaults.set(coveredSince, forKey: Self.coverageKey) + } +} + +enum RecapScope: String, CaseIterable, Sendable { + case week, month, year + + fileprivate var component: Calendar.Component { + switch self { + case .week: .weekOfYear + case .month: .month + case .year: .year + } + } +} + +/// One calendar week, month or year. Calendar periods rather than rolling windows, so stepping +/// back lands on "the week of the 7th", not on an arbitrary seven days. +struct RecapPeriod: Equatable, Sendable { + let scope: RecapScope + /// First instant of the period. + let start: Date + /// First instant after the period. + let end: Date + + init(scope: RecapScope, containing date: Date, calendar: Calendar) { + self.scope = scope + // API-forced unwrap: every Gregorian date sits in a week, a month and a year. + let interval = calendar.dateInterval(of: scope.component, for: date) + ?? DateInterval(start: calendar.startOfDay(for: date), duration: 86_400) + start = interval.start + end = interval.end + } + + func shifted(by offset: Int, calendar: Calendar) -> RecapPeriod { + let moved = calendar.date(byAdding: scope.component, value: offset, to: start) ?? start + return RecapPeriod(scope: scope, containing: moved, calendar: calendar) + } + + /// Gregorian whatever the system calendar, so a year runs January to December and the day keys + /// match `LocalUsageReader.localDayFormatter`. The week still starts on the user's first weekday. + static func calendar(timeZone: TimeZone = .current) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + calendar.firstWeekday = Calendar.current.firstWeekday + return calendar + } +} + +/// A recap of one period — read-only, computed on demand. Nothing here is a reward or a goal: it +/// reports what already happened, so the app still asks nothing of the user. +// Not Equatable: `DexEntry` is not, and the graduated rows carry it whole. +struct UsageRecap: Sendable { + /// One meter: a day in the week and month views, a month in the year view. + struct Bucket: Equatable, Sendable { + /// `yyyy-MM-dd` of the bucket's first day. + let key: String + let tokens: Int + /// Holds today. + let isCurrent: Bool + /// False for the future and for days before the ledger started: unknown, not zero. + let hasData: Bool + } + + struct Day: Equatable, Sendable { + let key: String + let tokens: Int + } + + let period: RecapPeriod + let buckets: [Bucket] + let total: Int + /// Today falls inside the period. + let isInProgress: Bool + /// The previous period over the same number of days while this one is running, the whole of it + /// otherwise. nil when the ledger does not reach its first day: days the app never saw would + /// count as zero and invent a jump. + let previousTotal: Int? + let bestDay: Day? + let activeDays: Int + /// Days of the period, up to today, that the ledger covers: the denominator of `activeDays`. + let countedDays: Int + /// Longest run of consecutive active days inside the period. + let bestStreak: Int + /// Individuals graduated during the period, newest first. + let graduated: [DexEntry] + /// The previous period holds at least one day the ledger covers. + let canGoBack: Bool + + var delta: Double? { + guard let previousTotal, previousTotal > 0 else { return nil } + return Double(total - previousTotal) / Double(previousTotal) + } + + static func make(ledger: UsageLedger, dex: [DexEntry], period: RecapPeriod, now: Date, + calendar: Calendar) -> UsageRecap { + let formatter = LocalUsageReader.localDayFormatter(timeZone: calendar.timeZone) + let today = calendar.startOfDay(for: now) + + // `date(byAdding:)` rather than +86400: a DST day is 23 or 25 hours long. + func days(_ start: Date, _ end: Date) -> [Date] { + var result: [Date] = [] + var cursor = calendar.startOfDay(for: start) + while cursor < end { + result.append(cursor) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + return result + } + func known(_ key: String) -> Bool { ledger.tokens(on: key) > 0 || ledger.covers(key) } + + let periodDays = days(period.start, period.end) + let elapsed = periodDays.filter { $0 <= today }.map { date in + let key = formatter.string(from: date) + return Day(key: key, tokens: ledger.tokens(on: key)) + } + var bestStreak = 0 + var run = 0 + for day in elapsed { + run = day.tokens > 0 ? run + 1 : 0 + bestStreak = max(bestStreak, run) + } + + let buckets: [Bucket] + switch period.scope { + case .week, .month: + buckets = periodDays.map { date in + let key = formatter.string(from: date) + return Bucket(key: key, tokens: ledger.tokens(on: key), isCurrent: date == today, + hasData: date <= today && known(key)) + } + case .year: + buckets = (0..<12).map { index in + let start = calendar.date(byAdding: .month, value: index, to: period.start) ?? period.start + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? period.end + let keys = days(start, end).filter { $0 <= today }.map { formatter.string(from: $0) } + return Bucket(key: formatter.string(from: start), + tokens: keys.reduce(0) { $0 + ledger.tokens(on: $1) }, + isCurrent: start <= today && today < end, + hasData: keys.contains(where: known)) + } + } + + let isInProgress = period.start <= today && today < period.end + let previous = period.shifted(by: -1, calendar: calendar) + let previousDays = days(previous.start, previous.end).map { formatter.string(from: $0) } + // A running period is compared over the same number of days, or the 18th of the month + // would read as a 40% drop against a whole month. + let compared = isInProgress ? Array(previousDays.prefix(elapsed.count)) : previousDays + let previousTotal = compared.first.map(ledger.covers) == true + ? compared.reduce(0) { $0 + ledger.tokens(on: $1) } + : nil + + let graduated = dex + .filter { entry in + !entry.isReleased && entry.caughtAt.map { $0 >= period.start && $0 < period.end } == true + } + .sorted { ($0.caughtAt ?? .distantPast) > ($1.caughtAt ?? .distantPast) } + + return UsageRecap( + period: period, + buckets: buckets, + total: elapsed.reduce(0) { $0 + $1.tokens }, + isInProgress: isInProgress, + previousTotal: previousTotal, + bestDay: elapsed.filter { $0.tokens > 0 }.max { $0.tokens < $1.tokens }, + activeDays: elapsed.filter { $0.tokens > 0 }.count, + countedDays: elapsed.filter { known($0.key) }.count, + bestStreak: bestStreak, + graduated: graduated, + canGoBack: previousDays.last.map(ledger.covers) ?? false) + } +} diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 3dbf6c70..fc9d24d8 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -428,6 +428,24 @@ final class UsageStore { return byDay.values.sorted { $0.date < $1.date } } + /// Day-by-day history behind the usage recap. Providers only report the current month, so the + /// numbers are copied into a rolling ledger at every refresh — see `UsageLedger`. + private(set) var dailyLedger = UsageLedger() + + private func recordDailyLedger() { + var ledger = dailyLedger + ledger.merge(monthDailyTotals) + // This year and the one before it: the year view compares against last year, and nothing + // reaches further back. + let calendar = RecapPeriod.calendar() + let lastYear = RecapPeriod(scope: .year, containing: Date(), calendar: calendar) + .shifted(by: -1, calendar: calendar) + ledger.prune(before: LocalUsageReader.localDayFormatter().string(from: lastYear.start)) + guard ledger != dailyLedger else { return } + dailyLedger = ledger + ledger.save(to: defaults) + } + /// Claude 의 활성 5h 블록 — 5h forecast·"현재 블록" 행은 Claude 공식 한도와 짝이므로 /// providerID 로 명시 조회한다 (전 프로바이더가 블록을 갖게 된 후 first-with-block 은 오매칭). private var claudeActiveBlock: BlockUsage? { @@ -703,6 +721,7 @@ final class UsageStore { additionalClaudeConfigDirs = d.string(forKey: ClaudeAccountRoots.defaultsKey) ?? "" claudeTrackedAccountMode = ClaudeTrackedAccountMode(storedValue: d.string(forKey: ClaudeTrackedAccountMode.defaultsKey)) armedCandyWindows = Set(d.stringArray(forKey: Self.armedCandyWindowsKey) ?? []) + dailyLedger = UsageLedger.load(from: d) if let credential = sessionKeys.credential() { sessionKeyConfigured = true @@ -926,6 +945,9 @@ final class UsageStore { } } } + // 일별 원장은 여기서 갱신한다 — monthDaily 는 phase 2 에서만 채워지므로, phase 1 직후에 + // 기록하면 설치 후 첫 갱신에서 사용량 요약이 통째로 빈다. + recordDailyLedger() // ── 한도 조회 (Keychain 프롬프트로 블로킹될 수 있어 마지막) // 세션 키 경로는 Keychain 을 안 읽으므로 이 토글과 무관하게 조회한다 — 토글을 켠 이유(팝업)가 diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 8fe36d7a..1a936928 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -22,6 +22,8 @@ final class PopoverNavigation { var tab: PopoverTab = .home /// 일반적인 컬렉션 재진입에는 마지막 세그먼트를 유지하되, 대표 포켓몬 선택 진입점은 도감으로 강제한다. var showingCollectionLog = false + /// The usage recap takes over the popover like Settings does; closing the popover drops it. + var showingRecap = false /// 프로바이더 탭 선택 — reset() 대상이 아님(팝오버를 다시 열어도 보던 서비스 유지). var providerID: String? /// Claude account tab in the limits section. Kept across openings, like `providerID`. @@ -33,6 +35,7 @@ final class PopoverNavigation { func reset() { showSettings = false expandAdvancedOnOpen = false + showingRecap = false tab = .home } @@ -78,6 +81,8 @@ struct PopoverView: View { .environment(store) .environment(companion) .environment(updater) + } else if nav.showingRecap { + RecapScreen(store: store, companion: companion) { nav.showingRecap = false } } else { mainContent } @@ -176,6 +181,12 @@ struct PopoverView: View { periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil) periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil) Spacer() + Button { nav.showingRecap = true } label: { + Image(systemName: "chart.bar.xaxis") + } + .buttonStyle(.borderless) + .help(l.recapOpen) + .accessibilityLabel(l.recapOpen) } .padding(.top, 2) } diff --git a/Sources/PokeTokenBar/UI/UsageRecapView.swift b/Sources/PokeTokenBar/UI/UsageRecapView.swift new file mode 100644 index 00000000..2d236373 --- /dev/null +++ b/Sources/PokeTokenBar/UI/UsageRecapView.swift @@ -0,0 +1,476 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// Everything the recap prints, read once so the live view and the exported image agree. +@MainActor +struct RecapContent { + let language: AppLanguage + let recap: UsageRecap + let companionSpeciesID: Int? + let companionShiny: Bool + let graduates: [(entry: DexEntry, name: String)] + private let calendar: Calendar + /// Built once per read: a `DateFormatter` is expensive and the card asks for a label per meter. + private let dayKeyFormatter: DateFormatter + private let dayNameFormatter: DateFormatter + private let monthNameFormatter: DateFormatter + + init(store: UsageStore, companion: CompanionStore, scope: RecapScope, offset: Int, + now: Date = Date(), calendar: Calendar = RecapPeriod.calendar()) { + language = companion.language + self.calendar = calendar + let period = RecapPeriod(scope: scope, containing: now, calendar: calendar) + .shifted(by: offset, calendar: calendar) + recap = UsageRecap.make(ledger: store.dailyLedger, dex: companion.state.dex, period: period, + now: now, calendar: calendar) + // The Pokémon being raised right now, not the pinned representative: the recap is about + // usage that just happened, so it shows who lived it. + companionSpeciesID = companion.currentSpeciesID + companionShiny = companion.currentIsShiny + graduates = recap.graduated.prefix(4).map { entry in + (entry, companion.dexStoredChainNames(entry)?[entry.finalID] ?? "#\(entry.finalID)") + } + dayKeyFormatter = LocalUsageReader.localDayFormatter(timeZone: calendar.timeZone) + dayNameFormatter = Self.formatter(language, calendar, template: "EEEE d MMM") + monthNameFormatter = Self.formatter(language, calendar, template: "MMMM y") + } + + private static func formatter(_ language: AppLanguage, _ calendar: Calendar, template: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = language.displayLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.setLocalizedDateFormatFromTemplate(template) + return formatter + } + + var l: L { L(language) } + var scope: RecapScope { recap.period.scope } + + /// "14–20 Sept 2026", "September 2026" or "2026", from the display locale. It titles the card: + /// the exported image has to say which stretch it covers on its own. + func periodLabel() -> String { + switch scope { + case .week: + let formatter = DateIntervalFormatter() + formatter.locale = language.displayLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateTemplate = "dMMMy" + let lastDay = calendar.date(byAdding: .day, value: -1, to: recap.period.end) ?? recap.period.start + return formatter.string(from: recap.period.start, to: lastDay) + case .month: + return monthNameFormatter.string(from: recap.period.start) + case .year: + return String(calendar.component(.year, from: recap.period.start)) + } + } + + /// One short label per meter, from the display locale so there is nothing of ours to translate. + /// Weekdays take two letters (one collides: mardi/mercredi, Tuesday/Thursday), a month labels + /// only its weekly marks since 31 numbers do not fit, and a year uses month initials. + func bucketLabels() -> [String] { + let symbols = DateFormatter() + symbols.locale = language.displayLocale + let weekdays = symbols.shortStandaloneWeekdaySymbols ?? [] + let months = symbols.veryShortStandaloneMonthSymbols ?? [] + return recap.buckets.map { bucket in + guard let date = dayKeyFormatter.date(from: bucket.key) else { return "" } + switch scope { + case .week: + guard !weekdays.isEmpty else { return "" } + let weekday = calendar.component(.weekday, from: date) // 1-based + return String(weekdays[(weekday - 1) % weekdays.count].prefix(2)).uppercased() + case .month: + let day = calendar.component(.day, from: date) + return [1, 8, 15, 22, 29].contains(day) ? "\(day)" : "" + case .year: + guard !months.isEmpty else { return "" } + let month = calendar.component(.month, from: date) // 1-based + return months[(month - 1) % months.count].uppercased() + } + } + } + + func dayLabel(_ key: String) -> String { + guard let date = dayKeyFormatter.date(from: key) else { return key } + return dayNameFormatter.string(from: date) + } + + func accessibilityLabel(_ bucket: UsageRecap.Bucket) -> String { + let name: String + if scope == .year, let date = dayKeyFormatter.date(from: bucket.key) { + name = monthNameFormatter.string(from: date) + } else { + name = dayLabel(bucket.key) + } + return "\(name), \(bucket.hasData ? TokenFormatter.grouped(bucket.tokens) : l.recapNoData)" + } + + var exportFileName: String { + "recap-\(scope.rawValue)-\(dayKeyFormatter.string(from: recap.period.start)).png" + } +} + +/// The shareable panel — a Pokédex readout, not a second trainer card: red shell, dark LCD, +/// monospaced digits and segmented meters. Colors are all explicit so the export never shifts +/// with the system appearance. +@MainActor +struct RecapCard: View { + let content: RecapContent + + private static let size = CGSize(width: PopoverMetrics.contentWidth, height: 226) + private static let shellTop = Color(red: 0.84, green: 0.21, blue: 0.18) + private static let shellBottom = Color(red: 0.66, green: 0.12, blue: 0.11) + private static let screenInk = Color(red: 0.05, green: 0.07, blue: 0.08) + private static let lcd = Color(red: 0.51, green: 0.98, blue: 0.62) + private static let amber = Color(red: 1.0, green: 0.73, blue: 0.28) + /// Segments per meter. Eight reads as a level gauge; more would turn back into a plain bar. + private static let meterBlocks = 8 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + screen + graduateStrip + } + .padding(12) + .frame(width: Self.size.width, height: Self.size.height, alignment: .topLeading) + .background( + ZStack { + LinearGradient(colors: [Self.shellTop, Self.shellBottom], + startPoint: .top, endPoint: .bottom) + // The hinge side of the case, so the panel reads as a device and not as a card. + HStack(spacing: 0) { + Color.black.opacity(0.14).frame(width: 5) + Spacer(minLength: 0) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + .environment(\.colorScheme, .light) + .environment(\.locale, content.language.displayLocale) + } + + private var header: some View { + HStack(spacing: 6) { + lens + ForEach([Color(red: 0.95, green: 0.35, blue: 0.35), + Color(red: 0.98, green: 0.82, blue: 0.35), + Color(red: 0.45, green: 0.88, blue: 0.5)], id: \.self) { color in + Circle() + .fill(color) + .overlay(Circle().strokeBorder(.black.opacity(0.25), lineWidth: 0.5)) + .frame(width: 6, height: 6) + } + Spacer(minLength: 4) + Text(content.periodLabel().uppercased()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.95)) + .lineLimit(1).minimumScaleFactor(0.7) + } + .padding(.leading, 2) + } + + private var lens: some View { + Circle() + .fill(RadialGradient(colors: [Color(red: 0.72, green: 0.88, blue: 1.0), + Color(red: 0.15, green: 0.45, blue: 0.85)], + center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: 11)) + .overlay(Circle().strokeBorder(.white.opacity(0.9), lineWidth: 1.5)) + .overlay( + Circle().fill(.white.opacity(0.75)).frame(width: 4, height: 4) + .offset(x: -3, y: -3) + ) + .frame(width: 16, height: 16) + } + + private var screen: some View { + let recap = content.recap + let l = content.l + return VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(TokenFormatter.compact(recap.total)) + .font(.system(size: 24, weight: .bold, design: .monospaced)) + .foregroundStyle(Self.lcd) + Text(l.recapTokensUnit.uppercased()) + .font(.system(size: 7, weight: .medium, design: .monospaced)) + .tracking(0.6) + .foregroundStyle(Self.lcd.opacity(0.5)) + .lineLimit(1) + Spacer(minLength: 4) + deltaChip + } + meters + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + readout(l.recapBestDay, recap.bestDay.map { TokenFormatter.compact($0.tokens) } ?? "--") + readout(l.recapBestStreak, l.recapDays(recap.bestStreak)) + } + VStack(alignment: .leading, spacing: 3) { + readout(l.recapActiveDays, "\(recap.activeDays)/\(recap.countedDays)") + readout(l.recapGraduates, "\(recap.graduated.count)") + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Self.screenInk) + .overlay( + RoundedRectangle(cornerRadius: 6) + .fill(RadialGradient(colors: [Self.lcd.opacity(0.07), .clear], + center: UnitPoint(x: 0.15, y: 0.1), + startRadius: 0, endRadius: 180)) + ) + ) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.black.opacity(0.35), lineWidth: 2)) + .padding(.horizontal, 2) + } + + @ViewBuilder + private var deltaChip: some View { + // No chip unless the ledger covers the whole compared stretch: a fresh install must not + // claim a jump over days it never saw. + if let delta = content.recap.delta { + let up = delta >= 0 + let color = up ? Self.lcd : Self.amber + Text("\(up ? "▲" : "▼")\(TokenFormatter.percent(abs(delta) * 100))") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(color) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 2).strokeBorder(color.opacity(0.6), lineWidth: 1)) + } + } + + private var meters: some View { + let recap = content.recap + let peak = max(1, recap.buckets.map(\.tokens).max() ?? 1) + let labels = content.bucketLabels() + let spacing: CGFloat = switch content.scope { + case .week: 6 + case .month: 1.5 + case .year: 4 + } + return HStack(alignment: .bottom, spacing: spacing) { + ForEach(Array(recap.buckets.enumerated()), id: \.element.key) { index, bucket in + VStack(spacing: 3) { + meter(bucket, peak: peak) + // Zero-width frame: a month's "15" is wider than its column and must not + // widen it, or five meters would come out fatter than the rest. + Text(labels.indices.contains(index) ? labels[index] : "") + .font(.system(size: content.scope == .week ? 8 : 7, + weight: bucket.isCurrent ? .bold : .regular, design: .monospaced)) + .foregroundStyle(bucket.isCurrent ? Self.lcd : Self.lcd.opacity(0.45)) + .fixedSize() + .frame(width: 0, height: 10) + } + .frame(maxWidth: .infinity) + // The meter is plain rectangles, so VoiceOver would otherwise read the label alone. + .accessibilityElement(children: .ignore) + .accessibilityLabel(content.accessibilityLabel(bucket)) + } + } + } + + private func meter(_ bucket: UsageRecap.Bucket, peak: Int) -> some View { + // A bucket with usage always lights one segment, so "quiet" and "nothing" stay distinct; + // an unknown bucket (future, or before the ledger started) dims even its unlit grid. + let lit = bucket.tokens > 0 + ? max(1, Int((Double(bucket.tokens) / Double(peak) * Double(Self.meterBlocks)).rounded())) + : 0 + return VStack(spacing: 2) { + ForEach(0..= Self.meterBlocks - lit + ? (bucket.isCurrent ? Self.lcd : Self.lcd.opacity(0.55)) + : Self.lcd.opacity(bucket.hasData ? 0.09 : 0.035)) + .frame(height: 4) + } + } + } + + private func readout(_ label: String, _ value: String) -> some View { + HStack(spacing: 4) { + Text(label.uppercased()) + .font(.system(size: 7, weight: .medium, design: .monospaced)) + .foregroundStyle(Self.lcd.opacity(0.5)) + .lineLimit(1).minimumScaleFactor(0.7) + Spacer(minLength: 2) + Text(value) + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(Self.lcd) + .lineLimit(1).minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var graduateStrip: some View { + HStack(spacing: 5) { + SpriteView(speciesID: content.companionSpeciesID, size: 18, animated: false, + shiny: content.companionShiny) + Rectangle().fill(.white.opacity(0.35)).frame(width: 1, height: 14) + if content.graduates.isEmpty { + Text(content.l.recapNoGraduates) + .font(.system(size: 8, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + } else { + ForEach(content.graduates, id: \.entry.id) { graduate in + HStack(spacing: 2) { + SpriteView(speciesID: graduate.entry.finalID, size: 18, animated: false, + shiny: graduate.entry.isShiny) + // A badge on the sprite rather than after the name: four chips share + // the row, and a trailing sparkle cut "Charizard" to "Chariza…". + .overlay(alignment: .topTrailing) { + if graduate.entry.isShiny { + Text("✨").font(.system(size: 7)).offset(x: 3, y: -2) + } + } + Text(graduate.name) + .font(.system(size: 8, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + .lineLimit(1).minimumScaleFactor(0.7) + } + .padding(.horizontal, 4).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 3).fill(.black.opacity(0.22))) + } + } + Spacer(minLength: 0) + } + .padding(.leading, 2) + } +} + +@MainActor +struct RecapScreen: View { + let store: UsageStore + let companion: CompanionStore + let onBack: () -> Void + + @State private var scope: RecapScope = .week + /// 0 is the running period, -1 the one before it. Never positive: the future has no usage. + @State private var offset = 0 + @State private var copied = false + + var body: some View { + let l = companion.l + let content = RecapContent(store: store, companion: companion, scope: scope, offset: offset) + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Button { onBack() } label: { + Label(l.back, systemImage: "chevron.left").labelStyle(.titleAndIcon) + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) // Esc, like Settings: both take over the popover + Spacer(minLength: 4) + Picker(l.recapOpen, selection: $scope) { + ForEach(RecapScope.allCases, id: \.self) { Text(l.recapScopeName($0)).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .controlSize(.small) + .onChange(of: scope) { offset = 0 } + } + // The period itself is printed on the card, so this row only moves through time. + HStack { + Button { offset -= 1 } label: { + Label(l.recapPrevious, systemImage: "arrowtriangle.left.fill").labelStyle(.titleAndIcon) + } + .disabled(!content.recap.canGoBack) + Spacer() + Button { offset += 1 } label: { + HStack(spacing: 4) { + Text(l.recapNext) + Image(systemName: "arrowtriangle.right.fill") + } + } + .accessibilityLabel(l.recapNext) + .disabled(offset >= 0) + } + .buttonStyle(.borderless) + .font(.caption) + RecapCard(content: content) + VStack(alignment: .leading, spacing: 2) { + if let best = content.recap.bestDay { + Text(l.recapBestDayLine(content.dayLabel(best.key), TokenFormatter.grouped(best.tokens))) + } + if content.recap.isInProgress, content.recap.delta != nil { + Text(l.recapCompareSoFar(scope)) + } + } + .font(.caption).foregroundStyle(.secondary) + HStack(spacing: 6) { + Button(copied ? l.recapCopied : l.recapCopy) { + Task { + guard await RecapExport.copy(content) else { return } + copied = true + try? await Task.sleep(for: .seconds(1.5)) + copied = false + } + } + Button(l.recapSave) { + Task { await RecapExport.save(content) } + } + Spacer(minLength: 0) + } + .controlSize(.small) + Spacer(minLength: 0) + } + .padding(PopoverMetrics.padding) + .task { await companion.backfillMissingDexNames() } + } +} + +@MainActor +enum RecapExport { + static func pngData(_ content: RecapContent) async -> Data? { + await preload(content) + let renderer = ImageRenderer(content: RecapCard(content: content).padding(12) + .background(Color(white: 0.9)) + .environment(\.locale, content.language.displayLocale)) + renderer.scale = 4 + guard let image = renderer.cgImage else { return nil } + return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) + } + + /// `ImageRenderer` renders in one pass and never runs `.task`, so every sprite has to be in + /// the disk cache before the render or the card comes out with holes. + private static func preload(_ content: RecapContent) async { + for (speciesID, shiny) in [(content.companionSpeciesID, content.companionShiny)] + + content.graduates.map({ (Optional($0.entry.finalID), $0.entry.isShiny) }) { + guard let speciesID else { + _ = await SpriteLoader.eggImage() + continue + } + _ = await SpriteLoader.image(speciesID: speciesID, shiny: shiny) + } + } + + @discardableResult + static func copy(_ content: RecapContent) async -> Bool { + guard let data = await pngData(content) else { return false } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setData(data, forType: .png) + return true + } + + static func save(_ content: RecapContent) async { + guard let data = await pngData(content) else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType.png] + panel.nameFieldStringValue = content.exportFileName + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try data.write(to: url) + } catch { + AppLog.write("usage recap: save failed — \(error.localizedDescription)") + } + } +} diff --git a/Tests/PokeTokenBarTests/UsageRecapTests.swift b/Tests/PokeTokenBarTests/UsageRecapTests.swift new file mode 100644 index 00000000..048e5bda --- /dev/null +++ b/Tests/PokeTokenBarTests/UsageRecapTests.swift @@ -0,0 +1,369 @@ +import AppKit +import SwiftUI +import XCTest +@testable import PokeTokenBar + +// MARK: Fixtures — a fixed UTC calendar whose weeks start on Monday, so nothing depends on the +// machine running the suite. + +private let utc = TimeZone(identifier: "UTC")! + +private func calendar(firstWeekday: Int = 2) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = utc + calendar.firstWeekday = firstWeekday + return calendar +} + +private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 12) -> Date { + calendar().date(from: DateComponents(year: year, month: month, day: day, hour: hour))! +} + +/// Friday 18 September 2026, noon: the week runs Monday 14 to Sunday 20. +private let now = date(2026, 9, 18) + +private func key(_ year: Int, _ month: Int, _ day: Int) -> String { + LocalUsageReader.localDayFormatter(timeZone: utc).string(from: date(year, month, day)) +} + +private func usage(_ date: String, _ tokens: Int) -> DailyUsage { + DailyUsage(date: date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, totalTokens: tokens, totalCost: 0, costCoverage: .source) +} + +/// A ledger that has been recording since `since`, with usage on the given days. +private func ledger(since: String, _ days: [(String, Int)]) -> UsageLedger { + var ledger = UsageLedger() + ledger.merge([usage(since, 0)] + days.map { usage($0.0, $0.1) }) + return ledger +} + +private func recap(_ ledger: UsageLedger, _ scope: RecapScope, offset: Int = 0, dex: [DexEntry] = [], + calendar: Calendar = calendar()) -> UsageRecap { + let period = RecapPeriod(scope: scope, containing: now, calendar: calendar).shifted(by: offset, calendar: calendar) + return UsageRecap.make(ledger: ledger, dex: dex, period: period, now: now, calendar: calendar) +} + +private func graduate(_ id: String, on day: Date, shiny: Bool = false, released: Bool = false) -> DexEntry { + DexEntry(id: id, baseID: 10, finalID: 10, chainOrder: [10], rarity: .common, caughtAt: day, + isShiny: shiny, names: [10: ["en": "S10"]], releasedAt: released ? day : nil) +} + +// MARK: Ledger + +final class UsageLedgerTests: XCTestCase { + func testADayNeverGoesDown() { + var l = UsageLedger() + l.merge([usage("2026-09-10", 500), usage("2026-09-11", 300)]) + // A rotated log makes the provider report less for a day that really happened. + l.merge([usage("2026-09-10", 120), usage("2026-09-11", 900)]) + + XCTAssertEqual(l.tokens(on: "2026-09-10"), 500) + XCTAssertEqual(l.tokens(on: "2026-09-11"), 900) + } + + func testTheSeriesVouchesForItsEmptyDaysToo() { + var l = UsageLedger() + l.merge([usage("2026-09-01", 0), usage("2026-09-02", 40)]) + + XCTAssertEqual(l.tokensByDay, ["2026-09-02": 40], "an empty day adds no row") + XCTAssertEqual(l.coveredSince, "2026-09-01", "but it was scanned, so its zero is real") + XCTAssertTrue(l.covers("2026-09-01")) + XCTAssertFalse(l.covers("2026-08-31")) + + l.merge([usage("2026-09-05", 10)]) + XCTAssertEqual(l.coveredSince, "2026-09-01", "a later series does not shrink the coverage") + } + + /// Pruned days are unknown again: without moving the coverage up, a recap would read them as + /// a stretch with no usage. + func testPruningForgetsTheCoverageItDrops() { + var l = ledger(since: "2025-06-01", [("2025-06-10", 100), ("2026-02-01", 200)]) + l.prune(before: "2026-01-01") + + XCTAssertEqual(l.tokensByDay, ["2026-02-01": 200]) + XCTAssertEqual(l.coveredSince, "2026-01-01") + } + + func testJunkIsDroppedOnLoad() { + let defaults = UserDefaults(suiteName: "ledger-junk-\(UUID().uuidString)")! + defaults.set(["2026-09-10": 400, "nope": 10, "0000-00-00": 10, "2026-09-11": 0], + forKey: UsageLedger.defaultsKey) + defaults.set("0000-00-00", forKey: UsageLedger.coverageKey) + + let loaded = UsageLedger.load(from: defaults) + + XCTAssertEqual(loaded.tokensByDay, ["2026-09-10": 400]) + XCTAssertNil(loaded.coveredSince, "a key that is not a day must not backdate the coverage") + } + + func testSaveAndLoadRoundTrip() { + let defaults = UserDefaults(suiteName: "ledger-round-\(UUID().uuidString)")! + let l = ledger(since: "2026-09-01", [("2026-09-10", 700)]) + l.save(to: defaults) + + XCTAssertEqual(UsageLedger.load(from: defaults), l) + } +} + +// MARK: Refresh stubs (so the ledger test touches neither Keychain nor the network) + +private enum LedgerStubError: Error { case unavailable } + +private struct NoClaudeLimits: ClaudeLimitsProviding { + func fetch(allowKeychainPrompt: Bool) async throws -> LimitStatus { throw LedgerStubError.unavailable } +} +private struct NoCodexLimits: CodexLimitsProviding { + func fetch() async throws -> CodexRateLimitStatus? { nil } +} +private struct NoAntigravityLimits: AntigravityLimitsProviding { + func fetch(allowKeychainPrompt: Bool) async throws -> AntigravityRateLimitStatus { + throw LedgerStubError.unavailable + } +} +private struct NoStatus: ProviderStatusProviding { + func fetch() async -> [String: ProviderStatus] { [:] } +} + +/// Reports today in `fetchDaily` and the month series only in `fetchEnrichment` — like the real +/// providers, where the series lands one phase later than the snapshot. +private final class LedgerProvider: UsageProvider, @unchecked Sendable { + let id = "claude_code" + let displayName = "Claude Code" + let reportsCost = true + private let daily: DailyUsage + private let series: [DailyUsage] + init(daily: DailyUsage, series: [DailyUsage]) { self.daily = daily; self.series = series } + func fetchDaily() async throws -> DailyUsage? { daily } + func fetchEnrichment() async -> ProviderEnrichment { + var enrichment = ProviderEnrichment() + enrichment.periodsOK = true + enrichment.monthDaily = series + return enrichment + } +} + +@MainActor +final class UsageLedgerRecordingTests: XCTestCase { + /// Regression: the ledger used to be written right after phase 1, where `monthDaily` is still + /// nil, so the first refresh after an install recorded nothing and the recap came out empty. + func testTheFirstRefreshAlreadyFillsTheLedger() async { + let formatter = LocalUsageReader.localDayFormatter() + let today = formatter.string(from: Date()) + let yesterday = formatter.string(from: Date().addingTimeInterval(-86_400)) + let defaults = UserDefaults(suiteName: "ledger-refresh-\(UUID().uuidString)")! + let store = UsageStore( + providers: [LedgerProvider(daily: usage(today, 300), + series: [usage(yesterday, 900), usage(today, 300)])], + claudeLimitsProvider: NoClaudeLimits(), codexLimitsProvider: NoCodexLimits(), + antigravityLimitsProvider: NoAntigravityLimits(), statusProvider: NoStatus(), + autoRefresh: false, defaults: defaults) + + await store.refresh(scheduleEmptyRetry: false) + + XCTAssertEqual(store.dailyLedger.tokens(on: today), 300) + XCTAssertEqual(store.dailyLedger.tokens(on: yesterday), 900) + XCTAssertEqual(store.dailyLedger.coveredSince, yesterday) + XCTAssertEqual(UsageLedger.load(from: defaults), store.dailyLedger, "and it survives a relaunch") + } +} + +extension UsageLedgerRecordingTests { + /// The year view compares against last year, so the refresh must keep all of it — and nothing + /// older, or the defaults would grow forever. + func testARefreshKeepsLastYearAndDropsTheYearBefore() async { + let calendar = RecapPeriod.calendar() + let formatter = LocalUsageReader.localDayFormatter() + let thisYear = RecapPeriod(scope: .year, containing: Date(), calendar: calendar) + let lastYearStart = formatter.string(from: thisYear.shifted(by: -1, calendar: calendar).start) + let lastDayTwoYearsAgo = formatter.string(from: thisYear.shifted(by: -1, calendar: calendar).start + .addingTimeInterval(-12 * 3600)) + let today = formatter.string(from: Date()) + let defaults = UserDefaults(suiteName: "ledger-retention-\(UUID().uuidString)")! + var seeded = UsageLedger() + seeded.merge([usage(lastDayTwoYearsAgo, 70), usage(lastYearStart, 80)]) + seeded.save(to: defaults) + let store = UsageStore( + providers: [LedgerProvider(daily: usage(today, 5), series: [usage(today, 5)])], + claudeLimitsProvider: NoClaudeLimits(), codexLimitsProvider: NoCodexLimits(), + antigravityLimitsProvider: NoAntigravityLimits(), statusProvider: NoStatus(), + autoRefresh: false, defaults: defaults) + + await store.refresh(scheduleEmptyRetry: false) + + XCTAssertEqual(store.dailyLedger.tokens(on: lastYearStart), 80, "1 January of last year stays") + XCTAssertEqual(store.dailyLedger.tokens(on: lastDayTwoYearsAgo), 0, "31 December the year before goes") + XCTAssertEqual(store.dailyLedger.coveredSince, lastYearStart) + } +} + +// MARK: Periods + +final class RecapPeriodTests: XCTestCase { + func testAWeekStartsOnTheRegionsFirstWeekday() { + let monday = RecapPeriod(scope: .week, containing: now, calendar: calendar(firstWeekday: 2)) + XCTAssertEqual(monday.start, date(2026, 9, 14, hour: 0)) + XCTAssertEqual(monday.end, date(2026, 9, 21, hour: 0)) + + let sunday = RecapPeriod(scope: .week, containing: now, calendar: calendar(firstWeekday: 1)) + XCTAssertEqual(sunday.start, date(2026, 9, 13, hour: 0)) + } + + func testSteppingBackLandsOnWholePeriods() { + let cal = calendar() + let march = RecapPeriod(scope: .month, containing: date(2026, 3, 31), calendar: cal) + let february = march.shifted(by: -1, calendar: cal) + XCTAssertEqual(february.start, date(2026, 2, 1, hour: 0), "not 31 February rolled into March") + XCTAssertEqual(february.end, date(2026, 3, 1, hour: 0)) + + let lastYear = RecapPeriod(scope: .year, containing: now, calendar: cal).shifted(by: -1, calendar: cal) + XCTAssertEqual(lastYear.start, date(2025, 1, 1, hour: 0)) + XCTAssertEqual(lastYear.end, date(2026, 1, 1, hour: 0)) + } +} + +// MARK: Recap + +final class UsageRecapTests: XCTestCase { + func testEachScopeDrawsItsOwnMeters() { + let l = ledger(since: key(2026, 1, 1), [(key(2026, 9, 2), 10), (key(2026, 9, 17), 20), + (key(2026, 8, 30), 5)]) + + let week = recap(l, .week) + XCTAssertEqual(week.buckets.map(\.key).first, key(2026, 9, 14)) + XCTAssertEqual(week.buckets.count, 7) + XCTAssertEqual(week.buckets.filter(\.isCurrent).map(\.key), [key(2026, 9, 18)]) + + XCTAssertEqual(recap(l, .month).buckets.count, 30) + XCTAssertEqual(recap(l, .month, offset: -7).buckets.count, 28, "February 2026") + + let year = recap(l, .year) + XCTAssertEqual(year.buckets.count, 12) + XCTAssertEqual(year.buckets[8].tokens, 30, "September sums its days") + XCTAssertEqual(year.buckets[7].tokens, 5) + XCTAssertTrue(year.buckets[8].isCurrent) + XCTAssertEqual(year.total, 35) + } + + func testFutureAndUnrecordedDaysAreNotZeros() { + let week = recap(ledger(since: key(2026, 9, 16), [(key(2026, 9, 17), 50)]), .week) + + XCTAssertEqual(week.buckets.map(\.hasData), [false, false, true, true, true, false, false], + "Mon-Tue before the ledger, Sat-Sun still ahead") + XCTAssertEqual(week.activeDays, 1) + XCTAssertEqual(week.countedDays, 3) + + let year = recap(ledger(since: key(2026, 9, 1), []), .year) + XCTAssertEqual(year.buckets.map(\.hasData), Array(repeating: false, count: 8) + [true] + Array(repeating: false, count: 3)) + } + + /// The 18th of the month against a whole previous month would read as a drop; a running period + /// is compared over the same number of days. + func testARunningPeriodIsComparedOverTheSameDays() { + let l = ledger(since: key(2026, 8, 1), [(key(2026, 9, 10), 100), (key(2026, 8, 18), 40), + (key(2026, 8, 19), 1_000)]) + let month = recap(l, .month) + + XCTAssertTrue(month.isInProgress) + XCTAssertEqual(month.previousTotal, 40, "the 19th of August is past the 18th") + XCTAssertEqual(try XCTUnwrap(month.delta), 1.5, accuracy: 0.0001) + } + + func testAFinishedPeriodIsComparedWhole() { + let l = ledger(since: key(2026, 7, 1), [(key(2026, 8, 5), 300), (key(2026, 7, 31), 100)]) + let august = recap(l, .month, offset: -1) + + XCTAssertFalse(august.isInProgress) + XCTAssertEqual(august.previousTotal, 100) + XCTAssertEqual(try XCTUnwrap(august.delta), 2.0, accuracy: 0.0001) + } + + /// Regression: coverage used to be judged on the *newest* day of the previous window, so a + /// ledger starting inside it counted the days before as zero and inflated the delta. + func testAHalfCoveredPreviousPeriodIsNotAComparison() { + let week = recap(ledger(since: key(2026, 9, 9), [(key(2026, 9, 15), 150), (key(2026, 9, 10), 50)]), .week) + + XCTAssertNil(week.previousTotal, "the ledger starts on the Wednesday of the previous week") + XCTAssertNil(week.delta) + } + + func testTheBestStreakStaysInsideThePeriod() { + // Friday 11 to Wednesday 16 without a gap, then Friday 18 alone. + let days = [11, 12, 13, 14, 15, 16, 18].map { (key(2026, 9, $0), 5) } + let week = recap(ledger(since: key(2026, 9, 1), days), .week) + + XCTAssertEqual(week.bestStreak, 3, "Monday 14 to Wednesday 16; the days before the week do not count") + XCTAssertEqual(recap(ledger(since: key(2026, 9, 1), days), .month).bestStreak, 6) + } + + func testGraduationsInThePeriodOnly() { + let dex = [graduate("this-week", on: date(2026, 9, 17)), + graduate("monday", on: date(2026, 9, 14, hour: 0), shiny: true), + graduate("let-go", on: date(2026, 9, 16), released: true), + graduate("last-week", on: date(2026, 9, 13, hour: 23))] + let l = ledger(since: key(2026, 9, 1), []) + + XCTAssertEqual(recap(l, .week, dex: dex).graduated.map(\.id), ["this-week", "monday"], + "newest first, released out, Sunday 13 is last week") + XCTAssertEqual(recap(l, .month, dex: dex).graduated.count, 3) + } + + func testGoingBackStopsWhereTheLedgerStarts() { + let l = ledger(since: key(2026, 9, 1), [(key(2026, 9, 2), 10)]) + + XCTAssertFalse(recap(l, .month).canGoBack, "August was never recorded") + XCTAssertTrue(recap(l, .week, offset: -1).canGoBack, "the week of 31 August holds 1 September") + XCTAssertFalse(recap(l, .week, offset: -2).canGoBack) + XCTAssertFalse(recap(l, .year).canGoBack) + } + + func testAnEmptyLedgerStillDrawsThePeriod() { + let week = recap(UsageLedger(), .week) + + XCTAssertEqual(week.buckets.count, 7) + XCTAssertFalse(week.buckets.contains(where: \.hasData)) + XCTAssertEqual(week.total, 0) + XCTAssertNil(week.bestDay) + XCTAssertNil(week.previousTotal) + XCTAssertFalse(week.canGoBack) + } +} + +// MARK: Rendering + +@MainActor +final class UsageRecapRenderingTests: XCTestCase { + private let url = FileManager.default.temporaryDirectory + .appendingPathComponent("recap-\(UUID().uuidString).json") + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: url) + } + + func testTheCardRendersInEveryLanguageAndScope() throws { + let dex = [graduate("a", on: date(2026, 9, 17)), graduate("b", on: date(2026, 9, 15), shiny: true)] + let dexJSON = String(decoding: try JSONEncoder().encode(dex), as: UTF8.self) + try Data(#"{"installBaselineSet":true,"usedSinceInstall":1000,"lastDate":"d","dex":\#(dexJSON)}"#.utf8) + .write(to: url) + let defaults = UserDefaults(suiteName: "recap-render-\(UUID().uuidString)")! + ledger(since: key(2026, 8, 1), [(key(2026, 9, 17), 5_000), (key(2026, 8, 20), 900)]).save(to: defaults) + let usageStore = UsageStore(providers: [], autoRefresh: false, defaults: defaults) + + for language in AppLanguage.allCases { + let companion = CompanionStore(fileURL: url, defaults: defaults) + companion.setLanguage(language) + for scope in RecapScope.allCases { + let content = RecapContent(store: usageStore, companion: companion, scope: scope, offset: 0, + now: now, calendar: calendar()) + XCTAssertFalse(content.periodLabel().isEmpty, "\(language) \(scope)") + XCTAssertFalse(content.l.recapCompareSoFar(scope).isEmpty) + XCTAssertEqual(content.bucketLabels().count, content.recap.buckets.count) + XCTAssertTrue(content.exportFileName.hasPrefix("recap-\(scope.rawValue)-")) + let renderer = ImageRenderer(content: RecapCard(content: content)) + renderer.scale = 1 + let image = try XCTUnwrap(renderer.cgImage, "\(language) \(scope)") + XCTAssertEqual(image.width, Int(PopoverMetrics.contentWidth)) + } + } + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 06c91bb6..f63e8ca1 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -124,6 +124,18 @@ read_when: (`testParseCursorUsageEventClampsHugeTokenCounts`). Kiro 타임스탬프의 `Int64(Double)` 도 `Int64.max` 를 넘는 유한값에서 트랩한다. 상한은 더하는 추출기마다 같이 건다. +- **기간 비교의 "데이터가 있는가"는 그 구간의 *가장 오래된* 날로 판정한다.** 사용량 요약이 지난주 + 대비를 낼지 말지를 지난 구간의 **첫 날(=가장 최근 날)** 로 판정했더니, 원장이 구간 중간부터 시작해도 + 비교가 성립했다 — 빠진 날들은 0 으로 합산돼 실측 delta 가 +200% 로 부풀었다(가드의 목적이던 "신규 + 설치가 없던 주 대비 급등을 주장하는" 바로 그 증상). 누락분을 0 으로 세는 합계 위에 커버리지 게이트를 + 올릴 땐, 게이트 기준점이 **합계의 모든 날을 덮는지** 확인한다(`testAHalfCoveredPreviousPeriodIsNotAComparison`). + 같은 맥락에서 손편집 가능한 `UserDefaults` 를 원장으로 쓰면 키 검증도 **길이가 아니라 파싱**으로 + 한다 — `"0000-00-00"` 처럼 날짜가 아니면서 모든 실제 날짜보다 작게 정렬되는 키는 커버리지를 위조한다. + 그리고 "0 인가, 모르는가"는 행의 유무로 추론하지 말고 **커버리지 시작일을 따로 저장**한다 + (`UsageLedger.coveredSince`) — 행은 사용량이 있는 날에만 생기므로, 첫 행 날짜로 커버리지를 추정하면 + 조용했던 기간과 기록 이전 기간이 구별되지 않는다. 오래된 행을 잘라낼 땐 커버리지도 함께 올린다 + (`testPruningForgetsTheCoverageItDrops`). + ## 외부 로그·사용량 소스 - **토큰 파일 파서는 외부 도구의 실제 직렬화 구조체(중첩 객체)를 반영해야 한다.** `jetski-standalone-oauth-token` @@ -404,6 +416,14 @@ read_when: **opt-in(`POKETOKENBAR_RUN_LARGE_PERF=1` / `scripts/perf-codex-large-rollout.sh`)이라 CI 는 돌리지 않는다.** 자동으로 막히지 않으므로 이 부류를 건드리면 직접 돌린다. (#184) +- **2단계 갱신에서 파생 상태는 그 값을 *채우는* 단계 뒤에 기록한다.** 사용량 요약의 일별 원장을 + `snapshots = newSnapshots`(phase 1) 직후에 저장했더니, `monthDaily` 는 `fetchEnrichment`(phase 2) + 에서만 채워져 **설치 후 첫 갱신이 통째로 빈 요약**을 만들었다. 두 번째 갱신부터는 phase 1 이 이전 + 스냅샷의 `prevMonthDaily` 를 이어받아 우연히 채워지므로, 단위 테스트도 손으로 앱을 열어보는 확인도 + 통과한다 — 틀리는 건 첫 갱신 한 번뿐이라 눈에 띄지 않는다(개발 앱 defaults 에 키가 안 생긴 걸 보고 + 발견). refresh 파이프라인에 새 파생 상태를 붙일 땐 그 입력이 phase 1/2 중 어디서 채워지는지 먼저 + 확인하고, 회귀 가드는 **첫 refresh 한 번**으로 검증한다(`testTheFirstRefreshAlreadyFillsTheLedger`). + ## 빌드·도구체인 - **새 SDK에서 통과해도 CI의 AppKit Sendable 선언을 가정하지 마라.** 동시 캐시 로드 테스트가 diff --git a/scripts/test-gate.sh b/scripts/test-gate.sh index 1e6d1dc6..3981586e 100755 --- a/scripts/test-gate.sh +++ b/scripts/test-gate.sh @@ -35,6 +35,7 @@ LOGIC_CORE=( "Sources/PokeTokenBar/Core/LocalUsageCache.swift" "Sources/PokeTokenBar/Core/ModelPricing.swift" "Sources/PokeTokenBar/Core/CustomScanRoots.swift" + "Sources/PokeTokenBar/Core/UsageRecap.swift" ) echo "▶ swift test (--enable-code-coverage)" From a2ce70510225c6134d97987240a8a476064043da Mon Sep 17 00:00:00 2001 From: Dylan LE FLOUR Date: Mon, 21 Sep 2026 17:37:04 +0200 Subject: [PATCH 2/3] feat(recap): carry the Unown letter onto the recap card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recap was branched before Unown forms landed: the raised Pokémon and the graduates fell back to the default A sprite, and the strip named them all "Unown". The export preloads the same form, since ImageRenderer only draws what is already cached. --- Sources/PokeTokenBar/UI/UsageRecapView.swift | 17 +++++++++++------ Tests/PokeTokenBarTests/UsageRecapTests.swift | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Sources/PokeTokenBar/UI/UsageRecapView.swift b/Sources/PokeTokenBar/UI/UsageRecapView.swift index 2d236373..fb46b92e 100644 --- a/Sources/PokeTokenBar/UI/UsageRecapView.swift +++ b/Sources/PokeTokenBar/UI/UsageRecapView.swift @@ -9,6 +9,7 @@ struct RecapContent { let recap: UsageRecap let companionSpeciesID: Int? let companionShiny: Bool + let companionUnownForm: UnownForm? let graduates: [(entry: DexEntry, name: String)] private let calendar: Calendar /// Built once per read: a `DateFormatter` is expensive and the card asks for a label per meter. @@ -28,8 +29,10 @@ struct RecapContent { // usage that just happened, so it shows who lived it. companionSpeciesID = companion.currentSpeciesID companionShiny = companion.currentIsShiny + companionUnownForm = companion.currentUnownForm graduates = recap.graduated.prefix(4).map { entry in - (entry, companion.dexStoredChainNames(entry)?[entry.finalID] ?? "#\(entry.finalID)") + let name = companion.dexStoredChainNames(entry)?[entry.finalID] ?? "#\(entry.finalID)" + return (entry, UnownForm.displayName(name, speciesID: entry.finalID, form: entry.unownForm)) } dayKeyFormatter = LocalUsageReader.localDayFormatter(timeZone: calendar.timeZone) dayNameFormatter = Self.formatter(language, calendar, template: "EEEE d MMM") @@ -312,7 +315,7 @@ struct RecapCard: View { private var graduateStrip: some View { HStack(spacing: 5) { SpriteView(speciesID: content.companionSpeciesID, size: 18, animated: false, - shiny: content.companionShiny) + shiny: content.companionShiny, unownForm: content.companionUnownForm) Rectangle().fill(.white.opacity(0.35)).frame(width: 1, height: 14) if content.graduates.isEmpty { Text(content.l.recapNoGraduates) @@ -323,7 +326,8 @@ struct RecapCard: View { ForEach(content.graduates, id: \.entry.id) { graduate in HStack(spacing: 2) { SpriteView(speciesID: graduate.entry.finalID, size: 18, animated: false, - shiny: graduate.entry.isShiny) + shiny: graduate.entry.isShiny, + unownForm: graduate.entry.unownForm) // A badge on the sprite rather than after the name: four chips share // the row, and a trailing sparkle cut "Charizard" to "Chariza…". .overlay(alignment: .topTrailing) { @@ -442,13 +446,14 @@ enum RecapExport { /// `ImageRenderer` renders in one pass and never runs `.task`, so every sprite has to be in /// the disk cache before the render or the card comes out with holes. private static func preload(_ content: RecapContent) async { - for (speciesID, shiny) in [(content.companionSpeciesID, content.companionShiny)] - + content.graduates.map({ (Optional($0.entry.finalID), $0.entry.isShiny) }) { + for (speciesID, shiny, form) in [(content.companionSpeciesID, content.companionShiny, + content.companionUnownForm)] + + content.graduates.map({ (Optional($0.entry.finalID), $0.entry.isShiny, $0.entry.unownForm) }) { guard let speciesID else { _ = await SpriteLoader.eggImage() continue } - _ = await SpriteLoader.image(speciesID: speciesID, shiny: shiny) + _ = await SpriteLoader.image(speciesID: speciesID, shiny: shiny, unownForm: form) } } diff --git a/Tests/PokeTokenBarTests/UsageRecapTests.swift b/Tests/PokeTokenBarTests/UsageRecapTests.swift index 048e5bda..967dc85a 100644 --- a/Tests/PokeTokenBarTests/UsageRecapTests.swift +++ b/Tests/PokeTokenBarTests/UsageRecapTests.swift @@ -366,4 +366,22 @@ final class UsageRecapRenderingTests: XCTestCase { } } } + + func testTheGraduateStripCarriesTheUnownLetter() throws { + let unown = DexEntry(id: "unown-q", baseID: UnownForm.speciesID, finalID: UnownForm.speciesID, + chainOrder: [UnownForm.speciesID], rarity: .rare, caughtAt: date(2026, 9, 17), + names: [UnownForm.speciesID: ["en": "Unown"]], unownForm: .q) + let dexJSON = String(decoding: try JSONEncoder().encode([unown]), as: UTF8.self) + try Data(#"{"installBaselineSet":true,"usedSinceInstall":1000,"lastDate":"d","language":"en","dex":\#(dexJSON)}"#.utf8) + .write(to: url) + let defaults = UserDefaults(suiteName: "recap-unown-\(UUID().uuidString)")! + ledger(since: key(2026, 9, 1), [(key(2026, 9, 17), 5_000)]).save(to: defaults) + let content = RecapContent(store: UsageStore(providers: [], autoRefresh: false, defaults: defaults), + companion: CompanionStore(fileURL: url, defaults: defaults), + scope: .week, offset: 0, now: now, calendar: calendar()) + + let graduate = try XCTUnwrap(content.graduates.first) + XCTAssertEqual(graduate.entry.unownForm, .q, "the strip would otherwise draw the default A sprite") + XCTAssertEqual(graduate.name, "Unown [Q]") + } } From 3d913bd592a679f48c518b63668036127c082aa9 Mon Sep 17 00:00:00 2001 From: Dylan LE FLOUR Date: Thu, 24 Sep 2026 09:00:25 +0200 Subject: [PATCH 3/3] fix(recap): remove the PNG export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy image and Save PNG composed Pokémon sprites into an exported image, which the copyrighted-asset policy in CONTRIBUTING.md rules out. The recap stays in the app; RecapExport, its sprite preload, the three export strings and the file-name assertion go. --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- Sources/PokeTokenBar/Core/Localization.swift | 3 - Sources/PokeTokenBar/UI/UsageRecapView.swift | 79 +------------------ Tests/PokeTokenBarTests/UsageRecapTests.swift | 1 - 6 files changed, 7 insertions(+), 82 deletions(-) diff --git a/README.ja.md b/README.ja.md index b5138fab..b1ca6f7f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -167,7 +167,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ - **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。 - **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。 - **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。 -- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)、画像のコピーと保存に対応。記録は今年と昨年分をローカルに保存。 +- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)。記録は今年と昨年分をローカルに保存。 ## 対応ツール diff --git a/README.ko.md b/README.ko.md index eab5a3e5..edd8bc75 100644 --- a/README.ko.md +++ b/README.ko.md @@ -167,7 +167,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다 - **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). - **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측. - **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시. -- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업), 이미지 복사·저장 지원. 기록은 올해와 작년분을 로컬에 보관. +- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업). 기록은 올해와 작년분을 로컬에 보관. ## 지원 도구 diff --git a/README.md b/README.md index fbc90cf3..af68c5ca 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne - **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). - **Burn-rate forecast** — projects when the current 5h window hits 100%. - **In-app updates** — one-click update check; current version shown in Settings. -- **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations), with copy and save as PNG. History is kept locally for this year and the last. +- **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations). History is kept locally for this year and the last. ## Works with diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index b660d2c5..fee82d97 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -214,9 +214,6 @@ struct L { "Ninguna graduación en este periodo.", "Aucun diplômé sur cette période.", "Nenhuma formatura neste período.", "Kein Abschluss in diesem Zeitraum.") } - var recapCopy: String { t("이미지 복사", "Copy image", "画像をコピー", "Copiar imagen", "Copier l'image", "Copiar imagem", "Bild kopieren") } - var recapCopied: String { t("복사됨", "Copied", "コピーしました", "Copiado", "Copié", "Copiado", "Kopiert") } - var recapSave: String { t("저장…", "Save…", "保存…", "Guardar…", "Enregistrer…", "Salvar…", "Speichern…") } var generalSectionTitle: String { t("일반", "General", "一般", "General", "Général", "Geral", "Allgemein") } var menuBarSectionTitle: String { t("메뉴바에 표시", "Show in menu bar", "メニューバーに表示", "Mostrar en la barra de menús", "Afficher dans la barre des menus", "Mostrar na barra de menus", "In der Menüleiste anzeigen") } diff --git a/Sources/PokeTokenBar/UI/UsageRecapView.swift b/Sources/PokeTokenBar/UI/UsageRecapView.swift index fb46b92e..fcb0e3b5 100644 --- a/Sources/PokeTokenBar/UI/UsageRecapView.swift +++ b/Sources/PokeTokenBar/UI/UsageRecapView.swift @@ -1,8 +1,6 @@ -import AppKit import SwiftUI -import UniformTypeIdentifiers -/// Everything the recap prints, read once so the live view and the exported image agree. +/// Everything the recap prints, read once. @MainActor struct RecapContent { let language: AppLanguage @@ -51,8 +49,7 @@ struct RecapContent { var l: L { L(language) } var scope: RecapScope { recap.period.scope } - /// "14–20 Sept 2026", "September 2026" or "2026", from the display locale. It titles the card: - /// the exported image has to say which stretch it covers on its own. + /// "14–20 Sept 2026", "September 2026" or "2026", from the display locale. It titles the card. func periodLabel() -> String { switch scope { case .week: @@ -111,13 +108,10 @@ struct RecapContent { return "\(name), \(bucket.hasData ? TokenFormatter.grouped(bucket.tokens) : l.recapNoData)" } - var exportFileName: String { - "recap-\(scope.rawValue)-\(dayKeyFormatter.string(from: recap.period.start)).png" - } } -/// The shareable panel — a Pokédex readout, not a second trainer card: red shell, dark LCD, -/// monospaced digits and segmented meters. Colors are all explicit so the export never shifts +/// The recap panel — a Pokédex readout, not a second trainer card: red shell, dark LCD, +/// monospaced digits and segmented meters. Colors are all explicit so the card never shifts /// with the system appearance. @MainActor struct RecapCard: View { @@ -359,7 +353,6 @@ struct RecapScreen: View { @State private var scope: RecapScope = .week /// 0 is the running period, -1 the one before it. Never positive: the future has no usage. @State private var offset = 0 - @State private var copied = false var body: some View { let l = companion.l @@ -409,73 +402,9 @@ struct RecapScreen: View { } } .font(.caption).foregroundStyle(.secondary) - HStack(spacing: 6) { - Button(copied ? l.recapCopied : l.recapCopy) { - Task { - guard await RecapExport.copy(content) else { return } - copied = true - try? await Task.sleep(for: .seconds(1.5)) - copied = false - } - } - Button(l.recapSave) { - Task { await RecapExport.save(content) } - } - Spacer(minLength: 0) - } - .controlSize(.small) Spacer(minLength: 0) } .padding(PopoverMetrics.padding) .task { await companion.backfillMissingDexNames() } } } - -@MainActor -enum RecapExport { - static func pngData(_ content: RecapContent) async -> Data? { - await preload(content) - let renderer = ImageRenderer(content: RecapCard(content: content).padding(12) - .background(Color(white: 0.9)) - .environment(\.locale, content.language.displayLocale)) - renderer.scale = 4 - guard let image = renderer.cgImage else { return nil } - return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) - } - - /// `ImageRenderer` renders in one pass and never runs `.task`, so every sprite has to be in - /// the disk cache before the render or the card comes out with holes. - private static func preload(_ content: RecapContent) async { - for (speciesID, shiny, form) in [(content.companionSpeciesID, content.companionShiny, - content.companionUnownForm)] - + content.graduates.map({ (Optional($0.entry.finalID), $0.entry.isShiny, $0.entry.unownForm) }) { - guard let speciesID else { - _ = await SpriteLoader.eggImage() - continue - } - _ = await SpriteLoader.image(speciesID: speciesID, shiny: shiny, unownForm: form) - } - } - - @discardableResult - static func copy(_ content: RecapContent) async -> Bool { - guard let data = await pngData(content) else { return false } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setData(data, forType: .png) - return true - } - - static func save(_ content: RecapContent) async { - guard let data = await pngData(content) else { return } - let panel = NSSavePanel() - panel.allowedContentTypes = [UTType.png] - panel.nameFieldStringValue = content.exportFileName - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - try data.write(to: url) - } catch { - AppLog.write("usage recap: save failed — \(error.localizedDescription)") - } - } -} diff --git a/Tests/PokeTokenBarTests/UsageRecapTests.swift b/Tests/PokeTokenBarTests/UsageRecapTests.swift index 967dc85a..677ea39a 100644 --- a/Tests/PokeTokenBarTests/UsageRecapTests.swift +++ b/Tests/PokeTokenBarTests/UsageRecapTests.swift @@ -358,7 +358,6 @@ final class UsageRecapRenderingTests: XCTestCase { XCTAssertFalse(content.periodLabel().isEmpty, "\(language) \(scope)") XCTAssertFalse(content.l.recapCompareSoFar(scope).isEmpty) XCTAssertEqual(content.bucketLabels().count, content.recap.buckets.count) - XCTAssertTrue(content.exportFileName.hasPrefix("recap-\(scope.rawValue)-")) let renderer = ImageRenderer(content: RecapCard(content: content)) renderer.scale = 1 let image = try XCTUnwrap(renderer.cgImage, "\(language) \(scope)")