Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ
- **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。
- **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。
- **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。
- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)。記録は今年と昨年分をローカルに保存。

## 対応ツール

Expand Down
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다
- **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정).
- **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측.
- **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시.
- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업). 기록은 올해와 작년분을 로컬에 보관.

## 지원 도구

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). History is kept locally for this year and the last.

## Works with

Expand Down
50 changes: 50 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,56 @@ 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 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") }
Expand Down
233 changes: 233 additions & 0 deletions Sources/PokeTokenBar/Core/UsageRecap.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading