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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- Archive quota only from successful original provider results with a capture timestamp, never from mixed UI fallbacks. Archive tokens independently after a successful activity read. Preserve explicit zero days; missing days remain unknown. Never fill missing Claude days from an empty scan.
- Effort scans run in the bounded `TokenGaugeCapture --record-effort-history` subprocess, never the resident UI. Decode only allowlisted metadata; hash response IDs before storage. Deduplicate events using MAX counters and preserve known model/effort values. The daily total is MAX(provider aggregate, summed model rows, summed effort events), never a sum of overlapping sources. Effort events and daily totals have no expiry.
- Weekly pace requires at least three verified, same-reset observations spanning 30 minutes within the last hour, with a latest reading no older than 20 minutes. Reset, decrease, invalid reading, or a gap over 30 minutes breaks continuity. Reset timestamps may vary by at most one second against a persisted continuity anchor; larger changes break continuity. Legacy quota rows remain unverified. Archive valid estimates in `pace_samples` without expiry, including idle readings; only newly increased usage updates the last active pace. A current pace must match both the latest verified quota timestamp in SQLite and the visible snapshot capture timestamp. Show retained pace as historical with its measurement date, never as a fresh reading. Never attribute quota pace to a reasoning effort from token counts.
- Calendar reset markers group coincident weekly quotas per provider. The next live date is confirmed; later weeks project the same seven-day interval for a year and are labelled expected. Only ready, visible quotas contribute; cancellation or unavailable data removes the forecast. Future reset cards omit activity and streaks.
- History queries run off the main actor and load only the selected date range; hover and selection never query SQLite or scan logs. Calendar weeks begin Monday. Unknown days must not become zero-use days. Preview/test stores must never read or write the real user history.
- History hover updates the selected detail only on actual pointer movement; layout/scroll/refresh must not select the date beneath a stationary cursor. The annual view centers today on entry and on Today, preserves the viewport during refresh, and highlights today independently of selection. The native calendar positions its canvas synchronously before drawing. Verify real hover, opening, past-date selection, Today and refresh; suppress gutters with `.scrollIndicators(.never)` in SwiftUI and native scroller flags in the calendar.
- Query the history with `scripts/usage_history.sh {status|daily|models|weekly|monthly|quota|export|sql}`; the `daily_totals`, `weekly_totals` (ISO week Monday) and `monthly_totals` views exist so both Steven and an agent read the same aggregation.
Expand All @@ -44,7 +45,7 @@
- No continuous menu bar or hidden-popover animations. The seven-day chart uses one or two native bars per day according to display mode and per-day hover/click targets; hidden providers never affect its legend, summary or scale; never rebuild its data for every cursor pixel. Skip status-item image/title assignment when unchanged.
- Panel style and menu-bar style persist independently; existing installs default to classic panels and numeric indicators. Compact/ring styles retain exact quota and reset data, historical provenance, and provider/window selection. Graphics never show stale values as live; exact menu values remain in tooltip/accessibility. Animations are brief, event-driven, optional, and honor Reduce Motion.
- Use the native segmented control for history modes so selection and labels share one renderer. Year/week transitions must be checked in actual pixels after parent resizing; aligned accessibility frames alone cannot prove that the selection background stayed attached.
- Ring grids must cap columns at the number of visible windows and distribute the occupied row across the card. Two quotas must never leave a reserved third slot; cover this with a wide two-cell render check. Unified ring width follows visible quotas within its bounds.
- Ring grids must cap columns at the number of visible windows and distribute the occupied row across the card. Two quotas must never leave a reserved third slot; cover this with a wide two-cell render check. Unified ring width follows visible quotas up to 604 pt, enough for one Codex ring and three Claude rings in a single row.
- Centralize visual constants in `Theme.swift`; individual panels are 340 pt wide and Unified is 560 pt wide; the panel grows to fit its content, with the layout matrix verified below 700 pt. Never wrap the entire popover in a vertical scroll view; only unusually long provider lists scroll within their own bounded area. Individual views show only their own provider; Unified shows two titled cards side by side. Scroll only when real provider content exceeds its bounded height.
- The popover closes on any click outside it: `.transient` alone does not dismiss an accessory app's popover when the click lands in another application (Steven, 2026-08-27). `StatusItemController` arms a global mouse-down monitor plus `didResignActiveNotification` while the popover is shown and tears both down in `popoverDidClose`. Keep the monitor to MOUSE events only — a global key monitor would demand Accessibility, which this app must never request.
- Reset lines scale with distance: under an hour shows minutes plus the clock time, under a day shows hours plus the clock time, the next calendar day shows `mañana` plus the clock time, and anything further shows whole days only. Far distances count the real remaining duration, never midnight crossings — 3.4 days away reads `3 d`, not `4 d`.
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.0] - 2026-09-20

