diff --git a/AGENTS.md b/AGENTS.md index adb452b..ad7b2cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 02fde68..4052910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5c7cd3f..7028bc0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Code
Classic panels, yearly history, and Settings -

Yearly local activity calendar beneath the ring quota panel

+

Weekly reset forecasts and streaks above the local activity calendar

Classic Unified panel with separate provider quotas

Codex view showing only Codex quota and token activity diff --git a/Resources/Info.plist b/Resources/Info.plist index dc825e0..f3ed138 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.5.0 CFBundleVersion - 5 + 7 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings index f9a74d5..7c6fc8d 100644 --- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings @@ -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."; diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings index 360a00a..326d604 100644 --- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings +++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings @@ -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."; diff --git a/Sources/TokenGaugeApp/Support/HistoryReset.swift b/Sources/TokenGaugeApp/Support/HistoryReset.swift new file mode 100644 index 0000000..4075e9e --- /dev/null +++ b/Sources/TokenGaugeApp/Support/HistoryReset.swift @@ -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)) + } +} diff --git a/Sources/TokenGaugeApp/Support/Theme.swift b/Sources/TokenGaugeApp/Support/Theme.swift index a8af7f4..a355292 100644 --- a/Sources/TokenGaugeApp/Support/Theme.swift +++ b/Sources/TokenGaugeApp/Support/Theme.swift @@ -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 diff --git a/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift b/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift index b062dc4..d9f8301 100644 --- a/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift +++ b/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift @@ -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 } @@ -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 @@ -137,6 +140,7 @@ 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 } @@ -144,6 +148,8 @@ final class HistoryCalendarCanvas: NSView { 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 @@ -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() + } + } } } @@ -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() @@ -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 { @@ -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) } @@ -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 } } diff --git a/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift b/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift index e6f116f..6d40b45 100644 --- a/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift +++ b/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift @@ -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 @@ -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) @@ -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) diff --git a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift index 5df66aa..c527077 100644 --- a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift +++ b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift @@ -6,6 +6,7 @@ struct HistoryPanelView: View { @ObservedObject var model: HistoryDashboardModel @Binding var mode: HistoryMode let providers: [UsageProvider] + var resets: [HistoryReset] = [] var compact = false @State private var calendarFocusRequest = 0 @State private var showingDetails = false @@ -26,7 +27,7 @@ struct HistoryPanelView: View { private var calendarProviders: [UsageProvider] { let recorded = [UsageProvider.codex, .claude].filter { provider in - model.days.contains { $0.tokens(for: provider) != nil } + model.days.contains { $0.tokens(for: provider) != nil } || resets.contains { $0.provider == provider } } return recorded.isEmpty ? [.codex, .claude] : recorded } @@ -67,14 +68,12 @@ struct HistoryPanelView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - if mode == .recent { - Text("history.heading".localized).font(.system(size: 11, weight: .semibold)) - } else { - navigation + ViewThatFits(in: .horizontal) { + historyToolbar(showsStreak: true) + VStack(alignment: .leading, spacing: 3) { + historyToolbar(showsStreak: false) + streakBadge } - Spacer(minLength: 0) - modePicker } if model.loadFailed { Text("history.load_failed".localized).font(.caption).foregroundStyle(.secondary) @@ -91,6 +90,7 @@ struct HistoryPanelView: View { } .animation(motion, value: model.loadedMode) .animation(motion, value: model.periodStart) + resetLegend selectionSummary } } @@ -100,6 +100,7 @@ struct HistoryPanelView: View { .onPreferenceChange(HistoryFrameKey.self) { frames = $0 } .onPreferenceChange(HistoryDayCardHeightKey.self) { if $0 > 0 { cardHeight = $0 } } .onChange(of: providers) { _, _ in dismissDayCard() } + .onChange(of: resets) { _, _ in dismissDayCard() } .onChange(of: mode) { _, newValue in model.offset = 0 model.selectedDayKey = nil @@ -118,13 +119,42 @@ struct HistoryPanelView: View { } } + private func historyToolbar(showsStreak: Bool) -> some View { + HStack(spacing: 8) { + if mode == .recent { + Text("history.heading".localized).font(.system(size: 11, weight: .semibold)) + .fixedSize() + } else { + navigation + } + Spacer(minLength: 0) + if showsStreak { + streakBadge + Spacer(minLength: 0) + } + modePicker + } + } + + private var streakBadge: some View { + HStack(spacing: 4) { + Image(systemName: "flame.fill").foregroundStyle(accent) + Text(streakLine).foregroundStyle(.secondary) + } + .font(.system(size: 9, weight: .medium)) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(Capsule().fill(Color.primary.opacity(0.045))) + .fixedSize() + .accessibilityElement(children: .ignore).accessibilityLabel(streakLine) + } + @ViewBuilder private var dayCard: some View { if let selection = model.daySelection, let frame = cardFrame, let panel = frames[.panel], let day = model.days.first(where: { $0.id == selection.dayKey }) { let streak = model.streak(for: providers) HistoryDayCardView( - day: day, providers: calendarProviders, + day: day, providers: calendarProviders, resets: resetsOn(day), isToday: Calendar.current.isDate(day.date, inSameDayAs: today), isPinned: selection.isPinned, currentStreak: streak.current, longestStreak: streak.longest, onClose: dismissDayCard @@ -232,6 +262,17 @@ struct HistoryPanelView: View { .font(.system(size: 9, weight: selectedID == day.id ? .semibold : .regular)) .foregroundStyle(selectedID == day.id ? .primary : .secondary) .frame(height: 18).frame(maxWidth: .infinity) + .overlay(alignment: .top) { + HStack(spacing: 2) { + ForEach( + providers.filter { provider in resetsOn(day).contains { $0.provider == provider } }, + id: \.self + ) { provider in + RoundedRectangle(cornerRadius: 1).stroke(color(provider), lineWidth: 1) + .frame(width: 4, height: 4) + } + }.offset(y: -3) + } .background { if selectedID == day.id { RoundedRectangle(cornerRadius: 5).fill(accent.opacity(0.14)) @@ -245,7 +286,7 @@ struct HistoryPanelView: View { .frame(maxWidth: .infinity).contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(day.date > today) + .disabled(day.date > today && resetsOn(day).isEmpty) .onContinuousHover { phase in guard case .active = phase else { return } let pointer = NSEvent.mouseLocation @@ -280,7 +321,7 @@ struct HistoryPanelView: View { days: model.days, providers: calendarProviders, selectedDayKey: model.selectedDay?.id, focusID: "\(model.periodStart.timeIntervalSince1970):\(calendarFocusRequest)", hoverEnabled: !showingDetails && !model.isLoading, - accent: accent, pinnedDayKey: pinnedDayKey, pinSafeFrames: pinSafeFrames, + accent: accent, resets: resets, pinnedDayKey: pinnedDayKey, pinSafeFrames: pinSafeFrames, onHoverCard: { key, anchor in guard let key else { model.hideDayCard() @@ -306,6 +347,38 @@ struct HistoryPanelView: View { .frame(height: Theme.Layout.historyCalendarHeight) } + private func resetsOn(_ day: HistoryCalendarDay) -> [HistoryReset] { + resets.filter { Calendar.current.isDate($0.date, inSameDayAs: day.date) } + } + + @ViewBuilder private var resetLegend: some View { + if !resets.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text("history.weekly_reset".localized) + .font(.system(size: 9, weight: .medium)).foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 8) { + ForEach(providers, id: \.self) { provider in + if let reset = resets.first(where: { $0.provider == provider }) { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 1).stroke(color(provider), lineWidth: 1.5) + .frame(width: 6, height: 6) + Text( + name(provider) + " · " + + reset.date.formatted( + .dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute() + .locale(locale)) + ) + .font(.system(size: 9)).lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .help("history.projection_help".localized) + } + } + } + } + } + } + private func weekdayLabel(_ weekday: Int) -> String { var calendar = Calendar.current calendar.locale = locale @@ -313,15 +386,15 @@ struct HistoryPanelView: View { } @ViewBuilder private var selectionSummary: some View { - if let day = model.selectedDay { + if let selected = model.selectedDay, + let day = selected.date > today ? model.days.first(where: { $0.date == today }) : selected + { VStack(alignment: .leading, spacing: 7) { HStack { VStack(alignment: .leading, spacing: 2) { Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.abbreviated).locale(locale))) .font(.system(size: 10)).foregroundStyle(.secondary) .contentTransition(.opacity) - Text(streakLine) - .font(.system(size: 10)).foregroundStyle(.secondary) } todayBadge .opacity(Calendar.current.isDate(day.date, inSameDayAs: today) ? 1 : 0) @@ -420,7 +493,9 @@ struct HistoryPanelView: View { } private func select(_ day: HistoryCalendarDay) { - guard !showingDetails, !model.isLoading, day.date <= today, model.selectedDayKey != day.id else { return } + guard !showingDetails, !model.isLoading, (day.date <= today || !resetsOn(day).isEmpty), + model.selectedDayKey != day.id + else { return } withAnimation(motion) { model.selectedDayKey = day.id } } @@ -429,7 +504,7 @@ struct HistoryPanelView: View { let totals = providers.map { provider in name(provider) + " " + (day.tokens(for: provider).map(exact) ?? "history.unknown".localized) }.joined(separator: " · ") - return date + ": " + totals + return date + ": " + totals + resetsOn(day).map { " · " + $0.label }.joined() } private var streakLine: String { diff --git a/Sources/TokenGaugeApp/Views/PopoverView.swift b/Sources/TokenGaugeApp/Views/PopoverView.swift index 8f5b1c6..0fde5ec 100644 --- a/Sources/TokenGaugeApp/Views/PopoverView.swift +++ b/Sources/TokenGaugeApp/Views/PopoverView.swift @@ -76,7 +76,7 @@ struct PopoverView: View { .animation(store.animateChanges && !reduceMotion ? Theme.Motion.content : nil, value: store.panelStyle) HistoryPanelView( model: history, mode: $store.historyMode, - providers: store.displayMode.providers, compact: store.panelStyle != .standard) + providers: store.displayMode.providers, resets: upcomingResets, compact: store.panelStyle != .standard) } .padding(.horizontal, Theme.Layout.panelPadding) .padding(.top, 12) @@ -87,6 +87,17 @@ struct PopoverView: View { .id(localization.language) } + var upcomingResets: [HistoryReset] { + store.displayMode.providers.flatMap { provider in + let state = store.state(for: provider) + guard state.status == .ready, let snapshot = state.snapshot else { return [HistoryReset]() } + let windows = WindowVisibility.visible( + snapshot.windows, provider: provider, showLunaReserve: store.showLunaReserve, + hiddenClaudeWindows: store.hiddenClaudeWindows) + return HistoryReset.upcoming(provider: provider, windows: windows) + }.sorted { $0.date < $1.date } + } + private var providerPicker: some View { HStack(spacing: 3) { ForEach(UsageDisplayMode.allCases) { mode in diff --git a/Sources/TokenGaugeApp/Views/SettingsView.swift b/Sources/TokenGaugeApp/Views/SettingsView.swift index 01bd78e..5c5b0bb 100644 --- a/Sources/TokenGaugeApp/Views/SettingsView.swift +++ b/Sources/TokenGaugeApp/Views/SettingsView.swift @@ -228,8 +228,13 @@ struct SettingsView: View { } else { Divider() VStack(alignment: .leading, spacing: 8) { - Toggle("settings.claude_recovery".localized, isOn: $store.claudeAutomaticRecovery) - .toggleStyle(.switch).controlSize(.small) + HStack(spacing: 12) { + Text("settings.claude_recovery".localized).font(.callout) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Toggle("settings.claude_recovery".localized, isOn: $store.claudeAutomaticRecovery) + .labelsHidden().toggleStyle(.switch).controlSize(.small) + }.frame(minHeight: 24) Text("settings.claude_recovery_help".localized) .font(.caption).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Tests/TokenGaugeAppTests/HistoryResetTests.swift b/Tests/TokenGaugeAppTests/HistoryResetTests.swift new file mode 100644 index 0000000..c72b6d7 --- /dev/null +++ b/Tests/TokenGaugeAppTests/HistoryResetTests.swift @@ -0,0 +1,100 @@ +import AppKit +import TokenGaugeCore +import XCTest + +@testable import TokenGaugeApp + +@MainActor +final class HistoryResetTests: XCTestCase { + func testOnlyKnownFutureWeeklyResetsAreScheduled() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let future = now.addingTimeInterval(86400) + let windows = [ + Fixture.window(id: "expired", resetsAt: now, durationMinutes: 10080), + Fixture.window(id: "unknown", durationMinutes: 10080), + Fixture.window(id: "session", resetsAt: future, durationMinutes: 300), + Fixture.window(id: "weekly", resetsAt: future, durationMinutes: 10080), + ] + let resets = HistoryReset.upcoming(provider: .codex, windows: windows, now: now, through: future) + XCTAssertEqual(resets.map(\.date), [future]) + } + + func testSharedWeeklyQuotasProduceOneResetPerWeek() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let first = now.addingTimeInterval(3600) + let windows = ["weekly", "fable"].map { + Fixture.window(id: $0, resetsAt: first, durationMinutes: 10080) + } + let resets = HistoryReset.upcoming( + provider: .claude, windows: windows, now: now, + through: first.addingTimeInterval(21 * 86400)) + XCTAssertEqual(resets.count, 4) + XCTAssertEqual(resets.map(\.isProjected), [false, true, true, true]) + XCTAssertEqual(Set(resets.map(\.id)).count, 4) + XCTAssertEqual(resets.last?.date, first.addingTimeInterval(21 * 86400)) + XCTAssertTrue(HistoryReset.upcoming(provider: .claude, windows: [], now: now).isEmpty) + } + + func testDifferentWeeklySchedulesRemainSeparateAndConfirmedDatesWin() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let first = now.addingTimeInterval(3600) + let next = first.addingTimeInterval(7 * 86400) + let windows = [ + Fixture.window(id: "general", resetsAt: first, durationMinutes: 10080), + Fixture.window(id: "reserve", resetsAt: next, durationMinutes: 10080), + Fixture.window(id: "different", resetsAt: first.addingTimeInterval(86400), durationMinutes: 10080), + ] + let resets = HistoryReset.upcoming(provider: .codex, windows: windows, now: now, through: next) + XCTAssertEqual(resets.count, 3) + XCTAssertTrue(resets.allSatisfy { !$0.isProjected }) + } + + func testCancellationRemovesOnlyThatProvidersEntireForecast() throws { + try withDefaults { defaults in + let snapshots = UsageProvider.allCases.map { provider in + Fixture.snapshot( + provider, + windows: [ + Fixture.window( + id: provider == .codex ? "codex.primary" : "seven_day", + resetsAt: Date().addingTimeInterval(86400), durationMinutes: 10080) + ]) + } + let store = Fixture.store(defaults: defaults, snapshots: snapshots) + store.displayMode = .unified + let view = PopoverView(store: store, showSettings: {}, showAbout: {}) + XCTAssertGreaterThan(view.upcomingResets.filter { $0.provider == .claude }.count, 3) + store.setCancelled(true, for: .claude) + XCTAssertFalse(view.upcomingResets.contains { $0.provider == .claude }) + XCTAssertGreaterThan(view.upcomingResets.filter { $0.provider == .codex }.count, 3) + store.setCancelled(true, for: .codex) + XCTAssertTrue(view.upcomingResets.isEmpty) + } + } + + func testFutureResetCanBeSelectedAndRefreshRemovesItsMarker() throws { + let calendar = Calendar.current + let tomorrow = try XCTUnwrap(calendar.date(byAdding: .day, value: 1, to: Date())) + let date = calendar.startOfDay(for: tomorrow) + let day = HistoryCalendarDay(id: HistoryDashboardModel.dayKey(date), date: date, totals: [:], efforts: []) + let resets = [UsageProvider.codex, .claude].map { + HistoryReset(provider: $0, date: tomorrow) + } + let canvas = HistoryCalendarCanvas() + var selected: String? + canvas.update( + HistoryCalendarView( + days: [day], providers: [.codex, .claude], selectedDayKey: nil, focusID: "test", resets: resets + ) { selected = $0 }) + XCTAssertTrue(canvas.isEnabled(at: 0)) + XCTAssertTrue(canvas.accessibilityLabel(at: 0).contains("Codex")) + XCTAssertTrue(canvas.accessibilityLabel(at: 0).contains("Claude")) + canvas.select(0) + XCTAssertEqual(selected, day.id) + canvas.update( + HistoryCalendarView( + days: [day], providers: [.codex, .claude], selectedDayKey: nil, focusID: "test" + ) { selected = $0 }) + XCTAssertFalse(canvas.isEnabled(at: 0)) + } +} diff --git a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift index b47fe3b..12160a3 100644 --- a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift +++ b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift @@ -138,7 +138,10 @@ final class PopoverLayoutTests: XCTestCase { let view = NSHostingView(rootView: PopoverView(store: store, showSettings: {}, showAbout: {})) let budget = Theme.Layout.maximumPanelHeight + UpdateBannerView.height(for: UpdateManager.shared.phase) XCTAssertGreaterThanOrEqual(view.fittingSize.width, Theme.Layout.compactPanelWidth, file: file, line: line) - XCTAssertLessThanOrEqual(view.fittingSize.width, Theme.Layout.unifiedPanelWidth, file: file, line: line) + XCTAssertLessThanOrEqual( + view.fittingSize.width, + store.panelStyle == .rings ? Theme.Layout.ringUnifiedWidth : Theme.Layout.unifiedPanelWidth, + file: file, line: line) XCTAssertLessThanOrEqual( view.fittingSize.height, budget, "\(LocalizationManager.shared.language.rawValue) / \(store.displayMode.rawValue) / \(store.panelStyle.rawValue) / \(UpdateManager.shared.phase)", diff --git a/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift b/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift index c94885d..8185c57 100644 --- a/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift +++ b/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift @@ -42,8 +42,8 @@ final class QuotaRingLayoutTests: XCTestCase { (individual, 3, .rows), (narrowCard, 1, .single), (narrowCard, 2, .rows), - (halfCard, 2, .rows), - (wideCard, 3, .rows), + (halfCard, 2, .grid(columns: 2)), + (wideCard, 3, .grid(columns: 3)), (wideCard, 2, .grid(columns: 2)), ] for (width, windows, mode) in matrix { @@ -75,6 +75,14 @@ final class QuotaRingLayoutTests: XCTestCase { } } + func testSingleCodexAndThreeClaudeWindowsFitACompleteGrid() { + let width = QuotaRingLayout.unifiedPanelWidth(codexCells: 1, claudeCells: 3) + let claude = + width - Theme.Layout.panelPadding * 2 - spacing + - Theme.Layout.minimumRingCardWidth - Theme.Layout.cardPadding * 2 + XCTAssertEqual(QuotaRingLayout.choose(availableWidth: claude, windows: 3), .grid(columns: 3)) + } + func testUnifiedPanelWidthStaysWithinItsBounds() { XCTAssertEqual( QuotaRingLayout.unifiedPanelWidth(codexCells: 1, claudeCells: 1), Theme.Layout.minimumRingUnifiedWidth) diff --git a/Tests/TokenGaugeAppTests/ScreenshotTests.swift b/Tests/TokenGaugeAppTests/ScreenshotTests.swift index 276da8f..6ba9513 100644 --- a/Tests/TokenGaugeAppTests/ScreenshotTests.swift +++ b/Tests/TokenGaugeAppTests/ScreenshotTests.swift @@ -113,6 +113,26 @@ final class ScreenshotTests: XCTestCase { } } + LocalizationManager.shared.language = .spanish + store.displayMode = .unified + store.panelStyle = .rings + store.historyMode = .calendar + store.showLunaReserve = false + let resetHistory = HistoryDashboardModel(previewSnapshots: snapshots, mode: .calendar, now: now) + try render( + PopoverView(store: store, showSettings: {}, showAbout: {}, history: resetHistory), + to: output.appendingPathComponent("weekly-resets-three-claude.png")) + let resetDate = now.addingTimeInterval(21 * 86400) + try render( + HistoryDayCardView( + day: HistoryCalendarDay( + id: HistoryDashboardModel.dayKey(resetDate), date: resetDate, + totals: [:], efforts: []), providers: [.codex, .claude], + resets: [HistoryReset(provider: .claude, date: resetDate, isProjected: true)], + isToday: false, isPinned: true, currentStreak: 37, longestStreak: 51, onClose: {}), + to: output.appendingPathComponent("weekly-reset-card.png")) + store.showLunaReserve = true + LocalizationManager.shared.language = .english for historyMode in [HistoryMode.week, .calendar] { store.historyMode = historyMode diff --git a/docs/images/weekly-resets.png b/docs/images/weekly-resets.png new file mode 100644 index 0000000..4fb9e65 Binary files /dev/null and b/docs/images/weekly-resets.png differ