### Added

- Weekly reset dates in the activity calendar, with provider colors, local times, and expected dates for later weeks. Cancelling tracking removes that provider's forecast.

### Changed

- Coincident weekly quotas share a compact reset card without repeated labels or empty activity details.
- The unified ring panel fits one Codex ring alongside three Claude quota rings.
- Current and best streaks appear above the calendar, keeping the daily summary focused on usage.

### Fixed

- Automatic reconnection aligns with the other provider settings switches.

## [1.4.0] - 2026-09-18

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Code

<details>
<summary>Classic panels, yearly history, and Settings</summary>
<p align="center"><img src="docs/images/history-calendar-rings.png" width="440" alt="Yearly local activity calendar beneath the ring quota panel"></p>
<p align="center"><img src="docs/images/weekly-resets.png" width="440" alt="Weekly reset forecasts and streaks above the local activity calendar"></p>
<p align="center"><img src="docs/images/unified.png" width="560" alt="Classic Unified panel with separate provider quotas"></p>
<p align="center">
<img src="docs/images/panel.png" width="340" alt="Codex view showing only Codex quota and token activity">
Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.4.0</string>
<string>1.5.0</string>
<key>CFBundleVersion</key>
<string>5</string>
<string>7</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
4 changes: 4 additions & 0 deletions Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,7 @@
"pace.info_retention" = "When no new usage is recorded, the last active pace stays available with its date. Measurements are stored only on this Mac.";
"pace.info_saved" = "Showing the last measurement with usage while waiting for new data.";
"account.current" = "Account: %@";

"history.weekly_reset" = "Weekly reset";
"history.projected" = "Expected";
"history.projection_help" = "Later weeks follow the current schedule. Dates update with each reading and disappear when tracking is cancelled.";
4 changes: 4 additions & 0 deletions Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,7 @@
"pace.info_retention" = "Si no se registra consumo nuevo, conservamos el último ritmo activo con su fecha. Las mediciones se guardan solo en este Mac.";
"pace.info_saved" = "Mostramos la última medición con consumo mientras llegan datos nuevos.";
"account.current" = "Cuenta: %@";

"history.weekly_reset" = "Reinicio semanal";
"history.projected" = "Previsto";
"history.projection_help" = "Las semanas siguientes se calculan con el horario actual. Se actualizan con cada lectura y se ocultan al cancelar el seguimiento.";
38 changes: 38 additions & 0 deletions Sources/TokenGaugeApp/Support/HistoryReset.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import TokenGaugeCore

struct HistoryReset: Identifiable, Equatable {
let provider: UsageProvider
let date: Date
var isProjected = false
var id: String { provider.rawValue + ":" + String(date.timeIntervalSince1970) }

static func upcoming(
provider: UsageProvider, windows: [QuotaWindow], now: Date = Date(), through end: Date? = nil
) -> [Self] {
let end = end ?? now.addingTimeInterval(366 * 86400)
let dates = windows.compactMap { window -> Date? in
guard window.durationMinutes == 10080, let date = window.resetsAt, date > now else { return nil }
return date
}.sorted()
var result: [Self] = []
for start in dates {
var date = start
while date <= max(end, start) {
if let index = result.firstIndex(where: { abs($0.date.timeIntervalSince(date)) < 60 }) {
if date == start { result[index].isProjected = false }
} else {
result.append(Self(provider: provider, date: date, isProjected: date != start))
}
date = date.addingTimeInterval(7 * 86400)
}
}
return result.sorted { $0.date < $1.date }
}

@MainActor var label: String {
let locale = Locale(identifier: LocalizationManager.shared.language.rawValue)
let name = (provider == .codex ? "provider.codex" : "provider.claude_short").localized
return name + " · " + date.formatted(.dateTime.hour().minute().locale(locale))
}
}
2 changes: 1 addition & 1 deletion Sources/TokenGaugeApp/Support/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ enum Theme {
static let compactPanelWidth: CGFloat = 320
static let compactUnifiedWidth: CGFloat = 360
static let ringPanelWidth: CGFloat = 360
static let ringUnifiedWidth: CGFloat = 560
static let ringUnifiedWidth: CGFloat = 604
static let activityChartHeight: CGFloat = 64
static let historyCalendarGridTop: CGFloat = 22
static let historyCalendarHeight: CGFloat = historyCalendarGridTop + 87
Expand Down
43 changes: 38 additions & 5 deletions Sources/TokenGaugeApp/Views/HistoryCalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct HistoryCalendarView: NSViewRepresentable {
let focusID: String
var hoverEnabled = true
var accent: Color = Color(nsColor: .labelColor)
var resets: [HistoryReset] = []
var pinnedDayKey: String?
var pinSafeFrames: [CGRect] = []
var onHoverCard: @MainActor (String?, HistoryDayCardAnchor?) -> Void = { _, _ in }
Expand Down Expand Up @@ -83,6 +84,8 @@ final class HistoryCalendarScrollView: NSScrollView {
final class HistoryCalendarCanvas: NSView {
private var days: [HistoryCalendarDay] = []
private var providers: [UsageProvider] = []
private var resets: [HistoryReset] = []
private var resetsByDay: [String: [HistoryReset]] = [:]
private var selected: String?
private var pinned: String?
private var hoverEnabled = true
Expand Down Expand Up @@ -137,13 +140,16 @@ final class HistoryCalendarCanvas: NSView {
let newToday = Calendar.current.startOfDay(for: Date())
let changed =
providers != value.providers || locale != language || today != newToday
|| resets != value.resets
|| accentColor != value.accent
|| days.count != value.days.count
|| !zip(days, value.days).allSatisfy { $0.id == $1.id && $0.date == $1.date && $0.totals == $1.totals }
let selectionChanged = selected != value.selectedDayKey
selected = value.selectedDayKey
guard changed || selectionChanged else { return }
if changed {
resets = value.resets
resetsByDay = Dictionary(grouping: resets) { HistoryDashboardModel.dayKey($0.date) }
days = value.days
providers = value.providers
accentColor = value.accent
Expand Down Expand Up @@ -286,6 +292,26 @@ final class HistoryCalendarCanvas: NSView {
NSColor.labelColor.setFill()
NSBezierPath(ovalIn: rect.insetBy(dx: 3, dy: 3)).fill()
}
let resetProviders = providers.filter { provider in
(resetsByDay[day.id] ?? []).contains { $0.provider == provider }
}
if !resetProviders.isEmpty {
for (part, provider) in resetProviders.enumerated() {
NSGraphicsContext.saveGraphicsState()
NSBezierPath(
rect: NSRect(
x: rect.minX - 1 + CGFloat(part) * (rect.width + 2) / CGFloat(resetProviders.count),
y: rect.minY - 1, width: (rect.width + 2) / CGFloat(resetProviders.count),
height: rect.height + 2)
).addClip()
let projected = (resetsByDay[day.id] ?? []).filter { $0.provider == provider }.allSatisfy(
\.isProjected)
color(provider).withAlphaComponent(projected ? 0.55 : 1).setStroke()
path.lineWidth = projected ? 1.5 : 2
path.stroke()
NSGraphicsContext.restoreGraphicsState()
}
}
}
}

Expand Down Expand Up @@ -334,7 +360,7 @@ final class HistoryCalendarCanvas: NSView {

override func mouseDown(with event: NSEvent) {
guard let index = index(at: convert(event.locationInWindow, from: nil)), days.indices.contains(index),
days[index].date <= today
isEnabled(at: index)
else {
onDismissCard?()
cancelHoverCard()
Expand Down Expand Up @@ -402,7 +428,7 @@ final class HistoryCalendarCanvas: NSView {
guard hoverIndex != index else { return }
cancelHoverCard()
hoverIndex = index
guard days.indices.contains(index), days[index].date <= today else { return }
guard days.indices.contains(index), isEnabled(at: index) else { return }
let key = days[index].id
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated {
Expand Down Expand Up @@ -430,7 +456,7 @@ final class HistoryCalendarCanvas: NSView {
}

func select(_ index: Int) {
guard days.indices.contains(index), days[index].date <= today, days[index].id != selected else { return }
guard days.indices.contains(index), isEnabled(at: index), days[index].id != selected else { return }
onSelect?(days[index].id)
}

Expand All @@ -453,11 +479,18 @@ final class HistoryCalendarCanvas: NSView {
} else {
usage = ""
}
return prefix + date + ": " + totals + usage
let resetText = (resetsByDay[day.id] ?? [])
.map {
"history.weekly_reset".localized + " · " + $0.label
+ ($0.isProjected ? " · " + "history.projected".localized : "")
}.joined(separator: "\n")
return prefix + date + ": " + totals + usage + (resetText.isEmpty ? "" : "\n" + resetText)
}

func accessibilityLabel(at index: Int) -> String { descriptions.indices.contains(index) ? descriptions[index] : "" }
func isEnabled(at index: Int) -> Bool { days.indices.contains(index) && days[index].date <= today }
func isEnabled(at index: Int) -> Bool {
days.indices.contains(index) && (days[index].date <= today || resetsByDay[days[index].id] != nil)
}
func isSelected(at index: Int) -> Bool { days.indices.contains(index) && days[index].id == selected }
}

Expand Down
44 changes: 30 additions & 14 deletions Sources/TokenGaugeApp/Views/HistoryDayCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum HistoryDayCardPlacement {
struct HistoryDayCardView: View {
let day: HistoryCalendarDay
let providers: [UsageProvider]
var resets: [HistoryReset] = []
let isToday: Bool
let isPinned: Bool
let currentStreak: Int
Expand All @@ -51,22 +52,37 @@ struct HistoryDayCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
if rows.isEmpty {
Text("history.no_activity".localized).font(.system(size: 10)).foregroundStyle(.secondary)
} else {
let percentages = HistoryDayCardPlacement.percentages(rows.map { day.tokens(for: $0) ?? 0 })
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(rows.enumerated()), id: \.element) { index, provider in
providerRow(provider, percentage: percentages[index])
if !resets.isEmpty {
Text("history.weekly_reset".localized)
.font(.system(size: 10)).foregroundStyle(.secondary)
ForEach(resets) { reset in
HStack(spacing: 6) {
ProviderLogo(provider: reset.provider, size: 13)
Text(reset.label).font(.system(size: 11, weight: .medium))
if reset.isProjected {
Text("history.projected".localized).font(.system(size: 9)).foregroundStyle(.secondary)
}
}
}
}
Divider()
Text(
"history.streak_current".localized(String(currentStreak)) + " · "
+ "history.streak_longest".localized(String(longestStreak))
)
.font(.system(size: 10)).foregroundStyle(.secondary)
if day.date <= Date() {
if rows.isEmpty {
Text("history.no_activity".localized).font(.system(size: 10)).foregroundStyle(.secondary)
} else {
let percentages = HistoryDayCardPlacement.percentages(rows.map { day.tokens(for: $0) ?? 0 })
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(rows.enumerated()), id: \.element) { index, provider in
providerRow(provider, percentage: percentages[index])
}
}
}
Divider()
Text(
"history.streak_current".localized(String(currentStreak)) + " · "
+ "history.streak_longest".localized(String(longestStreak))
)
.font(.system(size: 10)).foregroundStyle(.secondary)
}
}
.padding(11)
.frame(width: HistoryDayCardPlacement.width, alignment: .leading)
Expand All @@ -79,7 +95,7 @@ struct HistoryDayCardView: View {

private var header: some View {
HStack(spacing: 6) {
Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.wide).year().locale(locale)))
Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.abbreviated).locale(locale)))
.font(.system(size: 11, weight: .semibold)).lineLimit(2).fixedSize(horizontal: false, vertical: true)
if isToday {
Text("history.today".localized)
Expand Down
Loading
Loading