diff --git a/README.md b/README.md index 8d7b231..37e7109 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CapBar [![CI](https://github.com/kikkimo/CapBar/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kikkimo/CapBar/actions/workflows/ci.yml) -[![Version 0.1.4](https://img.shields.io/badge/version-0.1.4-2F80ED?style=flat-square)](scripts/Info.plist) +[![Version 0.2.0](https://img.shields.io/badge/version-0.2.0-2F80ED?style=flat-square)](scripts/Info.plist) [![macOS 14+](https://img.shields.io/badge/macOS-14%2B-333333?style=flat-square&logo=apple&logoColor=white)](#install-and-use) [![Swift 6](https://img.shields.io/badge/Swift-6-F05138?style=flat-square&logo=swift&logoColor=white)](Package.swift) [![Apple Silicon](https://img.shields.io/badge/arch-Apple%20Silicon-0A7F75?style=flat-square)](#build-from-source) @@ -25,20 +25,29 @@ - **Refresh when you choose:** Refresh one account or all idle accounts. Opening the window shows saved snapshots by default. Optional refresh on open checks each account against a shared threshold of 5 minutes by default. - **Clear states:** Refreshing accounts show progress while keeping their previous values. A failed refresh preserves the last successful snapshot and marks the failure. - **Native macOS behavior:** Light and dark appearances, a resizable popover, a right-click menu, and an optional launch-at-login setting. -- **Optional usage trends:** Track each account's 7-day quota consumption in a rolling chart. Enabling statistics also enables scheduled sampling while CapBar runs; the interval defaults to 4 hours and can be set to 1, 2, 3, 4, 6, or 8 hours. Manual refreshes contribute samples too. Chart bins match the selected interval, with a 2-hour minimum. +- **Optional usage trends:** Switch between one total chart per provider and individual account charts. Totals convert each subscription to the first account's capacity before adding usage, and are labeled as estimates because provider 7-day capacity ratios are unverified. Ambiguous plans require a tier selection in Settings. Trend colors adapt to each provider's observed range. Enabling statistics also enables scheduled sampling while CapBar runs; the interval defaults to 4 hours and can be set to 1, 2, 3, 4, 6, or 8 hours. Manual refreshes contribute samples too. Chart bins match the selected interval, with a 2-hour minimum. +- **Usage analysis:** The provider view shows observed 7-day totals, peak, minimum, average, coverage, leading account, active time periods, and recent 24-hour change. Historical records track rolling 7-day highs and lows, the highest single day, and the highest interval. Missing intervals stay blank; the headline uses the latest fully observed interval. | 7-day usage trend, dark appearance | 7-day usage trend, light appearance | | :---: | :---: | | ![CapBar usage trend in dark appearance](docs/images/trend-dark.png) | ![CapBar usage trend in light appearance](docs/images/trend-light.png) | +| Individual account trends | +| :---: | +| ![CapBar individual account usage trends](docs/images/trend-individual-light.png) | + +| Provider usage analysis, dark | Provider usage analysis, light | +| :---: | :---: | +| ![CapBar provider usage analysis in dark appearance](docs/images/trend-expanded-dark.png) | ![CapBar provider usage analysis in light appearance](docs/images/trend-expanded-light.png) | + *Trend illustrations use fictional accounts and sample history.* ## Install and use -CapBar 0.1.4 requires **macOS 14 or later** on **Apple Silicon**. Download the CI-built installer and checksum from [GitHub Releases](https://github.com/kikkimo/CapBar/releases/latest). +CapBar 0.2.0 requires **macOS 14 or later** on **Apple Silicon**. Download the CI-built installer and checksum from [GitHub Releases](https://github.com/kikkimo/CapBar/releases/latest). -1. Download `CapBar-0.1.4.pkg` and `SHA256SUMS` from the release, then run `shasum -a 256 -c SHA256SUMS` in that folder. -2. Open `CapBar-0.1.4.pkg`. The installer places the app at `/Applications/CapBar.app`. +1. Download `CapBar-0.2.0.pkg` and `SHA256SUMS` from the release, then run `shasum -a 256 -c SHA256SUMS` in that folder. +2. Open `CapBar-0.2.0.pkg`. The installer places the app at `/Applications/CapBar.app`. 3. Launch CapBar from Applications. It appears in the menu bar, without a Dock icon. 4. Click the menu bar item to view snapshots. Select **Refresh All (全部刷新)** or refresh an individual account when you want current data. @@ -56,7 +65,7 @@ On first launch, CapBar adds `~/.claude` and `~/.codex` as default entries. Add The popover starts at 448 × 620 pt. Enter a precise width or height and submit it, or use the steppers in 10 pt increments. Wider windows rearrange account details horizontally. Snapshots and settings are stored as JSON under `~/Library/Application Support/CapBar/`. -Usage statistics are off by default. Turning them on also starts scheduled quota probes while the app runs; turning them off stops the schedule. Successful probes are kept in a local SQLite history. Charts use UTC intervals and display dates in your local time zone. A complete interval needs enough observations before its usage can appear; missing intervals are not treated as zero usage. +Usage statistics are off by default. Turning them on also starts scheduled quota probes while the app runs; turning them off stops the schedule. Successful probes are kept in a local SQLite history. Charts use UTC intervals and display dates in your local time zone. A complete interval needs enough observations before its usage can appear; missing intervals are not treated as zero usage. New samples store the detected or selected plan tier; older samples without a tier use the current calibrated tier for estimated totals. ## How quota refresh works @@ -74,7 +83,7 @@ cd CapBar ./scripts/package-installer.sh ``` -The script runs the checks, builds `dist/CapBar.app`, verifies the app bundle, and creates `dist/CapBar-0.1.4.pkg`. It verifies that the package targets `/Applications` **without relocating** to an existing development copy. +The script runs the checks, builds `dist/CapBar.app`, verifies the app bundle, and creates `dist/CapBar-0.2.0.pkg`. It verifies that the package targets `/Applications` **without relocating** to an existing development copy. ```sh swift run CapBarChecks diff --git a/Sources/CapBarCore/Domain/HistoricalTrendStatistics.swift b/Sources/CapBarCore/Domain/HistoricalTrendStatistics.swift new file mode 100644 index 0000000..b9df887 --- /dev/null +++ b/Sources/CapBarCore/Domain/HistoricalTrendStatistics.swift @@ -0,0 +1,67 @@ +import Foundation + +struct HistoricalUsageRecord: Codable, Equatable, Sendable { + let usedPercent: Double + let startsAt: Date + let endsAt: Date +} + +struct HistoricalTrendStatistics: Codable, Equatable, Sendable { + let highSevenDays: HistoricalUsageRecord? + let lowSevenDays: HistoricalUsageRecord? + let highDay: HistoricalUsageRecord? + let highInterval: HistoricalUsageRecord? + + static func calculate(series: UsageTrendSeries, calendar: Calendar) -> Self { + let duration = TimeInterval(series.binHours * 3_600) + var days: [Date: (usage: Double, covered: TimeInterval)] = [:] + var highInterval: HistoricalUsageRecord? + + for point in series.points { + guard let amount = point.usedPercent, amount.isFinite, amount >= 0 else { continue } + let start = point.endAt.addingTimeInterval(-duration) + let record = HistoricalUsageRecord(usedPercent: amount, startsAt: start, endsAt: point.endAt) + if highInterval == nil || amount >= highInterval!.usedPercent { highInterval = record } + + var cursor = start + while cursor < point.endAt.addingTimeInterval(-0.000_001) { + guard let day = calendar.dateInterval(of: .day, for: cursor.addingTimeInterval(0.000_001)) + else { break } + let stop = min(day.end, point.endAt) + guard stop > cursor else { break } + let seconds = stop.timeIntervalSince(cursor) + var accumulated = days[day.start] ?? (usage: 0, covered: 0) + accumulated.usage += amount * seconds / duration + accumulated.covered += seconds + days[day.start] = accumulated + cursor = stop + } + } + + let completeDays = days.compactMap { start, day -> HistoricalUsageRecord? in + guard let interval = calendar.dateInterval(of: .day, for: start), + abs(day.covered - interval.duration) < 1 else { return nil } + return HistoricalUsageRecord(usedPercent: day.usage, startsAt: start, endsAt: interval.end) + } + let byStart = Dictionary(uniqueKeysWithValues: completeDays.map { ($0.startsAt, $0) }) + let highDay = completeDays.max { $0.usedPercent < $1.usedPercent } + + var highWeek: HistoricalUsageRecord? + var lowWeek: HistoricalUsageRecord? + for start in completeDays.map(\.startsAt).sorted() { + var total = 0.0 + var complete = true + for offset in 0..<7 { + guard let dayStart = calendar.date(byAdding: .day, value: offset, to: start), + let day = byStart[dayStart] else { complete = false; break } + total += day.usedPercent + } + guard complete, let end = calendar.date(byAdding: .day, value: 7, to: start) else { continue } + let record = HistoricalUsageRecord(usedPercent: total, startsAt: start, endsAt: end) + if highWeek == nil || total > highWeek!.usedPercent { highWeek = record } + if lowWeek == nil || total < lowWeek!.usedPercent { lowWeek = record } + } + return Self(highSevenDays: highWeek, lowSevenDays: lowWeek, + highDay: highDay, highInterval: highInterval) + } +} diff --git a/Sources/CapBarCore/Domain/ProviderTrendStatistics.swift b/Sources/CapBarCore/Domain/ProviderTrendStatistics.swift new file mode 100644 index 0000000..fbd789a --- /dev/null +++ b/Sources/CapBarCore/Domain/ProviderTrendStatistics.swift @@ -0,0 +1,158 @@ +import Foundation + +struct TrendIntervalPeak: Sendable { + let value: Double + let endAt: Date +} + +struct TrendAccountLeader: Sendable { + let label: String + let equivalentPercent: Double + let sharePercent: Double +} + +struct TrendUsageWindow: Sendable { + let usedPercent: Double? + let validBinCount: Int + let expectedBinCount: Int + + var isComplete: Bool { validBinCount == expectedBinCount } +} + +struct TrendUsagePeriod: Sendable { + let startHour: Int + let endHour: Int + + var label: String { String(format: "%02d:00–%02d:00", startHour, endHour) } +} + +struct ProviderTrendStatistics: Sendable { + let peak: TrendIntervalPeak? + let minimum: Double? + let average: Double? + let observedTotal: Double? + let validBinCount: Int + let expectedBinCount: Int + let leader: TrendAccountLeader? + let highUsagePeriod: TrendUsagePeriod? + let lowUsagePeriod: TrendUsagePeriod? + let usagePeriodsAreUniform: Bool + let recent24Hours: TrendUsageWindow + let changePercent: Double? +} + +extension ProviderTrendOverview { + func statistics(calendar: Calendar) -> ProviderTrendStatistics? { + guard let series else { return nil } + let valid = series.points.compactMap { point -> (endAt: Date, usage: Double)? in + guard let usage = point.usedPercent, usage.isFinite, usage >= 0 else { return nil } + return (point.endAt, usage) + } + let total = valid.isEmpty ? nil : valid.reduce(0) { $0 + $1.usage } + var peak: TrendIntervalPeak? + for point in valid where peak == nil || point.usage >= peak!.value { + peak = TrendIntervalPeak(value: point.usage, endAt: point.endAt) + } + + let binsPerDay = 24 / series.binHours + let recent = usageWindow(in: series.points, offsetFromEnd: 0, count: binsPerDay) + let previous = usageWindow(in: series.points, offsetFromEnd: binsPerDay, count: binsPerDay) + let change: Double? + if recent.isComplete, previous.isComplete, + let current = recent.usedPercent, let earlier = previous.usedPercent { + change = earlier > 0 ? (current / earlier - 1) * 100 : current == 0 ? 0 : nil + } else { + change = nil + } + let periods = usagePeriods(valid: valid, binHours: series.binHours, calendar: calendar) + + return ProviderTrendStatistics( + peak: peak, + minimum: valid.map(\.usage).min(), + average: total.map { $0 / Double(valid.count) }, + observedTotal: total, + validBinCount: valid.count, + expectedBinCount: series.points.count, + leader: total.flatMap { leadingAccount(total: $0, validEnds: valid.map(\.endAt)) }, + highUsagePeriod: periods.high, + lowUsagePeriod: periods.low, + usagePeriodsAreUniform: periods.uniform, + recent24Hours: recent, + changePercent: change + ) + } + + private func leadingAccount(total: Double, validEnds: [Date]) -> TrendAccountLeader? { + guard total > 0, !accounts.isEmpty else { return nil } + var totals = [Double](repeating: 0, count: accounts.count) + var labels: [String] = [] + for endAt in validEnds { + guard let contributions = contributions(at: endAt), contributions.count == totals.count else { continue } + if labels.isEmpty { labels = contributions.map(\.label) } + for (index, contribution) in contributions.enumerated() { + totals[index] += contribution.equivalentPercent + } + } + guard let index = totals.indices.max(by: { totals[$0] < totals[$1] }), totals[index] > 0, + labels.indices.contains(index) else { return nil } + return TrendAccountLeader(label: labels[index], equivalentPercent: totals[index], + sharePercent: totals[index] / total * 100) + } + + private func usageWindow(in points: [UsageTrendPoint], offsetFromEnd: Int, count: Int) -> TrendUsageWindow { + let end = max(0, points.count - offsetFromEnd) + let start = max(0, end - count) + let values = points[start..= 0 } + return TrendUsageWindow(usedPercent: values.isEmpty ? nil : values.reduce(0, +), + validBinCount: values.count, expectedBinCount: count) + } + + private func usagePeriods(valid: [(endAt: Date, usage: Double)], binHours: Int, + calendar: Calendar) -> (high: TrendUsagePeriod?, low: TrendUsagePeriod?, uniform: Bool) { + let duration = TimeInterval(binHours * 3_600) + guard Double(valid.count) * duration >= 24 * 3_600, + Set(valid.map { calendar.startOfDay(for: $0.endAt.addingTimeInterval(-duration / 2)) }).count >= 2 + else { return (nil, nil, false) } + + let periodHours = max(6, binHours) + let periodCount = 24 / periodHours + var usage = [Double](repeating: 0, count: periodCount) + var observedSeconds = [TimeInterval](repeating: 0, count: periodCount) + var observedDays = [Set](repeating: [], count: periodCount) + for point in valid { + var cursor = point.endAt.addingTimeInterval(-duration) + while cursor < point.endAt.addingTimeInterval(-0.000_001) { + let withinHour = cursor.addingTimeInterval(0.000_001) + guard let hour = calendar.dateInterval(of: .hour, for: withinHour) else { break } + let stop = min(hour.end, point.endAt) + guard stop > cursor else { break } + let bucket = calendar.component(.hour, from: withinHour) / periodHours + let seconds = stop.timeIntervalSince(cursor) + usage[bucket] += point.usage * seconds / duration + observedSeconds[bucket] += seconds + observedDays[bucket].insert(calendar.startOfDay(for: withinHour)) + cursor = stop + } + } + + var highest: (index: Int, rate: Double)? + var lowest: (index: Int, rate: Double)? + var eligiblePeriodCount = 0 + for index in 0..= 2 && observedSeconds[index] > 0 { + eligiblePeriodCount += 1 + let rate = usage[index] / observedSeconds[index] + if highest == nil || rate > highest!.rate { highest = (index, rate) } + if lowest == nil || rate < lowest!.rate { lowest = (index, rate) } + } + guard eligiblePeriodCount >= 2, let highest, let lowest else { return (nil, nil, false) } + guard abs(highest.rate - lowest.rate) > max(1e-12, abs(highest.rate) * 1e-9) + else { return (nil, nil, true) } + return ( + TrendUsagePeriod(startHour: highest.index * periodHours, + endHour: (highest.index + 1) * periodHours), + TrendUsagePeriod(startHour: lowest.index * periodHours, + endHour: (lowest.index + 1) * periodHours), + false + ) + } +} diff --git a/Sources/CapBarCore/Domain/UsageTrend.swift b/Sources/CapBarCore/Domain/UsageTrend.swift index 6a0c413..482b799 100644 --- a/Sources/CapBarCore/Domain/UsageTrend.swift +++ b/Sources/CapBarCore/Domain/UsageTrend.swift @@ -6,6 +6,7 @@ struct UsageTrendPoint: Sendable { let remainingPercent: Double? let crossesReset: Bool let isEstimated: Bool + var planTier: UsagePlanTier? = nil var isMissing: Bool { usedPercent == nil } } @@ -51,7 +52,8 @@ enum UsageTrendCalculator { usedPercent: max(0, usage.amount), remainingPercent: remaining(at: end, samples: ordered, spans: spans), crossesReset: usage.crossesReset, - isEstimated: estimated + isEstimated: estimated, + planTier: usage.planTier ) } let maximum = max(10, ceil((points.compactMap(\.usedPercent).max() ?? 0) / 10) * 10) @@ -64,16 +66,91 @@ enum UsageTrendCalculator { return UsageTrendSeries(points: points, binHours: binHours, axisMaximum: maximum, axisTicks: ticks, sampleCount: sampleCount) } + // Walk adjacent sample spans once. The seven-day chart can scan its small window; + // an all-time history must not repeat a scan of every sample for every bin. + static func calculateHistory( + samples: [UsageHistorySample], intervalHours: Int, endingAt now: Date + ) -> UsageTrendSeries { + let ordered = samples + .filter { $0.usedPercent.isFinite && (0...100).contains($0.usedPercent) && $0.capturedAt <= now } + .sorted { $0.capturedAt < $1.capturedAt } + let binHours = max(2, intervalHours) + let binSeconds = Double(binHours) * 3_600 + guard let first = ordered.first, let last = ordered.last else { + return UsageTrendSeries(points: [], binHours: binHours, axisMaximum: 10, + axisTicks: [0, 5, 10], sampleCount: 0) + } + let firstEnd = floor(first.capturedAt.timeIntervalSince1970 / binSeconds) * binSeconds + binSeconds + let lastEnd = floor(last.capturedAt.timeIntervalSince1970 / binSeconds) * binSeconds + let spanSeconds = lastEnd - firstEnd + guard spanSeconds >= 0, spanSeconds <= 1_000_000 * binSeconds else { + return UsageTrendSeries(points: [], binHours: binHours, axisMaximum: 10, + axisTicks: [0, 5, 10], sampleCount: ordered.count) + } + let spans = zip(ordered, ordered.dropFirst()).map { + Span(first: $0.0, last: $0.1, intervalHours: max(1, intervalHours)) + } + var spanIndex = 0 + let pointCount = Int(spanSeconds / binSeconds) + 1 + var points: [UsageTrendPoint] = [] + points.reserveCapacity(pointCount) + var maximumUsage = 0.0 + for index in 0.. cursor + epsilon, reset <= stop + epsilon { + crossesReset = true + } + cursor = stop + } + let value = valid && hasSpan ? max(0, amount) : nil + if let value { maximumUsage = max(maximumUsage, value) } + points.append(UsageTrendPoint(endAt: Date(timeIntervalSince1970: end), + usedPercent: value, remainingPercent: nil, + crossesReset: valid && crossesReset, isEstimated: false, + planTier: valid ? tier : nil)) + } + let maximum = max(10, ceil(maximumUsage / 10) * 10) + let ticks = Int(maximum) % 30 == 0 + ? [0, maximum / 3, maximum * 2 / 3, maximum] + : [0, maximum / 2, maximum] + return UsageTrendSeries(points: points, binHours: binHours, axisMaximum: maximum, + axisTicks: ticks, sampleCount: ordered.count) + } + private static func consumption( from start: TimeInterval, to end: TimeInterval, spans: [Span] - ) -> (amount: Double, crossesReset: Bool)? { + ) -> (amount: Double, crossesReset: Bool, planTier: UsagePlanTier?)? { var cursor = start var amount = 0.0 var crossesReset = false + var planTier: UsagePlanTier? while cursor < end - epsilon { guard let span = spans.first(where: { $0.start <= cursor + epsilon && $0.end > cursor + epsilon }), span.isValid else { return nil } + if let current = planTier, let observed = span.observedPlanTier, current != observed { return nil } + planTier = planTier ?? span.observedPlanTier let stop = min(end, span.end) amount += span.unwrapped(at: stop) - span.unwrapped(at: cursor) if let reset = span.reset, reset > cursor + epsilon, reset <= stop + epsilon { @@ -81,7 +158,7 @@ enum UsageTrendCalculator { } cursor = stop } - return (amount, crossesReset) + return (amount, crossesReset, planTier) } private static func containsSample(at time: TimeInterval, in samples: [UsageHistorySample]) -> Bool { @@ -107,6 +184,7 @@ enum UsageTrendCalculator { let end: TimeInterval let reset: TimeInterval? let isValid: Bool + var observedPlanTier: UsagePlanTier? { first.planTier ?? last.planTier } init(first: UsageHistorySample, last: UsageHistorySample, intervalHours: Int) { self.first = first @@ -128,6 +206,7 @@ enum UsageTrendCalculator { } ?? false let allowedHours = isNearReset ? min(intervalHours, 2) : intervalHours isValid = endTime > startTime && endTime - startTime <= Double(allowedHours) * 3_600 + 15 * 60 + && (first.planTier == nil || last.planTier == nil || first.planTier == last.planTier) && (reset != nil || last.usedPercent >= first.usedPercent) } diff --git a/Sources/CapBarCore/Domain/UsageTrendOverview.swift b/Sources/CapBarCore/Domain/UsageTrendOverview.swift new file mode 100644 index 0000000..d279e0d --- /dev/null +++ b/Sources/CapBarCore/Domain/UsageTrendOverview.swift @@ -0,0 +1,217 @@ +import Foundation + +enum TrendScope: String, CaseIterable, Sendable { + case total + case individual +} + +enum UsagePlanTier: String, CaseIterable, Codable, Sendable { + case claudePro, claudeMax5, claudeMax20, claudeTeamStandard, claudeTeamPremium + case codexPlus, codexPro5, codexPro20 + + var provider: Provider { + switch self { + case .claudePro, .claudeMax5, .claudeMax20, .claudeTeamStandard, .claudeTeamPremium: .claude + case .codexPlus, .codexPro5, .codexPro20: .codex + } + } + + var displayName: String { + switch self { + case .claudePro: "Pro" + case .claudeMax5: "Max 5x" + case .claudeMax20: "Max 20x" + case .claudeTeamStandard: "Team Standard" + case .claudeTeamPremium: "Team Premium" + case .codexPlus: "Plus" + case .codexPro5: "Pro 5x" + case .codexPro20: "Pro 20x" + } + } + + // Estimated seven-day capacities from the design proposal; not verified provider limits. + var capacityFactor: Double { + switch self { + case .claudePro, .codexPlus: 1 + case .claudeMax5, .codexPro5: 5 + case .claudeMax20, .codexPro20: 20 + case .claudeTeamStandard: 1.25 + case .claudeTeamPremium: 6.25 + } + } + + static func options(for provider: Provider) -> [Self] { allCases.filter { $0.provider == provider } } + + static func detect(provider: Provider, rawPlan: String?) -> Self? { + guard let rawPlan else { return nil } + let plan = rawPlan.lowercased() + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .split(whereSeparator: \.isWhitespace).joined(separator: " ") + return switch (provider, plan) { + case (.claude, "pro"): .claudePro + case (.claude, "max 5x"), (.claude, "max 5 x"): .claudeMax5 + case (.claude, "max 20x"), (.claude, "max 20 x"): .claudeMax20 + case (.claude, "team standard"): .claudeTeamStandard + case (.claude, "team premium"): .claudeTeamPremium + case (.codex, "plus"): .codexPlus + case (.codex, "pro 5x"), (.codex, "pro 5 x"): .codexPro5 + case (.codex, "pro 20x"), (.codex, "pro 20 x"): .codexPro20 + default: nil + } + } +} + +struct UsagePlanOverride: Codable, Sendable { + let account: AccountID + let tier: UsagePlanTier +} + +struct WeightedUsageTrend: Sendable { + let series: UsageTrendSeries + let capacity: Double +} + +struct TrendOverviewAccount: Sendable { + let label: String + let tier: UsagePlanTier? + let series: UsageTrendSeries? + + init(label: String = "", tier: UsagePlanTier?, series: UsageTrendSeries?) { + self.label = label + self.tier = tier + self.series = series + } +} + +struct TrendContribution: Sendable { + let label: String + let equivalentPercent: Double +} + +struct ProviderTrendOverview: Sendable { + let accounts: [TrendOverviewAccount] + let accountCount: Int + let baselinePlan: UsagePlanTier? + let uncalibratedCount: Int + let pendingHistoryCount: Int + let series: UsageTrendSeries? + + var latestObservedPoint: UsageTrendPoint? { + series?.points.last { point in + guard let used = point.usedPercent else { return false } + return used.isFinite && used >= 0 + } + } + + static func build(_ accounts: [TrendOverviewAccount]) -> Self { + let baseline = accounts.first?.tier + let uncalibrated = accounts.filter { $0.tier == nil || $0.tier?.provider != baseline?.provider }.count + let pending = accounts.filter { $0.series == nil }.count + guard uncalibrated == 0, pending == 0, let baseline else { + return Self(accounts: accounts, accountCount: accounts.count, baselinePlan: baseline, + uncalibratedCount: uncalibrated, pendingHistoryCount: pending, series: nil) + } + let weighted = accounts.compactMap { account -> WeightedUsageTrend? in + guard let tier = account.tier, let series = account.series else { return nil } + return WeightedUsageTrend(series: series, capacity: tier.capacityFactor) + } + return Self( + accounts: accounts, accountCount: accounts.count, baselinePlan: baseline, uncalibratedCount: 0, + pendingHistoryCount: 0, + series: UsageTrendAggregator.aggregate(weighted, baselineCapacity: baseline.capacityFactor) + ) + } + + func contributions(at endAt: Date) -> [TrendContribution]? { + guard let baselinePlan, series?.points.first(where: { $0.endAt == endAt })?.usedPercent != nil else { return nil } + let inputs = accounts.compactMap { account -> WeightedUsageTrend? in + guard let tier = account.tier, let series = account.series else { return nil } + return WeightedUsageTrend(series: series, capacity: tier.capacityFactor) + } + guard inputs.count == accounts.count else { return nil } + let aligned = inputs.map { $0.series.points.first { $0.endAt == endAt } } + guard let values = UsageTrendAggregator.contributionValues(aligned, inputs: inputs, + baselineCapacity: baselinePlan.capacityFactor) else { return nil } + return zip(accounts, values).enumerated().map { index, pair in + TrendContribution(label: pair.0.label.isEmpty ? "账号 \(index + 1)" : pair.0.label, + equivalentPercent: pair.1) + } + } +} + +enum UsageTrendAggregator { + static func aggregate(_ inputs: [WeightedUsageTrend], baselineCapacity: Double) -> UsageTrendSeries? { + guard let first = inputs.first, !first.series.points.isEmpty, + baselineCapacity.isFinite, baselineCapacity > 0, + inputs.allSatisfy({ $0.capacity.isFinite && $0.capacity > 0 && $0.series.binHours == first.series.binHours }) + else { return nil } + + let remaining = inputs.dropFirst().map { input in + Dictionary(input.series.points.map { ($0.endAt, $0) }, uniquingKeysWith: { _, latest in latest }) + } + let points = first.series.points.map { point in + let aligned: [UsageTrendPoint?] = [point] + remaining.map { $0[point.endAt] } + let total = contributionValues(aligned, inputs: inputs, baselineCapacity: baselineCapacity)?.reduce(0, +) + return UsageTrendPoint( + endAt: point.endAt, usedPercent: total, remainingPercent: nil, + crossesReset: false, isEstimated: false + ) + } + let maximum = max(10, ceil((points.compactMap(\.usedPercent).max() ?? 0) / 10) * 10) + let ticks = Int(maximum) % 30 == 0 + ? [0, maximum / 3, maximum * 2 / 3, maximum] + : [0, maximum / 2, maximum] + return UsageTrendSeries( + points: points, binHours: first.series.binHours, axisMaximum: maximum, + axisTicks: ticks, sampleCount: inputs.reduce(0) { $0 + $1.series.sampleCount }, + nextSampleAt: inputs.compactMap { $0.series.nextSampleAt }.min() + ) + } + + static func contributionValues(_ points: [UsageTrendPoint?], inputs: [WeightedUsageTrend], + baselineCapacity: Double) -> [Double]? { + guard points.count == inputs.count, baselineCapacity.isFinite, baselineCapacity > 0 else { return nil } + var values: [Double] = [] + for (point, input) in zip(points, inputs) { + guard let point, let used = point.usedPercent, used.isFinite, used >= 0 else { return nil } + let value = used * (point.planTier?.capacityFactor ?? input.capacity) / baselineCapacity + guard value.isFinite else { return nil } + values.append(value) + } + return values + } +} + +enum UsageTrendColorScale { + struct RGB: Equatable, Sendable { + let red: Double + let green: Double + let blue: Double + } + + static func range(for series: [UsageTrendSeries]) -> ClosedRange? { + let values = series.flatMap(\.points).compactMap(\.usedPercent).filter(\.isFinite) + guard let minimum = values.min(), let maximum = values.max() else { return nil } + return minimum...maximum + } + + static func fraction(_ value: Double, in range: ClosedRange?) -> Double { + guard let range, value.isFinite, range.upperBound > range.lowerBound else { return 0.5 } + return min(1, max(0, (value - range.lowerBound) / (range.upperBound - range.lowerBound))) + } + + // Google Research Turbo polynomial approximation, copyright 2019 Google LLC (Apache-2.0). + // Match the approved design's softened central 0.14–0.84 range. + // https://www.research.google/blog/turbo-an-improved-rainbow-colormap-for-visualization/ + static func turboRGB(at fraction: Double) -> RGB { + let position = fraction.isFinite ? min(1, max(0, fraction)) : 0.5 + let x = 0.14 + position * 0.70 + let x2 = x * x, x3 = x2 * x, x4 = x3 * x, x5 = x4 * x + let red = 0.13572138 + 4.61539260*x - 42.66032258*x2 + 132.13108234*x3 - 152.94239396*x4 + 59.28637943*x5 + let green = 0.09140261 + 2.19418839*x + 4.84296658*x2 - 14.18503333*x3 + 4.27729857*x4 + 2.82956604*x5 + let blue = 0.10667330 + 12.64194608*x - 60.58204836*x2 + 110.36276771*x3 - 89.90310912*x4 + 27.34824973*x5 + func soften(_ channel: Double) -> Double { min(1, max(0, channel)) * 0.82 + 0.18 * 0.47 } + return RGB(red: soften(red), green: soften(green), blue: soften(blue)) + } +} diff --git a/Sources/CapBarCore/Refresh/RefreshCoordinator.swift b/Sources/CapBarCore/Refresh/RefreshCoordinator.swift index 38abc51..d61d1bc 100644 --- a/Sources/CapBarCore/Refresh/RefreshCoordinator.swift +++ b/Sources/CapBarCore/Refresh/RefreshCoordinator.swift @@ -49,7 +49,7 @@ actor RefreshCoordinator { func isRefreshing(_ id: AccountID) -> Bool { refreshing.contains(id) } - func requestRefresh(_ id: AccountID, recordHistory: Bool = false) async -> Bool { + func requestRefresh(_ id: AccountID, recordHistory: Bool = false, planTierOverride: UsagePlanTier? = nil) async -> Bool { guard !shuttingDown, !refreshing.contains(id), let provider = providers[id.provider] else { return false } refreshing.insert(id) let previous = records[id] @@ -70,7 +70,7 @@ actor RefreshCoordinator { } activeTasks[id] = Task { - await self.perform(id, provider: provider, recordHistory: recordHistory) + await self.perform(id, provider: provider, recordHistory: recordHistory, planTierOverride: planTierOverride) } return true } @@ -90,7 +90,8 @@ actor RefreshCoordinator { func requestRefreshAll(settings: UserSettings) async -> Int { var started = 0 for account in settings.accounts { - if await requestRefresh(account, recordHistory: settings.usageStatisticsEnabled) { started += 1 } + let override = settings.planOverrides.first { $0.account == account }?.tier + if await requestRefresh(account, recordHistory: settings.usageStatisticsEnabled, planTierOverride: override) { started += 1 } } return started } @@ -104,12 +105,14 @@ actor RefreshCoordinator { now().timeIntervalSince(last) < Double(settings.refreshThresholdMinutes * 60) { continue } - if await requestRefresh(account, recordHistory: settings.usageStatisticsEnabled) { started += 1 } + let override = settings.planOverrides.first { $0.account == account }?.tier + if await requestRefresh(account, recordHistory: settings.usageStatisticsEnabled, planTierOverride: override) { started += 1 } } return started } - private func perform(_ id: AccountID, provider: any UsageProvider, recordHistory: Bool) async { + private func perform(_ id: AccountID, provider: any UsageProvider, recordHistory: Bool, + planTierOverride: UsagePlanTier?) async { var record = records[id] ?? AccountRecord(id: id, snapshot: nil, lastAttemptAt: now(), lastError: nil) let previousSnapshot = record.snapshot var successfulSnapshot: UsageSnapshot? @@ -134,7 +137,12 @@ actor RefreshCoordinator { if recordHistory, let successfulSnapshot { do { guard let historyStore else { throw UsageHistoryError.database("History store unavailable") } - _ = try await historyStore.append(account: id, snapshot: successfulSnapshot) + let storedOverride = (try? await settingsStore.loadOrSeed())?.planOverrides + .first(where: { $0.account == id })?.tier + let override = planTierOverride ?? storedOverride + let tier = override?.provider == id.provider ? override + : UsagePlanTier.detect(provider: id.provider, rawPlan: successfulSnapshot.identity.plan) + _ = try await historyStore.append(account: id, snapshot: successfulSnapshot, planTier: tier) } catch { record.lastError = "历史记录未能保存" try? await snapshotStore.update(record) diff --git a/Sources/CapBarCore/Refresh/UsageSamplingController.swift b/Sources/CapBarCore/Refresh/UsageSamplingController.swift index e52ce2b..c50b863 100644 --- a/Sources/CapBarCore/Refresh/UsageSamplingController.swift +++ b/Sources/CapBarCore/Refresh/UsageSamplingController.swift @@ -90,7 +90,8 @@ actor UsageSamplingController { knownResetAt.removeValue(forKey: account) } guard let due = dueAt[account], due <= current else { continue } - if await coordinator.requestRefresh(account, recordHistory: true) { + let override = settings.planOverrides.first { $0.account == account }?.tier + if await coordinator.requestRefresh(account, recordHistory: true, planTierOverride: override) { dueAt[account] = UsageSamplingSchedule.nextEvent( after: current, intervalHours: settings.samplingIntervalHours, resetAt: reset diff --git a/Sources/CapBarCore/Resources/Turbo-LICENSE.txt b/Sources/CapBarCore/Resources/Turbo-LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/Sources/CapBarCore/Resources/Turbo-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Sources/CapBarCore/Storage/SettingsStore.swift b/Sources/CapBarCore/Storage/SettingsStore.swift index 33e344e..e2d3115 100644 --- a/Sources/CapBarCore/Storage/SettingsStore.swift +++ b/Sources/CapBarCore/Storage/SettingsStore.swift @@ -38,6 +38,7 @@ struct UserSettings: Codable, Sendable { var usageStatisticsEnabled: Bool var samplingIntervalHours: Int var samplingScheduleStartedAt: Date? + var planOverrides: [UsagePlanOverride] var popoverSize: PopoverSize init( @@ -48,6 +49,7 @@ struct UserSettings: Codable, Sendable { usageStatisticsEnabled: Bool = false, samplingIntervalHours: Int = SamplingInterval.defaultHours, samplingScheduleStartedAt: Date? = nil, + planOverrides: [UsagePlanOverride] = [], popoverSize: PopoverSize = PopoverSize() ) { self.accounts = accounts @@ -57,12 +59,13 @@ struct UserSettings: Codable, Sendable { self.usageStatisticsEnabled = usageStatisticsEnabled self.samplingIntervalHours = samplingIntervalHours self.samplingScheduleStartedAt = samplingScheduleStartedAt + self.planOverrides = planOverrides self.popoverSize = popoverSize } private enum CodingKeys: String, CodingKey { case accounts, defaultsSeeded, autoRefreshOnOpen, refreshThresholdMinutes - case usageStatisticsEnabled, samplingIntervalHours, samplingScheduleStartedAt, popoverSize + case usageStatisticsEnabled, samplingIntervalHours, samplingScheduleStartedAt, planOverrides, popoverSize } init(from decoder: Decoder) throws { @@ -75,6 +78,7 @@ struct UserSettings: Codable, Sendable { usageStatisticsEnabled: try values.decodeIfPresent(Bool.self, forKey: .usageStatisticsEnabled) ?? false, samplingIntervalHours: try values.decodeIfPresent(Int.self, forKey: .samplingIntervalHours) ?? SamplingInterval.defaultHours, samplingScheduleStartedAt: try values.decodeIfPresent(Date.self, forKey: .samplingScheduleStartedAt), + planOverrides: try values.decodeIfPresent([UsagePlanOverride].self, forKey: .planOverrides) ?? [], popoverSize: try values.decodeIfPresent(PopoverSize.self, forKey: .popoverSize) ?? PopoverSize() ) } diff --git a/Sources/CapBarCore/Storage/UsageHistoryStore.swift b/Sources/CapBarCore/Storage/UsageHistoryStore.swift index 1633c3b..94a9cf3 100644 --- a/Sources/CapBarCore/Storage/UsageHistoryStore.swift +++ b/Sources/CapBarCore/Storage/UsageHistoryStore.swift @@ -5,12 +5,38 @@ struct UsageHistorySample: Sendable, Equatable { let capturedAt: Date let usedPercent: Double let resetsAt: Date? + let planTier: UsagePlanTier? + + init(capturedAt: Date, usedPercent: Double, resetsAt: Date?, planTier: UsagePlanTier? = nil) { + self.capturedAt = capturedAt + self.usedPercent = usedPercent + self.resetsAt = resetsAt + self.planTier = planTier + } } enum UsageHistoryError: Error { case database(String) } +struct HistoricalAccount: Codable, Sendable { + let account: AccountID + let tier: UsagePlanTier +} + +struct HistoricalStatisticsLoad: Sendable { + let statistics: HistoricalTrendStatistics + let fromCache: Bool +} + +private struct HistoricalCacheSignature: Codable { + let version: Int + let accounts: [HistoricalAccount] + let intervalHours: Int + let calendar: String + let timeZone: String +} + actor UsageHistoryStore { private let url: URL @@ -18,36 +44,165 @@ actor UsageHistoryStore { self.url = url } - func append(account: AccountID, snapshot: UsageSnapshot) throws -> Bool { + func append(account: AccountID, snapshot: UsageSnapshot, planTier: UsagePlanTier? = nil) throws -> Bool { guard let weekly = snapshot.windows.first(where: { $0.kind == .sevenDay }) else { return false } try withDatabase { database in - let sql = """ - INSERT INTO usage_samples(provider, directory, captured_at, used_percent, resets_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(provider, directory, captured_at) DO UPDATE SET - used_percent = excluded.used_percent, - resets_at = excluded.resets_at - """ - let statement = try prepare(sql, in: database) - defer { sqlite3_finalize(statement) } - try bind(account.provider.rawValue, to: 1, in: statement, database: database) - try bind(account.directory, to: 2, in: statement, database: database) - sqlite3_bind_double(statement, 3, snapshot.capturedAt.timeIntervalSince1970) - sqlite3_bind_double(statement, 4, 100 - weekly.remainingPercent) - if let reset = weekly.resetsAt { - sqlite3_bind_double(statement, 5, reset.timeIntervalSince1970) - } else { - sqlite3_bind_null(statement, 5) + guard sqlite3_exec(database, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { throw error(database) } + do { + let sql = """ + INSERT INTO usage_samples(provider, directory, captured_at, used_percent, resets_at, plan_tier) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, directory, captured_at) DO UPDATE SET + used_percent = excluded.used_percent, + resets_at = excluded.resets_at, + plan_tier = COALESCE(excluded.plan_tier, usage_samples.plan_tier) + WHERE usage_samples.used_percent IS NOT excluded.used_percent + OR usage_samples.resets_at IS NOT excluded.resets_at + OR (excluded.plan_tier IS NOT NULL AND usage_samples.plan_tier IS NOT excluded.plan_tier) + """ + let statement = try prepare(sql, in: database) + defer { sqlite3_finalize(statement) } + try bind(account.provider.rawValue, to: 1, in: statement, database: database) + try bind(account.directory, to: 2, in: statement, database: database) + sqlite3_bind_double(statement, 3, snapshot.capturedAt.timeIntervalSince1970) + sqlite3_bind_double(statement, 4, 100 - weekly.remainingPercent) + if let reset = weekly.resetsAt { + sqlite3_bind_double(statement, 5, reset.timeIntervalSince1970) + } else { + sqlite3_bind_null(statement, 5) + } + if let planTier { + try bind(planTier.rawValue, to: 6, in: statement, database: database) + } else { + sqlite3_bind_null(statement, 6) + } + guard sqlite3_step(statement) == SQLITE_DONE else { throw error(database) } + if sqlite3_changes(database) > 0 { + let revision = try prepare(""" + INSERT INTO history_account_revisions(provider, directory, revision) VALUES (?, ?, 1) + ON CONFLICT(provider, directory) DO UPDATE SET revision = revision + 1 + """, in: database) + defer { sqlite3_finalize(revision) } + try bind(account.provider.rawValue, to: 1, in: revision, database: database) + try bind(account.directory, to: 2, in: revision, database: database) + guard sqlite3_step(revision) == SQLITE_DONE else { throw error(database) } + } + guard sqlite3_exec(database, "COMMIT", nil, nil, nil) == SQLITE_OK else { throw error(database) } + } catch { + sqlite3_exec(database, "ROLLBACK", nil, nil, nil) + throw error } - guard sqlite3_step(statement) == SQLITE_DONE else { throw error(database) } } return true } + func historicalStatistics(accounts: [HistoricalAccount], intervalHours: Int, + calendar: Calendar) throws -> HistoricalStatisticsLoad? { + guard let first = accounts.first, intervalHours > 0, + accounts.allSatisfy({ $0.account.provider == first.account.provider && $0.tier.provider == first.account.provider }) + else { return nil } + let signatureEncoder = JSONEncoder() + signatureEncoder.outputFormatting = [.sortedKeys] + let encodedSignature = try signatureEncoder.encode(HistoricalCacheSignature( + version: 2, accounts: accounts, intervalHours: intervalHours, + calendar: String(describing: calendar.identifier), + timeZone: calendar.timeZone.identifier + )) + guard let signature = String(data: encodedSignature, encoding: .utf8) else { return nil } + return try withDatabase { database in + var revisions: [Int64] = [] + revisions.reserveCapacity(accounts.count) + for configured in accounts { + revisions.append(try accountRevision(configured.account, in: database)) + } + let revisionKey = revisions.map(String.init).joined(separator: ",") + let cached = try prepare(""" + SELECT payload FROM history_statistics_cache + WHERE provider = ? AND signature = ? AND revisions = ? + """, in: database) + try bind(first.account.provider.rawValue, to: 1, in: cached, database: database) + try bind(signature, to: 2, in: cached, database: database) + try bind(revisionKey, to: 3, in: cached, database: database) + let cachedStatus = sqlite3_step(cached) + let cachedPayload = cachedStatus == SQLITE_ROW ? sqlite3_column_text(cached, 0).map { String(cString: $0) } : nil + sqlite3_finalize(cached) + if let cachedPayload, let data = cachedPayload.data(using: .utf8), + let statistics = try? JSONDecoder().decode(HistoricalTrendStatistics.self, from: data) { + return HistoricalStatisticsLoad(statistics: statistics, fromCache: true) + } + guard cachedStatus == SQLITE_ROW || cachedStatus == SQLITE_DONE else { throw error(database) } + + var weighted: [WeightedUsageTrend] = [] + weighted.reserveCapacity(accounts.count) + for configured in accounts { + let samples = try allSamples(account: configured.account, in: database) + let series = UsageTrendCalculator.calculateHistory( + samples: samples, intervalHours: intervalHours, + endingAt: samples.last?.capturedAt ?? .distantPast + ) + weighted.append(WeightedUsageTrend(series: series, capacity: configured.tier.capacityFactor)) + } + let aggregate = UsageTrendAggregator.aggregate(weighted, + baselineCapacity: first.tier.capacityFactor) + let empty = UsageTrendSeries(points: [], binHours: max(2, intervalHours), axisMaximum: 10, + axisTicks: [0, 5, 10], sampleCount: 0) + let statistics = HistoricalTrendStatistics.calculate(series: aggregate ?? empty, calendar: calendar) + let payload = String(data: try JSONEncoder().encode(statistics), encoding: .utf8)! + let save = try prepare(""" + INSERT INTO history_statistics_cache(provider, signature, revisions, payload) + VALUES (?, ?, ?, ?) + ON CONFLICT(provider) DO UPDATE SET + signature = excluded.signature, revisions = excluded.revisions, payload = excluded.payload + """, in: database) + defer { sqlite3_finalize(save) } + try bind(first.account.provider.rawValue, to: 1, in: save, database: database) + try bind(signature, to: 2, in: save, database: database) + try bind(revisionKey, to: 3, in: save, database: database) + try bind(payload, to: 4, in: save, database: database) + guard sqlite3_step(save) == SQLITE_DONE else { throw error(database) } + return HistoricalStatisticsLoad(statistics: statistics, fromCache: false) + } + } + + private func accountRevision(_ account: AccountID, in database: OpaquePointer) throws -> Int64 { + let statement = try prepare(""" + SELECT revision FROM history_account_revisions WHERE provider = ? AND directory = ? + """, in: database) + defer { sqlite3_finalize(statement) } + try bind(account.provider.rawValue, to: 1, in: statement, database: database) + try bind(account.directory, to: 2, in: statement, database: database) + let status = sqlite3_step(statement) + guard status == SQLITE_ROW || status == SQLITE_DONE else { throw error(database) } + return status == SQLITE_ROW ? sqlite3_column_int64(statement, 0) : 0 + } + + private func allSamples(account: AccountID, in database: OpaquePointer) throws -> [UsageHistorySample] { + let statement = try prepare(""" + SELECT captured_at, used_percent, resets_at, plan_tier FROM usage_samples + WHERE provider = ? AND directory = ? ORDER BY captured_at ASC + """, in: database) + defer { sqlite3_finalize(statement) } + try bind(account.provider.rawValue, to: 1, in: statement, database: database) + try bind(account.directory, to: 2, in: statement, database: database) + var samples: [UsageHistorySample] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return samples } + guard status == SQLITE_ROW else { throw error(database) } + samples.append(UsageHistorySample( + capturedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)), + usedPercent: sqlite3_column_double(statement, 1), + resetsAt: sqlite3_column_type(statement, 2) == SQLITE_NULL + ? nil : Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)), + planTier: sqlite3_column_text(statement, 3).flatMap { UsagePlanTier(rawValue: String(cString: $0)) } + )) + } + } + func samples(account: AccountID, from start: Date, through end: Date) throws -> [UsageHistorySample] { try withDatabase { database in let sql = """ - SELECT captured_at, used_percent, resets_at + SELECT captured_at, used_percent, resets_at, plan_tier FROM usage_samples WHERE provider = ? AND directory = ? AND captured_at >= ? AND captured_at <= ? ORDER BY captured_at ASC @@ -63,11 +218,15 @@ actor UsageHistoryStore { let status = sqlite3_step(statement) if status == SQLITE_DONE { return result } guard status == SQLITE_ROW else { throw error(database) } + let tier = sqlite3_column_text(statement, 3).flatMap { + UsagePlanTier(rawValue: String(cString: $0)) + } result.append(UsageHistorySample( capturedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)), usedPercent: sqlite3_column_double(statement, 1), resetsAt: sqlite3_column_type(statement, 2) == SQLITE_NULL - ? nil : Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)) + ? nil : Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)), + planTier: tier )) } } @@ -95,6 +254,7 @@ actor UsageHistoryStore { throw UsageHistoryError.database(message) } defer { sqlite3_close(database) } + sqlite3_busy_timeout(database, 5_000) let schema = """ CREATE TABLE IF NOT EXISTS usage_samples ( provider TEXT NOT NULL, @@ -102,10 +262,31 @@ actor UsageHistoryStore { captured_at REAL NOT NULL, used_percent REAL NOT NULL, resets_at REAL, + plan_tier TEXT, PRIMARY KEY (provider, directory, captured_at) ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS history_account_revisions ( + provider TEXT NOT NULL, directory TEXT NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY (provider, directory) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS history_statistics_cache ( + provider TEXT PRIMARY KEY, signature TEXT NOT NULL, + revisions TEXT NOT NULL, payload TEXT NOT NULL + ) WITHOUT ROWID; """ guard sqlite3_exec(database, schema, nil, nil, nil) == SQLITE_OK else { throw error(database) } + let columns = try prepare("PRAGMA table_info(usage_samples)", in: database) + var hasPlanTier = false + while sqlite3_step(columns) == SQLITE_ROW { + if let name = sqlite3_column_text(columns, 1), String(cString: name) == "plan_tier" { + hasPlanTier = true + } + } + sqlite3_finalize(columns) + if !hasPlanTier { + guard sqlite3_exec(database, "ALTER TABLE usage_samples ADD COLUMN plan_tier TEXT", nil, nil, nil) == SQLITE_OK + else { throw error(database) } + } return try work(database) } diff --git a/Sources/CapBarCore/UI/CapBarViewModel.swift b/Sources/CapBarCore/UI/CapBarViewModel.swift index 6c4c0d2..9b0e21f 100644 --- a/Sources/CapBarCore/UI/CapBarViewModel.swift +++ b/Sources/CapBarCore/UI/CapBarViewModel.swift @@ -13,8 +13,10 @@ import Foundation @Published var settings: UserSettings @Published private(set) var rows: [PopoverAccountRow] = [] @Published private(set) var trends: [AccountID: UsageTrendSeries] = [:] + @Published private(set) var historicalStatistics: [Provider: HistoricalTrendStatistics] = [:] @Published var showsSettings = false @Published private(set) var showsTrend = false + @Published private(set) var trendScope: TrendScope = .individual @Published var selectedProvider: Provider = .claude @Published var directoryInput = "" @Published var popoverWidthInput: String @@ -51,6 +53,20 @@ import Foundation let intervalHours: Int } private var trendKeys: [AccountID: TrendKey] = [:] + private struct HistoricalAccountKey: Equatable { + let account: AccountID + let tier: UsagePlanTier + let capturedAt: Date? + let weeklyRemaining: Double? + let weeklyResetsAt: Date? + } + private struct HistoryKey: Equatable { + let accounts: [HistoricalAccountKey] + let intervalHours: Int + let timeZone: String + } + private var historyKeys: [Provider: HistoryKey] = [:] + private var historyLoading = false private var trendReloadGeneration = 0 var maximumPopoverWidth: Int { @@ -115,8 +131,9 @@ import Foundation func refresh(_ account: AccountID) { let recordHistory = settings.usageStatisticsEnabled + let override = settings.planOverrides.first { $0.account == account }?.tier Task { - _ = await coordinator.requestRefresh(account, recordHistory: recordHistory) + _ = await coordinator.requestRefresh(account, recordHistory: recordHistory, planTierOverride: override) await reloadRows() } } @@ -127,6 +144,39 @@ import Foundation if showsTrend && reload { Task { await reloadTrends() } } } + func setTrendScope(_ scope: TrendScope) { + trendScope = scope + if scope == .total && showsTrend { Task { await reloadTrends() } } + } + + func planTier(for account: AccountID) -> UsagePlanTier? { + if let override = settings.planOverrides.first(where: { $0.account == account }) { + return override.tier.provider == account.provider ? override.tier : nil + } + let detected = rows.first(where: { $0.account == account })?.detectedPlan + return UsagePlanTier.detect(provider: account.provider, rawPlan: detected) + } + + func setPlanOverride(_ tier: UsagePlanTier?, for account: AccountID) { + guard settings.accounts.contains(account), tier == nil || tier?.provider == account.provider else { return } + settings.planOverrides.removeAll { $0.account == account } + if let tier { settings.planOverrides.append(UsagePlanOverride(account: account, tier: tier)) } + historyKeys.removeValue(forKey: account.provider) + historicalStatistics.removeValue(forKey: account.provider) + if showsTrend && trendScope == .total { Task { await reloadTrends() } } + onSamplingSettingsChange?(settings) + enqueueSave() + } + + func trendOverview(for provider: Provider) -> ProviderTrendOverview { + let accounts = settings.accounts.filter { $0.provider == provider } + return ProviderTrendOverview.build(accounts.map { account in + let row = rows.first { $0.account == account } + let label = row?.title == "尚未识别" ? (row?.directoryLabel ?? account.directory) : (row?.title ?? account.directory) + return TrendOverviewAccount(label: label, tier: planTier(for: account), series: trends[account]) + }) + } + func setUsageStatisticsEnabled(_ enabled: Bool, now: Date = Date()) { guard settings.usageStatisticsEnabled != enabled else { return } trendReloadGeneration += 1 @@ -136,6 +186,8 @@ import Foundation showsTrend = false trends.removeAll() trendKeys.removeAll() + historicalStatistics.removeAll() + historyKeys.removeAll() } onSamplingSettingsChange?(settings) enqueueSave() @@ -147,6 +199,8 @@ import Foundation settings.samplingIntervalHours = hours if settings.usageStatisticsEnabled { settings.samplingScheduleStartedAt = now } trendKeys.removeAll() + historyKeys.removeAll() + historicalStatistics.removeAll() if showsTrend { Task { await reloadTrends() } } onSamplingSettingsChange?(settings) enqueueSave() @@ -155,6 +209,7 @@ import Foundation func reloadTrends(endingAt now: Date = Date()) async { guard settings.usageStatisticsEnabled, showsTrend, historyStore != nil || historySampleLoader != nil else { return } + if trendScope == .total && historyLoading { return } trendReloadGeneration += 1 let generation = trendReloadGeneration let records = await coordinator.viewState().records @@ -197,9 +252,53 @@ import Foundation newKeys.removeValue(forKey: account) } } + var history = historicalStatistics + var newHistoryKeys = historyKeys + if trendScope == .total, let historyStore { + historyLoading = true + defer { historyLoading = false } + let calendar = Calendar.current + for provider in [Provider.claude, .codex] { + let selected = accounts.filter { $0.provider == provider } + guard !selected.isEmpty else { + history.removeValue(forKey: provider) + newHistoryKeys.removeValue(forKey: provider) + continue + } + let calibrated = selected.compactMap { account -> HistoricalAccount? in + guard let tier = planTier(for: account) else { return nil } + return HistoricalAccount(account: account, tier: tier) + } + guard calibrated.count == selected.count else { + history.removeValue(forKey: provider) + newHistoryKeys.removeValue(forKey: provider) + continue + } + let key = HistoryKey(accounts: calibrated.map { configured in + let snapshot = records[configured.account]?.snapshot + let weekly = snapshot?.windows.first { $0.kind == .sevenDay } + return HistoricalAccountKey(account: configured.account, tier: configured.tier, + capturedAt: snapshot?.capturedAt, + weeklyRemaining: weekly?.remainingPercent, + weeklyResetsAt: weekly?.resetsAt) + }, intervalHours: intervalHours, timeZone: calendar.timeZone.identifier) + if historyKeys[provider] == key, history[provider] != nil { continue } + do { + history[provider] = try await historyStore.historicalStatistics( + accounts: calibrated, intervalHours: intervalHours, calendar: calendar + )?.statistics + newHistoryKeys[provider] = key + } catch { + history.removeValue(forKey: provider) + newHistoryKeys.removeValue(forKey: provider) + } + } + } if generation == trendReloadGeneration && settings.usageStatisticsEnabled && showsTrend { trendKeys = newKeys trends = result + historyKeys = newHistoryKeys + historicalStatistics = history } } @@ -284,6 +383,8 @@ import Foundation } settings.accounts.append(account) trendReloadGeneration += 1 + historyKeys.removeValue(forKey: account.provider) + historicalStatistics.removeValue(forKey: account.provider) directoryInput = "" settingsMessage = nil onSamplingSettingsChange?(settings) @@ -294,7 +395,10 @@ import Foundation func removeAccount(_ account: AccountID) { guard !rows.contains(where: { $0.account == account && $0.isRefreshing }) else { return } settings.accounts.removeAll { $0 == account } + settings.planOverrides.removeAll { $0.account == account } trendReloadGeneration += 1 + historyKeys.removeValue(forKey: account.provider) + historicalStatistics.removeValue(forKey: account.provider) settingsMessage = nil onSamplingSettingsChange?(settings) enqueueSave() diff --git a/Sources/CapBarCore/UI/PopoverPresentation.swift b/Sources/CapBarCore/UI/PopoverPresentation.swift index 4831ee6..ec26c45 100644 --- a/Sources/CapBarCore/UI/PopoverPresentation.swift +++ b/Sources/CapBarCore/UI/PopoverPresentation.swift @@ -23,6 +23,7 @@ struct PopoverAccountRow: Sendable { let windows: [QuotaWindow] let isRefreshing: Bool let error: String? + var detectedPlan: String? = nil var refreshEnabled: Bool { !isRefreshing } var isExhausted: Bool { @@ -62,7 +63,8 @@ enum PopoverPresentation { captureAgeBand: captureAgeBand(record?.snapshot?.capturedAt, now: now), windows: record?.snapshot?.windows ?? [], isRefreshing: state.refreshing.contains(account), - error: record?.lastError + error: record?.lastError, + detectedPlan: identity?.plan ) } } diff --git a/Sources/CapBarCore/UI/PopoverView.swift b/Sources/CapBarCore/UI/PopoverView.swift index c304138..9f2dd58 100644 --- a/Sources/CapBarCore/UI/PopoverView.swift +++ b/Sources/CapBarCore/UI/PopoverView.swift @@ -63,31 +63,58 @@ struct CapBarPopoverView: View { } private var overview: some View { - ScrollView { - VStack(spacing: 0) { - if !model.rows.isEmpty && model.rows.allSatisfy({ $0.windows.isEmpty && !$0.isRefreshing && $0.error == nil }) { - HStack(spacing: 7) { - Image(systemName: "arrow.clockwise.circle") - Text("尚未采集额度,点击右上角“全部刷新”") - Spacer() + VStack(spacing: 0) { + if model.showsTrend && model.settings.usageStatisticsEnabled { + trendScopeBar + } + ScrollView { + VStack(spacing: 0) { + if !model.rows.isEmpty && model.rows.allSatisfy({ $0.windows.isEmpty && !$0.isRefreshing && $0.error == nil }) { + HStack(spacing: 7) { + Image(systemName: "arrow.clockwise.circle") + Text("尚未采集额度,点击右上角“全部刷新”") + Spacer() + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(secondary) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.72)) } - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(secondary) - .padding(.horizontal, 16).padding(.vertical, 10) - .background(Color(nsColor: .controlBackgroundColor).opacity(0.72)) - } - if model.rows.isEmpty { - Text("尚未添加账号目录") - .font(.system(size: 12)).foregroundStyle(secondary) - .frame(maxWidth: .infinity).padding(.vertical, 32) + if model.rows.isEmpty { + Text("尚未添加账号目录") + .font(.system(size: 12)).foregroundStyle(secondary) + .frame(maxWidth: .infinity).padding(.vertical, 32) + } + providerGroup(.claude, title: "CLAUDE CODE", dot: Color(red: 0.77, green: 0.49, blue: 0.35)) + providerGroup(.codex, title: "CODEX", dot: Color(red: 0.33, green: 0.66, blue: 0.52)) } - providerGroup(.claude, title: "CLAUDE CODE", dot: Color(red: 0.77, green: 0.49, blue: 0.35)) - providerGroup(.codex, title: "CODEX", dot: Color(red: 0.33, green: 0.66, blue: 0.52)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var trendScopeBar: some View { + HStack(spacing: 10) { + Picker("走势图范围", selection: Binding( + get: { model.trendScope }, + set: { model.setTrendScope($0) } + )) { + Text("单账号").tag(TrendScope.individual) + Text("总走势").tag(TrendScope.total) + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .frame(width: 153) + Spacer(minLength: 0) + Text("近 7 日 · \(max(2, model.settings.samplingIntervalHours)) 小时/点") + .font(.system(size: 9)).foregroundStyle(secondary).lineLimit(1) + } + .padding(.horizontal, 16).padding(.vertical, 8) + .background(Color(nsColor: .windowBackgroundColor).opacity(0.68)) + .overlay(alignment: .bottom) { line.frame(height: 1) } + } + @ViewBuilder private func providerGroup(_ provider: Provider, title: String, dot: Color) -> some View { let rows = model.rows.filter { $0.account.provider == provider } if !rows.isEmpty { @@ -100,27 +127,35 @@ struct CapBarPopoverView: View { .padding(.horizontal, 16).padding(.vertical, 8) .background(Color(nsColor: .controlBackgroundColor).opacity(0.72)) .overlay(alignment: .bottom) { line.frame(height: 1) } - ForEach(rows.indices, id: \.self) { index in - let row = rows[index] - let tinted = !model.showsTrend && row.isExhausted - CapBarAccountRow( - row: row, width: model.settings.popoverSize.width, - statisticsEnabled: model.settings.usageStatisticsEnabled, - showsTrend: model.showsTrend, - trend: model.trends[row.account] - ) { model.refresh(row.account) } - .padding(.horizontal, 16) - .background { - if tinted { - LinearGradient( - colors: [Color(nsColor: .systemRed).opacity(0.14), - Color(nsColor: .systemRed).opacity(0.06)], - startPoint: .leading, endPoint: .trailing - ) + if model.showsTrend && model.trendScope == .total { + CapBarTotalTrendCard(overview: model.trendOverview(for: provider), + historical: model.historicalStatistics[provider], provider: provider) { + model.showsSettings = true + } + .padding(.horizontal, 16) + } else { + ForEach(rows.indices, id: \.self) { index in + let row = rows[index] + let tinted = !model.showsTrend && row.isExhausted + CapBarAccountRow( + row: row, width: model.settings.popoverSize.width, + statisticsEnabled: model.settings.usageStatisticsEnabled, + showsTrend: model.showsTrend, + trend: model.trends[row.account] + ) { model.refresh(row.account) } + .padding(.horizontal, 16) + .background { + if tinted { + LinearGradient( + colors: [Color(nsColor: .systemRed).opacity(0.14), + Color(nsColor: .systemRed).opacity(0.06)], + startPoint: .leading, endPoint: .trailing + ) + } } + if index < rows.count - 1 && !tinted && (model.showsTrend || !rows[index + 1].isExhausted) { + line.frame(height: 1).padding(.horizontal, 16) } - if index < rows.count - 1 && !tinted && (model.showsTrend || !rows[index + 1].isExhausted) { - line.frame(height: 1).padding(.horizontal, 16) } } } @@ -166,6 +201,305 @@ struct CapBarPopoverView: View { } } +private struct CapBarTotalTrendCard: View { + let overview: ProviderTrendOverview + let historical: HistoricalTrendStatistics? + let provider: Provider + let openSettings: () -> Void + + private var title: String { provider == .claude ? "Claude 用量合计" : "Codex 用量合计" } + private var latestUsage: Double? { overview.latestObservedPoint?.usedPercent } + private var latestIntervalLabel: String { + guard let endAt = overview.latestObservedPoint?.endAt else { return "最近区间缺测" } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = .current + formatter.dateFormat = Calendar.current.isDate(endAt, equalTo: .now, toGranularity: .year) + ? "M月d日 HH:mm" : "yyyy年M月d日 HH:mm" + return "截至 \(formatter.string(from: endAt)) · 估算" + } + private var statistics: ProviderTrendStatistics? { overview.statistics(calendar: .current) } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.system(size: 13, weight: .semibold)) + Text("\(overview.accountCount) 个账号 · \(overview.baselinePlan.map { "以首账号 \($0.displayName) 为基准" } ?? "套餐待校准")") + .font(.system(size: 10)).foregroundStyle(.secondary) + } + Spacer(minLength: 4) + if overview.series != nil { + VStack(alignment: .trailing, spacing: 2) { + Text(latestUsage.map { $0.formatted(.number.precision(.fractionLength(0...1))) + "%" } ?? "—") + .font(.system(size: 16, weight: .bold)).monospacedDigit() + .foregroundStyle(latestUsage == nil ? Color.secondary : Color.primary) + Text(latestIntervalLabel) + .font(.system(size: 9)).foregroundStyle(.secondary) + } + } + } + if overview.uncalibratedCount > 0 { + HStack { + Text("\(overview.uncalibratedCount) 个账号套餐待校准,暂不计算总走势") + .font(.system(size: 11)).foregroundStyle(.secondary) + Spacer(minLength: 4) + Button("去设置") { openSettings() } + .buttonStyle(.plain).font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + .frame(height: 128) + } else if overview.pendingHistoryCount > 0 { + Text("\(overview.pendingHistoryCount) 个账号的历史数据暂不可用") + .font(.system(size: 11)).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 128) + } else { + UsageTrendChart( + series: overview.series, + colorRange: overview.series.flatMap { UsageTrendColorScale.range(for: [$0]) }, + totalOverview: overview + ) + } + if let statistics, statistics.validBinCount > 0, let binHours = overview.series?.binHours { + CapBarTotalTrendStatistics(statistics: statistics, binHours: binHours) + } else { + Text("套餐容量按估算系数换算 · 缺测区间留空") + .font(.system(size: 9)).foregroundStyle(.secondary) + } + if overview.uncalibratedCount == 0 { + CapBarHistoricalTrendStatistics(statistics: historical) + } + } + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct CapBarHistoricalTrendStatistics: View { + let statistics: HistoricalTrendStatistics? + + @Environment(\.colorScheme) private var colorScheme + private let secondary = Color(nsColor: .secondaryLabelColor) + private let separator = Color(nsColor: .separatorColor) + private var usageColor: Color { + colorScheme == .dark ? Color(red: 0.51, green: 0.73, blue: 1.0) + : Color(red: 0.14, green: 0.47, blue: 0.82) + } + private var lowColor: Color { + colorScheme == .dark ? Color(red: 0.43, green: 0.82, blue: 0.75) + : Color(red: 0.10, green: 0.55, blue: 0.49) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("历史纪录").font(.system(size: 10, weight: .semibold)) + Spacer() + Text("完整窗口 · 本地日期").font(.system(size: 9)).foregroundStyle(secondary) + } + LazyVGrid(columns: [GridItem(.flexible(), spacing: 6), GridItem(.flexible(), spacing: 6)], spacing: 6) { + record("历史最高 · 连续 7 日", statistics?.highSevenDays, + fallback: "尚无完整连续 7 日", color: usageColor, kind: .week) + record("历史最低 · 连续 7 日", statistics?.lowSevenDays, + fallback: "尚无完整连续 7 日", color: lowColor, kind: .week) + record("历史最高 · 单日", statistics?.highDay, + fallback: "尚无完整单日", color: usageColor, kind: .day) + record("历史最高 · 单个时段", statistics?.highInterval, + fallback: "尚无有效时段", color: usageColor, kind: .interval) + } + } + .padding(.top, 9) + .overlay(alignment: .top) { separator.frame(height: 1) } + } + + private enum RecordKind { case week, day, interval } + + private func record(_ title: String, _ value: HistoricalUsageRecord?, fallback: String, + color: Color, kind: RecordKind) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.system(size: 9, weight: .medium)).foregroundStyle(secondary) + .lineLimit(1) + Text(value.map { $0.usedPercent.formatted(.number.precision(.fractionLength(1))) + "%" } ?? "—") + .font(.system(size: 15, weight: .bold)).monospacedDigit() + .foregroundStyle(value == nil ? secondary : color) + .lineLimit(1) + Text(value.map { dateLabel($0, kind: kind) } ?? fallback) + .font(.system(size: 9)).foregroundStyle(secondary) + .lineLimit(1).truncationMode(.tail) + } + .frame(maxWidth: .infinity, minHeight: 68, alignment: .leading) + .padding(.horizontal, 9) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.52), + in: RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).stroke(separator, lineWidth: 1)) + .help(value.map { dateLabel($0, kind: kind) } ?? fallback) + } + + private func dateLabel(_ record: HistoricalUsageRecord, kind: RecordKind) -> String { + let calendar = Calendar.current + func formatted(_ date: Date, _ format: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = format + return formatter.string(from: date) + } + switch kind { + case .week: + return "\(formatted(record.startsAt, "M月d日"))–\(formatted(record.endsAt.addingTimeInterval(-1), "M月d日"))" + case .day: + return formatted(record.startsAt, "M月d日") + case .interval: + let lastInstant = record.endsAt.addingTimeInterval(-0.001) + let endTime = calendar.isDate(record.startsAt, inSameDayAs: lastInstant) + && !calendar.isDate(record.startsAt, inSameDayAs: record.endsAt) + ? "24:00" : formatted(record.endsAt, "HH:mm") + if calendar.isDate(record.startsAt, inSameDayAs: lastInstant) { + return "\(formatted(record.startsAt, "M月d日 HH:mm"))–\(endTime)" + } + return "\(formatted(record.startsAt, "M月d日 HH:mm"))–\(formatted(record.endsAt, "M月d日 HH:mm"))" + } + } +} + +private struct CapBarTotalTrendStatistics: View { + let statistics: ProviderTrendStatistics + let binHours: Int + + @Environment(\.colorScheme) private var colorScheme + private let secondary = Color(nsColor: .secondaryLabelColor) + private let separator = Color(nsColor: .separatorColor) + private var usageColor: Color { + colorScheme == .dark ? Color(red: 0.51, green: 0.73, blue: 1.0) + : Color(red: 0.14, green: 0.47, blue: 0.82) + } + private var coverageColor: Color { + colorScheme == .dark ? Color(red: 0.43, green: 0.82, blue: 0.75) + : Color(red: 0.10, green: 0.55, blue: 0.49) + } + private var periodColor: Color { + colorScheme == .dark ? Color(red: 1.0, green: 0.74, blue: 0.43) + : Color(red: 0.69, green: 0.39, blue: 0.12) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + primary(label: "最高 · 每 \(binHours) 小时", value: percent(statistics.peak?.value), + note: statistics.peak.map { peakTime($0.endAt) } ?? "数据不足", color: periodColor) + divider + primary(label: "最低 · 每 \(binHours) 小时", value: percent(statistics.minimum), + note: "有效区间含 0%", color: coverageColor) + divider + primary(label: "平均 · 每 \(binHours) 小时", value: percent(statistics.average), + note: "仅计算有效区间", color: usageColor) + } + .padding(.vertical, 8) + .overlay(alignment: .top) { separator.frame(height: 1) } + .overlay(alignment: .bottom) { separator.frame(height: 1) } + .padding(.bottom, 6) + + HStack(spacing: 7) { + Text("已观测合计").foregroundStyle(secondary) + Text(percent(statistics.observedTotal)).fontWeight(.semibold).foregroundStyle(usageColor) + Spacer(minLength: 2) + Text("数据覆盖").foregroundStyle(secondary) + Text("\(statistics.validBinCount)/\(statistics.expectedBinCount) 区间") + .fontWeight(.semibold).foregroundStyle(coverageColor) + } + .modifier(StatisticsRowStyle()) + + HStack(spacing: 7) { + Text("贡献最多").foregroundStyle(secondary).fixedSize() + Text(statistics.leader?.label ?? "—") + .fontWeight(.semibold) + .foregroundStyle(statistics.leader == nil ? secondary : usageColor) + .lineLimit(1).truncationMode(.middle) + .help(statistics.leader?.label ?? "尚无账号用量") + Spacer(minLength: 2) + if let leader = statistics.leader { + Text("等效用量 \(percent(leader.equivalentPercent)) · 占比 \(percent(leader.sharePercent))") + .font(.system(size: 10, weight: .semibold)).foregroundStyle(coverageColor).fixedSize() + .help("按首账号套餐换算的用量;占比为该账号在本服务已观测合计中的比例") + } + } + .modifier(StatisticsRowStyle()) + + HStack(spacing: 7) { + if statistics.usagePeriodsAreUniform { + Text("时段分布").foregroundStyle(secondary) + Text("无明显差异").fontWeight(.semibold).foregroundStyle(secondary) + } else { + Text("高用量时段").foregroundStyle(secondary).fixedSize() + Text(statistics.highUsagePeriod?.label ?? "数据不足") + .fontWeight(.semibold) + .foregroundStyle(statistics.highUsagePeriod == nil ? secondary : periodColor) + Spacer(minLength: 2) + Text("低用量时段").foregroundStyle(secondary).fixedSize() + Text(statistics.lowUsagePeriod?.label ?? "数据不足") + .fontWeight(.semibold) + .foregroundStyle(statistics.lowUsagePeriod == nil ? secondary : coverageColor) + } + } + .help("按本地时间和已观测用量估算;缺测区间不计入") + .modifier(StatisticsRowStyle()) + + HStack(spacing: 7) { + Text("最近 24 小时").foregroundStyle(secondary).fixedSize() + Text(percent(statistics.recent24Hours.usedPercent)) + .fontWeight(.semibold) + .foregroundStyle(statistics.recent24Hours.usedPercent == nil ? secondary : usageColor) + Text("覆盖 \(statistics.recent24Hours.validBinCount)/\(statistics.recent24Hours.expectedBinCount)") + .font(.system(size: 9)).foregroundStyle(secondary).fixedSize() + Spacer(minLength: 2) + Text("较前 24 小时").foregroundStyle(secondary).fixedSize() + Text(TrendStatisticsText.changeLabel(statistics.changePercent)) + .fontWeight(.semibold) + .foregroundStyle(statistics.changePercent == nil ? secondary : Color(red: 0.17, green: 0.62, blue: 0.45)) + .help(statistics.changePercent == nil ? "前一窗口采样不足或基线为零" : "与前一个完整 24 小时窗口相比") + } + .modifier(StatisticsRowStyle()) + } + .font(.system(size: 10)) + .monospacedDigit() + } + + private var divider: some View { + separator.frame(width: 1, height: 43) + } + + private func primary(label: String, value: String, note: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label).font(.system(size: 9)).foregroundStyle(secondary).lineLimit(1) + Text(value).font(.system(size: 15, weight: .bold)) + .foregroundStyle(value == "—" ? secondary : color).lineLimit(1) + Text(note).font(.system(size: 8)).foregroundStyle(secondary).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + } + + private func percent(_ value: Double?) -> String { + guard let value else { return "—" } + return value.formatted(.number.precision(.fractionLength(1))) + "%" + } + + private func peakTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = .current + formatter.dateFormat = "M/d HH:mm" + return formatter.string(from: date) + } +} + +private struct StatisticsRowStyle: ViewModifier { + func body(content: Content) -> some View { + content.frame(minHeight: 23).frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + } +} + private struct CapBarAccountRow: View { let row: PopoverAccountRow let width: Int diff --git a/Sources/CapBarCore/UI/SettingsView.swift b/Sources/CapBarCore/UI/SettingsView.swift index 3e59a8c..c7ac84e 100644 --- a/Sources/CapBarCore/UI/SettingsView.swift +++ b/Sources/CapBarCore/UI/SettingsView.swift @@ -177,19 +177,43 @@ struct CapBarSettingsView: View { .padding(.vertical, 12) } ForEach(model.rows, id: \.account) { row in - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text(row.title).font(.system(size: 12, weight: .semibold)).lineLimit(1) - Text("\(row.account.provider == .claude ? "Claude" : "Codex") · \(row.directoryLabel) · \(row.subtitle)") - .font(.system(size: 10)).foregroundStyle(secondary).lineLimit(1) - .help(row.account.directory) + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(row.title).font(.system(size: 12, weight: .semibold)).lineLimit(1) + Text("\(row.account.provider == .claude ? "Claude" : "Codex") · \(row.directoryLabel) · \(row.subtitle)") + .font(.system(size: 10)).foregroundStyle(secondary).lineLimit(1) + .help(row.account.directory) + } + Spacer(minLength: 8) + Button("移除") { model.removeAccount(row.account) } + .buttonStyle(.plain) + .font(.system(size: 11)).foregroundStyle(secondary) + .disabled(row.isRefreshing) + .accessibilityLabel("移除 \(row.title)") + } + if model.settings.usageStatisticsEnabled { + HStack(spacing: 8) { + Text("总走势套餐").font(.system(size: 10, weight: .medium)).foregroundStyle(secondary) + Spacer(minLength: 6) + Picker("总走势套餐", selection: Binding( + get: { model.settings.planOverrides.first { $0.account == row.account }?.tier }, + set: { model.setPlanOverride($0, for: row.account) } + )) { + Text("自动识别").tag(Optional.none) + ForEach(UsagePlanTier.options(for: row.account.provider), id: \.self) { tier in + Text(tier.displayName).tag(Optional(tier)) + } + } + .labelsHidden() + .frame(width: 152) + .font(.system(size: 10)) + } + if model.planTier(for: row.account) == nil { + Text("当前套餐无法细分,请选择具体档位后参与总走势") + .font(.system(size: 10)).foregroundStyle(Color(nsColor: .systemOrange)) + } } - Spacer(minLength: 8) - Button("移除") { model.removeAccount(row.account) } - .buttonStyle(.plain) - .font(.system(size: 11)).foregroundStyle(secondary) - .disabled(row.isRefreshing) - .accessibilityLabel("移除 \(row.title)") } .padding(.vertical, 10) line.frame(height: 1) diff --git a/Sources/CapBarCore/UI/UsageTrendChart.swift b/Sources/CapBarCore/UI/UsageTrendChart.swift index 00bd9ab..13c1dda 100644 --- a/Sources/CapBarCore/UI/UsageTrendChart.swift +++ b/Sources/CapBarCore/UI/UsageTrendChart.swift @@ -1,7 +1,18 @@ import SwiftUI enum UsageTrendChartText { - static func intervalUsageLabel(binHours: Int) -> String { "这 \(binHours) 小时用量" } + static func intervalUsageLabel(binHours: Int, isTotal: Bool = false) -> String { + "这 \(binHours) 小时\(isTotal ? "合计" : "")用量" + } +} + +enum TrendStatisticsText { + static func changeLabel(_ change: Double?) -> String { + guard let change, change.isFinite else { return "—" } + let raw = change.rounded() + let rounded = raw == 0 ? 0 : raw + return "\(rounded > 0 ? "+" : "")\(String(format: "%.0f", rounded))%" + } } enum UsageTrendEmptyState { @@ -39,6 +50,12 @@ struct UsageTrendDateLabel { let text: String } +struct UsageTrendLineSegment { + let from: Int + let to: Int + let dashed: Bool +} + enum UsageTrendChartLayout { static let leading: CGFloat = 26 static let trailing: CGFloat = 8 @@ -83,55 +100,92 @@ enum UsageTrendChartLayout { let validIndices = points.indices.filter { points[$0].usedPercent != nil } return validIndices.count == 1 ? validIndices[0] : nil } + + static func lineSegments(points: [UsageTrendPoint], connectMissing: Bool) -> [UsageTrendLineSegment] { + var result: [UsageTrendLineSegment] = [] + var previous: Int? + for index in points.indices where points[index].usedPercent != nil { + if let previous, connectMissing || index == previous + 1 { + result.append(UsageTrendLineSegment( + from: previous, to: index, + dashed: connectMissing && (index != previous + 1 || points[index].crossesReset) + )) + } + previous = index + } + return result + } + + static func isolatedPointIndices(points: [UsageTrendPoint]) -> [Int] { + points.indices.filter { index in + points[index].usedPercent != nil + && (index == 0 || points[index - 1].usedPercent == nil) + && (index == points.count - 1 || points[index + 1].usedPercent == nil) + } + } } struct UsageTrendChart: View { let series: UsageTrendSeries? var calendar: Calendar = .current + var colorRange: ClosedRange? = nil + var totalOverview: ProviderTrendOverview? = nil @State private var hoveredIndex: Int? - private let chartHeight: CGFloat = 84 + private var chartHeight: CGFloat { totalOverview == nil ? 84 : 105 } private let secondary = Color(nsColor: .secondaryLabelColor) var body: some View { - GeometryReader { geometry in - if let series, series.points.contains(where: { $0.usedPercent != nil }) { - chart(series: series, size: geometry.size) - .contentShape(Rectangle()) - .onContinuousHover { phase in - switch phase { - case .active(let location): - hoveredIndex = UsageTrendChartLayout.nearestPointIndex( - at: location.x, width: geometry.size.width, count: series.points.count - ) - case .ended: - hoveredIndex = nil + VStack(alignment: .leading, spacing: 0) { + GeometryReader { geometry in + if let series, series.points.contains(where: { $0.usedPercent != nil }) { + chart(series: series, size: geometry.size) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let location): + hoveredIndex = UsageTrendChartLayout.nearestPointIndex( + at: location.x, width: geometry.size.width, count: series.points.count + ) + case .ended: + hoveredIndex = nil + } } + } else if let series { + let lines = UsageTrendEmptyState.lines( + sampleCount: series.sampleCount, binHours: series.binHours, nextSampleAt: series.nextSampleAt, + now: Date(), calendar: calendar + ) + VStack(spacing: 5) { + Text(lines[0]).font(.system(size: 11, weight: .semibold)) + Text(lines[1]).font(.system(size: 10)).foregroundStyle(secondary) + Text(lines[2]).font(.system(size: 10, weight: .medium)).foregroundStyle(Color.accentColor) } - } else if let series { - let lines = UsageTrendEmptyState.lines( - sampleCount: series.sampleCount, binHours: series.binHours, nextSampleAt: series.nextSampleAt, - now: Date(), calendar: calendar - ) - VStack(spacing: 5) { - Text(lines[0]).font(.system(size: 11, weight: .semibold)) - Text(lines[1]).font(.system(size: 10)).foregroundStyle(secondary) - Text(lines[2]).font(.system(size: 10, weight: .medium)).foregroundStyle(Color.accentColor) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(secondary.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))) - } else { - Text("正在读取历史采样记录…") - .font(.system(size: 10)).foregroundStyle(secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(secondary.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))) + } else { + Text("正在读取历史采样记录…") + .font(.system(size: 10)).foregroundStyle(secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(secondary.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))) + } + } + .frame(height: chartHeight) + if let totalOverview, let series, let hoveredIndex, + series.points.indices.contains(hoveredIndex) { + TotalTrendTooltip(point: series.points[hoveredIndex], series: series, + overview: totalOverview, calendar: calendar) + .padding(.leading, UsageTrendChartLayout.leading) + .padding(.trailing, UsageTrendChartLayout.trailing) + .padding(.top, 7) } } - .frame(height: chartHeight) } private func chart(series: UsageTrendSeries, size: CGSize) -> some View { - let segments = lineSegments(points: series.points) + let isTotal = totalOverview != nil + let segments = UsageTrendChartLayout.lineSegments(points: series.points, connectMissing: !isTotal) + let range = colorRange ?? UsageTrendColorScale.range(for: [series]) return ZStack(alignment: .topLeading) { ForEach(Array(series.axisTicks.enumerated()), id: \.offset) { index, tick in let y = UsageTrendChartLayout.yPosition(value: tick, axisMaximum: series.axisMaximum, height: size.height) @@ -158,24 +212,33 @@ struct UsageTrendChart: View { control2: CGPoint(x: to.x - delta * 0.42, y: to.y) ) } - .stroke(Color.accentColor, style: StrokeStyle( + .stroke(LinearGradient( + colors: (0...4).map { step in + let start = series.points[segment.from].usedPercent ?? 0 + let end = series.points[segment.to].usedPercent ?? 0 + return trendColor(start + (end - start) * Double(step) / 4, range: range) + }, startPoint: .leading, endPoint: .trailing + ), style: StrokeStyle( lineWidth: 1.8, lineCap: .round, lineJoin: .round, dash: segment.dashed ? [3.5, 3] : [] )) } - if let index = UsageTrendChartLayout.standalonePointIndex(points: series.points), - let usage = series.points[index].usedPercent { + ForEach(isTotal ? UsageTrendChartLayout.isolatedPointIndices(points: series.points) + : UsageTrendChartLayout.standalonePointIndex(points: series.points).map { [$0] } ?? [], id: \.self) { index in + let usage = series.points[index].usedPercent ?? 0 let point = coordinate(for: index, points: series.points, size: size, maximum: series.axisMaximum) Circle() - .fill(Color.accentColor) + .fill(trendColor(usage, range: range)) .strokeBorder(Color(nsColor: .windowBackgroundColor), lineWidth: 1.5) .frame(width: 8, height: 8) .position(point) - Text(percent(usage)) - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Color.accentColor) - .position(x: max(UsageTrendChartLayout.leading + 16, point.x - 18), - y: max(UsageTrendChartLayout.top + 6, point.y - 13)) + if !isTotal { + Text(percent(usage)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(trendColor(usage, range: range)) + .position(x: max(UsageTrendChartLayout.leading + 16, point.x - 18), + y: max(UsageTrendChartLayout.top + 6, point.y - 13)) + } } ForEach(UsageTrendChartLayout.dateLabels(points: series.points, binHours: series.binHours, calendar: calendar), id: \.index) { label in Text(label.text) @@ -196,21 +259,24 @@ struct UsageTrendChart: View { .stroke(Color.accentColor.opacity(0.8), style: StrokeStyle(lineWidth: 1, dash: [2, 2])) if let usage = point.usedPercent { Circle() - .fill(Color.accentColor) + .fill(trendColor(usage, range: range)) .strokeBorder(Color(nsColor: .windowBackgroundColor), lineWidth: 1.5) .frame(width: 7, height: 7) .position(x: x, y: UsageTrendChartLayout.yPosition(value: usage, axisMaximum: series.axisMaximum, height: size.height)) } - tooltip(for: point, series: series) - .frame(width: 166) - .position(x: min(max(83, x), max(83, size.width - 83)), y: -29) - .zIndex(3) + if !isTotal { + individualTooltip(for: point, series: series, range: range) + .frame(width: 166) + .position(x: min(max(83, x), max(83, size.width - 83)), y: -29) + .zIndex(3) + } } } .frame(width: size.width, height: size.height) } - private func tooltip(for point: UsageTrendPoint, series: UsageTrendSeries) -> some View { + private func individualTooltip(for point: UsageTrendPoint, series: UsageTrendSeries, + range: ClosedRange?) -> some View { let formatter = DateFormatter() formatter.calendar = calendar formatter.timeZone = calendar.timeZone @@ -230,7 +296,9 @@ struct UsageTrendChart: View { HStack { Text(UsageTrendChartText.intervalUsageLabel(binHours: series.binHours)).foregroundStyle(secondary) Spacer(minLength: 4) - Text(point.usedPercent.map(percent) ?? "—").fontWeight(.bold) + Text(point.usedPercent.map(percent) ?? "—") + .foregroundStyle(point.usedPercent.map { trendColor($0, range: range) } ?? secondary) + .fontWeight(.bold) } Text(point.isMissing ? "缺测区间,额度未知" : point.crossesReset ? "跨重置区间" : point.isEstimated ? "线性估算" : "采样值") .font(.system(size: 9)).foregroundStyle(secondary) @@ -250,19 +318,124 @@ struct UsageTrendChart: View { ) } - private func lineSegments(points: [UsageTrendPoint]) -> [(from: Int, to: Int, dashed: Bool)] { - var result: [(from: Int, to: Int, dashed: Bool)] = [] - var previous: Int? - for index in points.indices where points[index].usedPercent != nil { - if let previous { - result.append((previous, index, index != previous + 1 || points[index].crossesReset)) + private func trendColor(_ value: Double, range: ClosedRange?) -> Color { + guard totalOverview != nil else { return .accentColor } + let rgb = UsageTrendColorScale.turboRGB(at: UsageTrendColorScale.fraction(value, in: range)) + return Color(red: rgb.red, green: rgb.green, blue: rgb.blue) + } + + private func percent(_ value: Double) -> String { + value.rounded() == value ? String(format: "%.0f%%", value) : String(format: "%.1f%%", value) + } +} + +struct TotalTrendTooltip: View { + let point: UsageTrendPoint + let series: UsageTrendSeries + let overview: ProviderTrendOverview + let calendar: Calendar + + private var secondary: Color { Color(nsColor: .secondaryLabelColor) } + private var contributions: [TrendContribution]? { overview.contributions(at: point.endAt) } + + private var dateLabel: String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "M月d日 HH:mm" + return formatter.string(from: point.endAt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(dateLabel + " · 本地时间") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(secondary) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("这 \(series.binHours) 小时合计用量") + .font(.system(size: 11, weight: .medium)) + Spacer(minLength: 4) + Text(point.usedPercent.map(percent) ?? "—") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(point.usedPercent.map { scaleColor($0, in: UsageTrendColorScale.range(for: [series])) } ?? secondary) + } + .padding(.top, 5) + Text(point.isMissing ? "采样覆盖不足" : "以 \(overview.baselinePlan?.displayName ?? "套餐") 为基准 · \(overview.accountCount) 个账号") + .font(.system(size: 10)) + .foregroundStyle(secondary) + .padding(.top, 2) + + if let contributions { + Rectangle().fill(Color(nsColor: .separatorColor).opacity(0.8)) + .frame(height: 1).padding(.vertical, 9) + HStack { + Text("账号列表") + Spacer(minLength: 4) + Text("估算等效使用量") + } + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(secondary) + .padding(.bottom, 5) + ForEach(contributions.indices, id: \.self) { index in + let contribution = contributions[index] + HStack(spacing: 10) { + Text(contribution.label) + .lineLimit(1).truncationMode(.middle) + .foregroundStyle(Color.primary) + .frame(width: 180, alignment: .leading) + GeometryReader { geometry in + Capsule().fill(Color(nsColor: .separatorColor).opacity(0.44)) + if contribution.equivalentPercent > 0 { + LinearGradient(colors: (0...20).map { step in + scaleColor(Double(step), in: 0...TotalTrendBreakdownScale.maximumPercent) + }, startPoint: .leading, endPoint: .trailing) + .frame(width: geometry.size.width, height: 7) + .frame(width: geometry.size.width * TotalTrendBreakdownScale.barFraction( + contribution.equivalentPercent + ), height: 7, alignment: .leading) + .clipped() + .clipShape(Capsule()) + } + } + .frame(height: 7) + Text(percent(contribution.equivalentPercent)) + .fontWeight(.bold).monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.85) + .foregroundStyle(scaleColor(contribution.equivalentPercent, + in: 0...TotalTrendBreakdownScale.maximumPercent)) + .frame(width: 52, alignment: .trailing) + } + .font(.system(size: 11)) + .frame(height: 26) + } } - previous = index } - return result + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color(nsColor: .separatorColor).opacity(0.7))) + .shadow(color: .black.opacity(0.24), radius: 11, y: 5) + .allowsHitTesting(false) } private func percent(_ value: Double) -> String { value.rounded() == value ? String(format: "%.0f%%", value) : String(format: "%.1f%%", value) } + + private func scaleColor(_ value: Double, in range: ClosedRange?) -> Color { + let rgb = UsageTrendColorScale.turboRGB(at: UsageTrendColorScale.fraction(value, in: range)) + return Color(red: rgb.red, green: rgb.green, blue: rgb.blue) + } +} + +enum TotalTrendBreakdownScale { + static let maximumPercent = 20.0 + + static func barFraction(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(1, max(0, value / maximumPercent)) + } } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..be3fabb --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,9 @@ +# Third-party notices + +CapBar's trend palette uses a softened, cropped range of the Google Research Turbo colormap polynomial approximation. + +- Copyright 2019 Google LLC +- Licensed under the Apache License, Version 2.0. The license text is bundled at `Sources/CapBarCore/Resources/Turbo-LICENSE.txt`. +- Source: https://gist.github.com/mikhailov-work/0d177465a8151eb6ede1768d51d476c7 + +CapBar's own source remains licensed under the MIT License in `LICENSE`. diff --git a/Tests/CapBarChecks/HistoricalTrendStatisticsChecks.swift b/Tests/CapBarChecks/HistoricalTrendStatisticsChecks.swift new file mode 100644 index 0000000..251e44d --- /dev/null +++ b/Tests/CapBarChecks/HistoricalTrendStatisticsChecks.swift @@ -0,0 +1,90 @@ +import Foundation +@testable import CapBarCore + +@MainActor func runHistoricalTrendStatisticsChecks() { + let hour: TimeInterval = 3_600 + let day: TimeInterval = 24 * hour + let origin = Date(timeIntervalSince1970: 0) + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(secondsFromGMT: 0)! + + func series(days: Int, missing: Set = []) -> UsageTrendSeries { + let points = (0..<(days * 6)).map { index -> UsageTrendPoint in + let dayIndex = index / 6 + let endAt = origin.addingTimeInterval(Double(index + 1) * 4 * hour) + return UsageTrendPoint(endAt: endAt, + usedPercent: missing.contains(index) ? nil : Double(dayIndex + 1), + remainingPercent: nil, crossesReset: false, isEstimated: false) + } + return UsageTrendSeries(points: points, binHours: 4, axisMaximum: 10, + axisTicks: [0, 5, 10], sampleCount: points.count) + } + func near(_ actual: Double?, _ expected: Double) -> Bool { + actual.map { abs($0 - expected) < 0.000_001 } ?? false + } + + let nineDays = HistoricalTrendStatistics.calculate(series: series(days: 9), calendar: utc) + check(near(nineDays.highSevenDays?.usedPercent, 252) + && nineDays.highSevenDays?.startsAt == origin.addingTimeInterval(2 * day) + && nineDays.highSevenDays?.endsAt == origin.addingTimeInterval(9 * day), + "highest seven days use the greatest complete rolling day window") + check(near(nineDays.lowSevenDays?.usedPercent, 168) + && nineDays.lowSevenDays?.startsAt == origin + && nineDays.lowSevenDays?.endsAt == origin.addingTimeInterval(7 * day), + "lowest seven days include the first complete rolling day window") + check(near(nineDays.highDay?.usedPercent, 54) + && nineDays.highDay?.startsAt == origin.addingTimeInterval(8 * day), + "highest day reports its complete local calendar day and use") + check(near(nineDays.highInterval?.usedPercent, 9) + && nineDays.highInterval?.endsAt == origin.addingTimeInterval(9 * day), + "highest interval reports the actual observed bin, including its time") + + let sevenDays = HistoricalTrendStatistics.calculate(series: series(days: 7), calendar: utc) + check(near(sevenDays.highSevenDays?.usedPercent, 168) + && near(sevenDays.lowSevenDays?.usedPercent, 168), + "exactly seven complete days form one valid rolling window") + let missingBin = HistoricalTrendStatistics.calculate(series: series(days: 9, missing: [4 * 6 + 2]), calendar: utc) + check(missingBin.highSevenDays == nil && missingBin.lowSevenDays == nil, + "a missing bin invalidates every seven-day window that crosses it") + check(near(missingBin.highDay?.usedPercent, 54) + && near(missingBin.highInterval?.usedPercent, 9), + "an incomplete week does not hide complete-day and valid-interval records") + let partial = HistoricalTrendStatistics.calculate(series: series(days: 1, missing: [0]), calendar: utc) + check(partial.highSevenDays == nil && partial.highDay == nil + && near(partial.highInterval?.usedPercent, 1), + "partial day has no day or week record but retains observed interval use") + let empty = HistoricalTrendStatistics.calculate(series: series(days: 0), calendar: utc) + check(empty.highSevenDays == nil && empty.lowSevenDays == nil + && empty.highDay == nil && empty.highInterval == nil, + "empty history has no invented zero-use records") + let idlePoints = series(days: 7).points.map { + UsageTrendPoint(endAt: $0.endAt, usedPercent: 0, remainingPercent: nil, + crossesReset: false, isEstimated: false) + } + let idleSeries = UsageTrendSeries(points: idlePoints, binHours: 4, + axisMaximum: 10, axisTicks: [0, 5, 10], sampleCount: idlePoints.count) + let idle = HistoricalTrendStatistics.calculate(series: idleSeries, calendar: utc) + check(near(idle.highSevenDays?.usedPercent, 0) && near(idle.lowSevenDays?.usedPercent, 0) + && near(idle.highDay?.usedPercent, 0) && near(idle.highInterval?.usedPercent, 0), + "fully observed zero use is a real historical record, distinct from missing data") + + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = TimeZone(identifier: "Asia/Shanghai")! + let local = HistoricalTrendStatistics.calculate(series: series(days: 9), calendar: shanghai) + check(near(local.highDay?.usedPercent, 52) + && local.highDay?.startsAt == origin.addingTimeInterval(8 * day - 8 * hour), + "daily totals split UTC bins at local midnight") + + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let springStart = ISO8601DateFormatter().date(from: "2026-03-06T00:00:00Z")! + let springPoints = (1...(10 * 6)).map { index in + UsageTrendPoint(endAt: springStart.addingTimeInterval(Double(index) * 4 * hour), + usedPercent: 1, remainingPercent: nil, crossesReset: false, isEstimated: false) + } + let springSeries = UsageTrendSeries(points: springPoints, binHours: 4, + axisMaximum: 10, axisTicks: [0, 5, 10], sampleCount: springPoints.count) + let spring = HistoricalTrendStatistics.calculate(series: springSeries, calendar: pacific) + check(near(spring.highSevenDays?.usedPercent, 41.75), + "rolling local days include a 23-hour daylight-saving day without treating it as a gap") +} diff --git a/Tests/CapBarChecks/PopoverModelChecks.swift b/Tests/CapBarChecks/PopoverModelChecks.swift index 11d0a97..8877110 100644 --- a/Tests/CapBarChecks/PopoverModelChecks.swift +++ b/Tests/CapBarChecks/PopoverModelChecks.swift @@ -22,6 +22,8 @@ import Foundation let rows = PopoverPresentation.rows(settings: settings, state: view, now: now, calendar: calendar) check(rows.count == 3, "configured accounts retain order") check(rows[0].title == "person@example.com" && rows[0].subtitle == "Example · Team", "identity uses email then organization and plan") + check(rows[0].detectedPlan == "Team" && rows[1].detectedPlan == "Pro", + "rows retain raw detected plan for subscription calibration") check(rows[0].timeLabel == "17 分钟前" && rows[0].isRefreshing, "loading keeps old capture time") check(rows[0].captureAgeBand == .underThirty, "row exposes a time color from its actual snapshot age") check(!rows[0].refreshEnabled && rows[0].windows.count == 2, "only loading account disables its refresh") diff --git a/Tests/CapBarChecks/ProviderTrendStatisticsChecks.swift b/Tests/CapBarChecks/ProviderTrendStatisticsChecks.swift new file mode 100644 index 0000000..03a5d67 --- /dev/null +++ b/Tests/CapBarChecks/ProviderTrendStatisticsChecks.swift @@ -0,0 +1,126 @@ +import Foundation +@testable import CapBarCore + +@MainActor func runProviderTrendStatisticsChecks() { + let hour: TimeInterval = 3_600 + let end = ISO8601DateFormatter().date(from: "2026-09-30T12:00:00Z")! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + + func series(_ values: [Double?], binHours: Int = 2, endingAt end: Date = end) -> UsageTrendSeries { + let points = values.enumerated().map { index, value in + UsageTrendPoint( + endAt: end.addingTimeInterval(Double(index - values.count + 1) * Double(binHours) * hour), + usedPercent: value, remainingPercent: nil, crossesReset: false, isEstimated: false + ) + } + return UsageTrendSeries(points: points, binHours: binHours, axisMaximum: 20, + axisTicks: [0, 10, 20], sampleCount: values.count) + } + func single(_ values: [Double?], binHours: Int = 2, endingAt end: Date = end) -> ProviderTrendOverview { + ProviderTrendOverview.build([ + TrendOverviewAccount(label: "alpha@example.com", tier: .claudePro, + series: series(values, binHours: binHours, endingAt: end)) + ]) + } + func near(_ actual: Double?, _ expected: Double) -> Bool { + actual.map { abs($0 - expected) < 0.000_001 } ?? false + } + + let sparse = single([nil, 0, 2, 4, nil]).statistics(calendar: calendar) + let trailingGap = single([1, 4, nil]) + check(near(trailingGap.latestObservedPoint?.usedPercent, 4) + && trailingGap.latestObservedPoint?.endAt == end.addingTimeInterval(-2 * hour), + "total headline uses the preceding observed interval while the newest bin awaits a sample") + check(single([0, nil]).latestObservedPoint?.usedPercent == 0, + "a measured zero remains available when the latest bin is missing") + check(single([nil, nil]).latestObservedPoint == nil, + "total headline remains unavailable when no interval has been observed") + let partiallyObservedTotal = ProviderTrendOverview.build([ + TrendOverviewAccount(label: "one@example.com", tier: .claudePro, series: series([2, 4, 6])), + TrendOverviewAccount(label: "two@example.com", tier: .claudePro, series: series([1, 3, nil])) + ]) + check(near(partiallyObservedTotal.latestObservedPoint?.usedPercent, 7) + && partiallyObservedTotal.latestObservedPoint?.endAt == end.addingTimeInterval(-2 * hour), + "total headline only falls back to a bin with observations for every account") + check(near(sparse?.peak?.value, 4) && sparse?.peak?.endAt == end.addingTimeInterval(-2 * hour), + "peak uses the largest observed interval and its ending time") + check(near(sparse?.minimum, 0) && near(sparse?.average, 2) && near(sparse?.observedTotal, 6), + "minimum includes real zero while average and total exclude missing intervals") + check(sparse?.validBinCount == 3 && sparse?.expectedBinCount == 5, + "coverage reports valid intervals against every plotted interval") + check(near(sparse?.recent24Hours.usedPercent, 6) + && sparse?.recent24Hours.validBinCount == 3 + && sparse?.recent24Hours.expectedBinCount == 12 + && sparse?.changePercent == nil, + "partial recent day keeps its observed amount and coverage without inventing a comparison") + + let allMissing = single([nil, nil]).statistics(calendar: calendar) + check(allMissing?.peak == nil && allMissing?.minimum == nil && allMissing?.average == nil + && allMissing?.observedTotal == nil && allMissing?.leader == nil, + "no measured intervals display unavailable statistics instead of zero") + let idle = single([0, 0]).statistics(calendar: calendar) + check(near(idle?.average, 0) && near(idle?.observedTotal, 0) && idle?.leader == nil, + "genuine zero use is measured but does not invent a leading account") + + let weighted = ProviderTrendOverview.build([ + TrendOverviewAccount(label: "Pro account", tier: .claudePro, series: series([1, 2, nil])), + TrendOverviewAccount(label: "Max account", tier: .claudeMax5, series: series([2, 1, 3])) + ]).statistics(calendar: calendar) + check(near(weighted?.observedTotal, 18) && weighted?.validBinCount == 2, + "provider statistics use only complete bins after subscription conversion") + check(weighted?.leader?.label == "Max account" + && near(weighted?.leader?.equivalentPercent, 15) + && near(weighted?.leader?.sharePercent, 15 / 18 * 100), + "leader sums baseline-equivalent account contributions only where total bins are valid") + + let twoDays = single(Array(repeating: 1.0, count: 12) + Array(repeating: 2.0, count: 12)) + .statistics(calendar: calendar) + check(near(twoDays?.recent24Hours.usedPercent, 24) + && twoDays?.recent24Hours.validBinCount == 12 + && near(twoDays?.changePercent, 100), + "two complete twenty-four-hour windows show relative change") + let missingPrevious = single([nil] + Array(repeating: 1.0, count: 11) + + Array(repeating: 2.0, count: 12)).statistics(calendar: calendar) + check(near(missingPrevious?.recent24Hours.usedPercent, 24) + && missingPrevious?.changePercent == nil, + "comparison is unavailable if the prior day contains a gap") + let zeroBaseline = single(Array(repeating: 0.0, count: 12) + + Array(repeating: 2.0, count: 12)).statistics(calendar: calendar) + check(zeroBaseline?.changePercent == nil, + "positive use after a zero baseline does not produce an infinite percent change") + let idleComparison = single(Array(repeating: 0.0, count: 24)).statistics(calendar: calendar) + check(near(idleComparison?.changePercent, 0), "two fully observed idle days have zero change") + + let localStart = ISO8601DateFormatter().date(from: "2026-09-27T16:00:00Z")! + let eveningValues = (0..<24).map { index -> Double in + [18, 20, 22].contains(index * 2 % 24) ? 8 : 1 + } + let evenings = single(eveningValues, endingAt: localStart.addingTimeInterval(48 * hour)) + .statistics(calendar: calendar) + check(evenings?.highUsagePeriod?.label == "18:00–24:00", + "high-use period follows local clock time across multiple observed days") + check(evenings?.lowUsagePeriod?.label == "00:00–06:00", + "low-use period picks the lowest observed local-time rate, including genuine quiet hours") + let coarseValues: [Double] = [1, 1, 16, 1, 1, 16] + let coarse = single(coarseValues, binHours: 8, endingAt: localStart.addingTimeInterval(48 * hour)) + .statistics(calendar: calendar) + check(coarse?.highUsagePeriod?.label == "16:00–24:00", + "eight-hour data uses broad eight-hour local periods rather than six-hour precision") + check(coarse?.lowUsagePeriod?.label == "00:00–08:00", + "low-use period respects the same broad buckets as high use") + let uniform = single(Array(repeating: 1.0, count: 24), + endingAt: localStart.addingTimeInterval(48 * hour)).statistics(calendar: calendar) + check(uniform?.highUsagePeriod == nil && uniform?.lowUsagePeriod == nil + && uniform?.usagePeriodsAreUniform == true, + "equal use throughout the day does not invent a high or low period") + let oneObservedPeriod = single([8, nil, nil, 8, nil, nil, 8], binHours: 8, + endingAt: localStart.addingTimeInterval(56 * hour)) + .statistics(calendar: calendar) + check(oneObservedPeriod?.highUsagePeriod == nil && oneObservedPeriod?.lowUsagePeriod == nil + && oneObservedPeriod?.usagePeriodsAreUniform == false, + "one observed time bucket cannot establish a high, low, or uniform daily pattern") + check(sparse?.highUsagePeriod == nil && sparse?.lowUsagePeriod == nil + && sparse?.usagePeriodsAreUniform == false, + "a few isolated bins do not claim habitual high or low periods") +} diff --git a/Tests/CapBarChecks/RefreshCoordinatorChecks.swift b/Tests/CapBarChecks/RefreshCoordinatorChecks.swift index 4bb9f66..0bc3f5c 100644 --- a/Tests/CapBarChecks/RefreshCoordinatorChecks.swift +++ b/Tests/CapBarChecks/RefreshCoordinatorChecks.swift @@ -45,7 +45,10 @@ private actor CancellableProvider: UsageProvider { let now = Date(timeIntervalSince1970: 1_800_000_000) let old = UsageSnapshot(identity: AccountIdentity(email: "a@example.com", plan: "Team", organization: nil), windows: [], capturedAt: now.addingTimeInterval(-600)) let provider = HoldingProvider() - let settings = UserSettings(accounts: [a, b], defaultsSeeded: true, autoRefreshOnOpen: false, refreshThresholdMinutes: 5) + let settings = UserSettings( + accounts: [a, b], defaultsSeeded: true, autoRefreshOnOpen: false, refreshThresholdMinutes: 5, + planOverrides: [UsagePlanOverride(account: a, tier: .claudeTeamStandard)] + ) do { try await settingsStore.save(settings) try await snapshotStore.update(AccountRecord(id: a, snapshot: old, lastAttemptAt: now.addingTimeInterval(-180), lastError: nil)) @@ -153,6 +156,8 @@ private actor CancellableProvider: UsageProvider { } let savedSamples = try await historyStore.samples(account: a, from: now.addingTimeInterval(-1), through: now.addingTimeInterval(1)) check(savedSamples.count == 1 && savedSamples.first?.usedPercent == 33, "successful refresh appends weekly history") + check(savedSamples.first?.planTier == .claudeTeamStandard, + "successful refresh records the calibrated tier alongside the historical sample") check((await historicalCoordinator.state())[a]?.snapshot?.capturedAt == now, "same refresh replaces latest JSON snapshot") check(await historicalCoordinator.requestRefresh(b, recordHistory: false), "statistics-disabled manual refresh still starts") for _ in 0..<100 { diff --git a/Tests/CapBarChecks/Runner.swift b/Tests/CapBarChecks/Runner.swift index c713f20..282397d 100644 --- a/Tests/CapBarChecks/Runner.swift +++ b/Tests/CapBarChecks/Runner.swift @@ -40,6 +40,8 @@ import Foundation if filter == nil || filter == "StorageTests" { await runStorageChecks() } if filter == nil || filter == "UsageHistoryTests" { await runUsageHistoryChecks() } if filter == nil || filter == "UsageTrendTests" { runUsageTrendChecks() } + if filter == nil || filter == "ProviderTrendStatisticsTests" { runProviderTrendStatisticsChecks() } + if filter == nil || filter == "HistoricalTrendStatisticsTests" { runHistoricalTrendStatisticsChecks() } if filter == nil || filter == "UsageSamplingTests" { await runUsageSamplingChecks() } if filter == nil || filter == "UsageTrendChartTests" { runUsageTrendChartChecks() } if filter == nil || filter == "TimeLabelTests" { runTimeLabelChecks() } diff --git a/Tests/CapBarChecks/StorageChecks.swift b/Tests/CapBarChecks/StorageChecks.swift index c4dde49..fb59c60 100644 --- a/Tests/CapBarChecks/StorageChecks.swift +++ b/Tests/CapBarChecks/StorageChecks.swift @@ -19,6 +19,9 @@ import Foundation let initialValues = initialFile?["settings"] as? [String: Any] check(initialValues?["usageStatisticsEnabled"] as? Bool == false, "usage statistics default off in saved settings") check(initialValues?["samplingIntervalHours"] as? Int == 4, "sampling interval defaults to four hours") + check(settings.planOverrides.isEmpty, "plan calibration starts empty rather than guessing ambiguous subscriptions") + let calibratedAccount = AccountID(provider: .claude, directory: "~/.claude-example") + settings.planOverrides = [UsagePlanOverride(account: calibratedAccount, tier: .claudeTeamPremium)] settings.popoverSize = PopoverSize(width: 720, height: 780) settings.accounts.removeAll { $0.provider == .claude } try await firstSettingsStore.save(settings) @@ -26,6 +29,9 @@ import Foundation let restored = try await newStore.loadOrSeed() check(!restored.accounts.contains { $0.provider == .claude }, "removed default directory stays removed after restart") check(restored.popoverSize.width == 720 && restored.popoverSize.height == 780, "custom popover size survives restart") + check(restored.planOverrides.first?.account == calibratedAccount + && restored.planOverrides.first?.tier == .claudeTeamPremium, + "per-account subscription calibration survives settings restart") check(PopoverSize(width: 100, height: 200).width == 448 && PopoverSize(width: 100, height: 200).height == 620, "popover size clamps both minimums") let legacy = #"{"schemaVersion":1,"settings":{"accounts":[],"defaultsSeeded":true,"autoRefreshOnOpen":false,"refreshThresholdMinutes":5}}"# try Data(legacy.utf8).write(to: settingsURL, options: .atomic) @@ -36,6 +42,7 @@ import Foundation let migratedValues = migratedFile?["settings"] as? [String: Any] check(migratedValues?["usageStatisticsEnabled"] as? Bool == false, "legacy settings migrate with statistics off") check(migratedValues?["samplingIntervalHours"] as? Int == 4, "legacy settings migrate with four-hour interval") + check(migrated.planOverrides.isEmpty, "legacy settings gain an empty plan calibration list") let mode = try FileManager.default.attributesOfItem(atPath: settingsURL.path)[.posixPermissions] as? NSNumber check(mode?.intValue == 0o600, "settings file is private to current user") diff --git a/Tests/CapBarChecks/UsageHistoryChecks.swift b/Tests/CapBarChecks/UsageHistoryChecks.swift index b01454e..831bb65 100644 --- a/Tests/CapBarChecks/UsageHistoryChecks.swift +++ b/Tests/CapBarChecks/UsageHistoryChecks.swift @@ -1,4 +1,5 @@ import Foundation +import SQLite3 @testable import CapBarCore @MainActor func runUsageHistoryChecks() async { @@ -18,7 +19,8 @@ import Foundation windows: [try QuotaWindow(kind: .sevenDay, remainingPercent: 70, resetsAt: reset)], capturedAt: start ) - check(try await store.append(account: first, snapshot: firstSnapshot), "seven-day quota is recorded") + check(try await store.append(account: first, snapshot: firstSnapshot, planTier: .claudeTeamPremium), + "seven-day quota and detected tier are recorded together") check(try await store.append(account: first, snapshot: firstSnapshot), "same-time write is idempotent") let revised = UsageSnapshot( identity: identity, @@ -39,6 +41,8 @@ import Foundation check(all.count == 1, "same account and timestamp have one historical row") check(all.first?.usedPercent == 32, "history stores seven-day used percentage") check(all.first?.capturedAt == start && all.first?.resetsAt == reset, "UTC capture and reset timestamps round trip") + check(all.first?.planTier == .claudeTeamPremium, + "history retains subscription tier at sampling time for later weighted totals") let otherRows = try await store.samples(account: second, from: start.addingTimeInterval(-1), through: start.addingTimeInterval(60)) check(otherRows.count == 1 && otherRows.first?.usedPercent == 20, "history keeps accounts separate") let tooEarly = try await store.samples(account: first, from: start.addingTimeInterval(-60), through: start.addingTimeInterval(-1)) @@ -51,4 +55,131 @@ import Foundation } catch { check(false, "history store should work: \(error)") } + + do { + let legacyURL = folder.appendingPathComponent("legacy-history.sqlite3") + var database: OpaquePointer? + guard sqlite3_open(legacyURL.path, &database) == SQLITE_OK, let database else { + check(false, "legacy database fixture opens") + return + } + let legacySchema = """ + CREATE TABLE usage_samples ( + provider TEXT NOT NULL, directory TEXT NOT NULL, captured_at REAL NOT NULL, + used_percent REAL NOT NULL, resets_at REAL, + PRIMARY KEY (provider, directory, captured_at) + ) WITHOUT ROWID; + """ + check(sqlite3_exec(database, legacySchema, nil, nil, nil) == SQLITE_OK, + "legacy history fixture creates its original schema") + var insert: OpaquePointer? + if sqlite3_prepare_v2(database, "INSERT INTO usage_samples VALUES (?, ?, ?, ?, NULL)", -1, &insert, nil) == SQLITE_OK, + let insert { + let inserted = "claude".withCString { provider in + first.directory.withCString { directory in + sqlite3_bind_text(insert, 1, provider, -1, nil) + sqlite3_bind_text(insert, 2, directory, -1, nil) + sqlite3_bind_double(insert, 3, start.addingTimeInterval(-60).timeIntervalSince1970) + sqlite3_bind_double(insert, 4, 13) + return sqlite3_step(insert) == SQLITE_DONE + } + } + check(inserted, "legacy history fixture has an existing sample") + sqlite3_finalize(insert) + } else { + check(false, "legacy history insert prepares") + } + sqlite3_close(database) + let legacyStore = UsageHistoryStore(url: legacyURL) + let legacySnapshot = UsageSnapshot( + identity: identity, + windows: [try QuotaWindow(kind: .sevenDay, remainingPercent: 45, resetsAt: reset)], + capturedAt: start + ) + check(try await legacyStore.append(account: first, snapshot: legacySnapshot, planTier: .claudeTeamStandard), + "existing database adds a plan tier column without reset") + let migrated = try await legacyStore.samples(account: first, from: start.addingTimeInterval(-61), through: start.addingTimeInterval(1)) + check(migrated.count == 2 && migrated[0].usedPercent == 13 && migrated[0].planTier == nil + && migrated[1].usedPercent == 55 && migrated[1].planTier == .claudeTeamStandard, + "migration preserves existing samples and records new tier values") + } catch { + check(false, "legacy history migration should work: \(error)") + } + + do { + let cacheURL = folder.appendingPathComponent("historical-cache.sqlite3") + let cacheStore = UsageHistoryStore(url: cacheURL) + let account = AccountID(provider: .claude, directory: folder.appendingPathComponent("history-account").path) + let calibration = [HistoricalAccount(account: account, tier: .claudePro)] + let base = ISO8601DateFormatter().date(from: "2026-09-01T00:00:00Z")! + let farReset = base.addingTimeInterval(30 * 86_400) + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(secondsFromGMT: 0)! + func snapshot(_ index: Int, used: Double) throws -> UsageSnapshot { + UsageSnapshot(identity: identity, + windows: [try QuotaWindow(kind: .sevenDay, remainingPercent: 100 - used, + resetsAt: farReset)], + capturedAt: base.addingTimeInterval(Double(index) * 8 * 3_600)) + } + for index in 0...21 { + _ = try await cacheStore.append(account: account, snapshot: snapshot(index, used: Double(index * 2)), + planTier: .claudePro) + } + let first = try await cacheStore.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(first?.fromCache == false && first?.statistics.highSevenDays?.usedPercent == 42, + "first all-time lookup calculates and stores seven complete days") + let second = try await cacheStore.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(second?.fromCache == true && second?.statistics.highSevenDays?.usedPercent == 42, + "unchanged history reuses persisted SQLite statistics") + var cacheDatabase: OpaquePointer? + if sqlite3_open(cacheURL.path, &cacheDatabase) == SQLITE_OK, let cacheDatabase { + let downgradeSignature = #"UPDATE history_statistics_cache SET signature = REPLACE(signature, '"version":2', '"version":1')"# + check(sqlite3_exec(cacheDatabase, downgradeSignature, nil, nil, nil) == SQLITE_OK, + "legacy summary signature fixture updates") + sqlite3_close(cacheDatabase) + let rebuilt = try await cacheStore.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(rebuilt?.fromCache == false && rebuilt?.statistics.highSevenDays?.usedPercent == 42, + "an older calculation version invalidates cached historical records") + } else { + if let cacheDatabase { sqlite3_close(cacheDatabase) } + check(false, "historical cache fixture opens for version migration") + } + _ = try await cacheStore.append(account: account, snapshot: snapshot(21, used: 42), planTier: .claudePro) + let unchanged = try await cacheStore.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(unchanged?.fromCache == true, "identical upsert does not invalidate the summary") + _ = try await cacheStore.append(account: account, snapshot: snapshot(21, used: 43), planTier: .claudePro) + let corrected = try await cacheStore.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(corrected?.fromCache == false && corrected?.statistics.highSevenDays?.usedPercent == 43, + "same-timestamp correction invalidates and rebuilds persisted records") + let reopened = UsageHistoryStore(url: cacheURL) + let restored = try await reopened.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(restored?.fromCache == true && restored?.statistics.highSevenDays?.usedPercent == 43, + "summary cache survives a store restart") + _ = try await cacheStore.append(account: account, snapshot: snapshot(21, used: 44), planTier: .claudePro) + let externalChange = try await reopened.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: utc) + check(externalChange?.fromCache == false && externalChange?.statistics.highSevenDays?.usedPercent == 44, + "another store instance's write invalidates the persisted cache") + let anotherTier = try await reopened.historicalStatistics( + accounts: [HistoricalAccount(account: account, tier: .claudeMax5)], intervalHours: 8, calendar: utc) + check(anotherTier?.fromCache == false, "subscription calibration changes invalidate the cache") + let anotherInterval = try await reopened.historicalStatistics(accounts: calibration, intervalHours: 4, calendar: utc) + check(anotherInterval?.fromCache == false, "sampling interval changes invalidate the cache") + var local = utc + local.timeZone = TimeZone(identifier: "Asia/Shanghai")! + let anotherZone = try await reopened.historicalStatistics(accounts: calibration, intervalHours: 8, calendar: local) + check(anotherZone?.fromCache == false, "local calendar time zone changes invalidate day boundaries") + let secondAccount = AccountID(provider: .claude, directory: folder.appendingPathComponent("history-max-account").path) + for index in 0...21 { + _ = try await reopened.append(account: secondAccount, snapshot: snapshot(index, used: Double(index)), + planTier: .claudeMax5) + } + let combined = try await reopened.historicalStatistics( + accounts: calibration + [HistoricalAccount(account: secondAccount, tier: .claudeMax5)], + intervalHours: 8, calendar: utc + ) + check(combined?.fromCache == false && combined?.statistics.highSevenDays?.usedPercent == 149, + "all-time provider records convert historical Max usage into the first Pro account's capacity") + } catch { + check(false, "historical cache checks should complete: \(error)") + } } diff --git a/Tests/CapBarChecks/UsageTrendChartChecks.swift b/Tests/CapBarChecks/UsageTrendChartChecks.swift index 4782b4e..ee72fe3 100644 --- a/Tests/CapBarChecks/UsageTrendChartChecks.swift +++ b/Tests/CapBarChecks/UsageTrendChartChecks.swift @@ -10,10 +10,35 @@ import Foundation check(labels.map(\.text) == ["21", "22", "23", "24", "25", "26", "27", "28"], "chart labels every local calendar day in rolling week") check(UsageTrendChartText.intervalUsageLabel(binHours: series.binHours) == "这 4 小时用量", "tooltip describes the selected chart interval") + check(UsageTrendChartText.intervalUsageLabel(binHours: 4, isTotal: true) == "这 4 小时合计用量", + "total tooltip distinguishes weighted aggregate usage from one account") + check(TrendStatisticsText.changeLabel(-0.04) == "0%" + && TrendStatisticsText.changeLabel(0.49) == "0%" + && TrendStatisticsText.changeLabel(68.5) == "+69%" + && TrendStatisticsText.changeLabel(-12.6) == "-13%" + && TrendStatisticsText.changeLabel(nil) == "—", + "small relative changes never render negative zero and signs follow rounded values") check(UsageTrendChartLayout.nearestPointIndex(at: 26, width: 416, count: 84) == 0, "hover at left edge picks oldest point") check(UsageTrendChartLayout.nearestPointIndex(at: 408, width: 416, count: 84) == 83, "hover at right edge picks latest point") check(abs(UsageTrendChartLayout.yPosition(value: 0, axisMaximum: 60, height: 84) - 63) < 0.01, "zero usage is at graph baseline") check(abs(UsageTrendChartLayout.yPosition(value: 60, axisMaximum: 60, height: 84) - 8) < 0.01, "axis maximum is at graph top") + let lowColor = UsageTrendColorScale.turboRGB(at: 0) + let midColor = UsageTrendColorScale.turboRGB(at: 0.5) + let highColor = UsageTrendColorScale.turboRGB(at: 1) + check(lowColor.blue > lowColor.red && highColor.red > highColor.blue && midColor.green > lowColor.green, + "trend palette moves from cool low usage through green to warm high usage") + check(UsageTrendColorScale.turboRGB(at: -1) == lowColor && UsageTrendColorScale.turboRGB(at: 2) == highColor, + "trend palette clamps out-of-range values") + check(TotalTrendBreakdownScale.maximumPercent == 20 + && abs(TotalTrendBreakdownScale.barFraction(3) - 0.15) < 0.001 + && abs(TotalTrendBreakdownScale.barFraction(5) - 0.25) < 0.001, + "each contribution bar uses the fixed 0–20% scale") + check(TotalTrendBreakdownScale.barFraction(0) == 0 + && TotalTrendBreakdownScale.barFraction(20) == 1 + && TotalTrendBreakdownScale.barFraction(27) == 1 + && TotalTrendBreakdownScale.barFraction(-3) == 0 + && TotalTrendBreakdownScale.barFraction(.nan) == 0, + "empty, full and overflow contribution bars stay within their track") let now = ISO8601DateFormatter().date(from: "2026-09-28T04:25:00Z")! let next = ISO8601DateFormatter().date(from: "2026-09-28T08:00:00Z")! @@ -49,4 +74,12 @@ import Foundation )] check(UsageTrendChartLayout.standalonePointIndex(points: twoPoints) == nil, "two valid points use their connecting line, including a zero-usage point") + let withGap = [twoPoints[83], UsageTrendPoint( + endAt: Date(timeIntervalSince1970: 7 * hour), usedPercent: nil, + remainingPercent: nil, crossesReset: false, isEstimated: false + ), twoPoints[84]] + check(UsageTrendChartLayout.lineSegments(points: withGap, connectMissing: false).isEmpty, + "total trend leaves an unobserved interval blank") + check(UsageTrendChartLayout.isolatedPointIndices(points: withGap) == [0, 2], + "observed points beside missing intervals remain visible") } diff --git a/Tests/CapBarChecks/UsageTrendChecks.swift b/Tests/CapBarChecks/UsageTrendChecks.swift index a4a57f6..b5d294b 100644 --- a/Tests/CapBarChecks/UsageTrendChecks.swift +++ b/Tests/CapBarChecks/UsageTrendChecks.swift @@ -75,6 +75,21 @@ import Foundation check(empty.points.allSatisfy { $0.usedPercent == nil }, "no history renders missing points without fabricated usage") check(empty.points.last?.endAt == Date(timeIntervalSince1970: 8 * hour), "incomplete current bin is not drawn") + let awaitingBoundarySample = UsageTrendCalculator.calculate( + samples: [ + sample(2 + 20.0 / 3_600, used: 10, reset: 80), + sample(4 + 20.0 / 3_600, used: 15, reset: 80), + sample(6 + 20.0 / 3_600, used: 20, reset: 80) + ], intervalHours: 2, endingAt: Date(timeIntervalSince1970: 8 * hour + 10) + ) + let awaitingOverview = ProviderTrendOverview.build([ + TrendOverviewAccount(label: "example@example.com", tier: .claudePro, series: awaitingBoundarySample) + ]) + check(awaitingBoundarySample.points.last?.usedPercent == nil + && near(awaitingOverview.latestObservedPoint?.usedPercent, 5) + && awaitingOverview.latestObservedPoint?.endAt == Date(timeIntervalSince1970: 6 * hour), + "the headline falls back to the preceding measured bin while the just-ended bin awaits sampling") + let expectedGrids: [(setting: Int, count: Int, lastHour: Double)] = [ (1, 84, 10), (2, 84, 10), (3, 56, 9), (4, 42, 8), (6, 28, 6), (8, 21, 8) @@ -115,4 +130,195 @@ import Foundation ) check(near(manualBridge.points[54].usedPercent, 30) && near(manualBridge.points[55].usedPercent, 30), "manual samples between three-hour grid points bridge a missed regular sample") + let stableTier = UsageTrendCalculator.calculate(samples: [ + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 0), usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro), + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 2 * hour), usedPercent: 30, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro) + ], intervalHours: 2, endingAt: Date(timeIntervalSince1970: 2 * hour)) + check(stableTier.points.last?.planTier == .claudePro && near(stableTier.points.last?.usedPercent, 20), + "a measured interval carries its historical subscription tier") + let newlyRecordedTierSamples = [ + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 0), usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: nil), + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 2 * hour + 20), usedPercent: 20, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro), + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 4 * hour + 20), usedPercent: 30, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro) + ] + let newlyRecordedTier = UsageTrendCalculator.calculate( + samples: newlyRecordedTierSamples, intervalHours: 2, + endingAt: Date(timeIntervalSince1970: 4 * hour + 30) + ) + check(newlyRecordedTier.points.suffix(2).allSatisfy { $0.usedPercent != nil } + && newlyRecordedTier.points.last?.usedPercent.map { abs($0 - 10) < 0.01 } == true + && newlyRecordedTier.points.last?.planTier == .claudePro, + "a newly populated tier field does not erase intervals on either side of its sample") + let newlyRecordedHistory = UsageTrendCalculator.calculateHistory( + samples: newlyRecordedTierSamples, intervalHours: 2, + endingAt: Date(timeIntervalSince1970: 4 * hour + 30) + ) + check(newlyRecordedHistory.points.count == 2 + && newlyRecordedHistory.points.allSatisfy { $0.usedPercent != nil } + && newlyRecordedHistory.points.last?.usedPercent.map { abs($0 - 10) < 0.01 } == true + && newlyRecordedHistory.points.last?.planTier == .claudePro, + "all-time history also bridges missing tier metadata without inventing a plan change") + let changedTier = UsageTrendCalculator.calculate(samples: [ + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 0), usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro), + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 2 * hour), usedPercent: 30, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudeMax5) + ], intervalHours: 2, endingAt: Date(timeIntervalSince1970: 2 * hour)) + check(changedTier.points.last?.usedPercent == nil, + "an interval crossing a subscription change is left blank instead of interpolated") + let changedTierHistory = UsageTrendCalculator.calculateHistory( + samples: [ + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 0), usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudePro), + UsageHistorySample(capturedAt: Date(timeIntervalSince1970: 2 * hour), usedPercent: 30, + resetsAt: Date(timeIntervalSince1970: 80 * hour), planTier: .claudeMax5) + ], intervalHours: 2, endingAt: Date(timeIntervalSince1970: 2 * hour) + ) + check(changedTierHistory.points.last?.usedPercent == nil, + "all-time history still leaves a confirmed subscription change blank") + + let historical = UsageTrendCalculator.calculateHistory( + samples: twoHourSamples, intervalHours: 2, endingAt: Date(timeIntervalSince1970: 8 * hour) + ) + check(historical.points.count == 4 + && zip(historical.points, series.points.suffix(4)).allSatisfy { + $0.0.endAt == $0.1.endAt && $0.0.usedPercent == $0.1.usedPercent + && $0.0.crossesReset == $0.1.crossesReset + }, "all-time bin calculation matches the existing seven-day reset and interpolation rules") + let historicalGap = UsageTrendCalculator.calculateHistory( + samples: [sample(0, used: 30, reset: 80), sample(8, used: 50, reset: 80)], + intervalHours: 4, endingAt: Date(timeIntervalSince1970: 8 * hour) + ) + check(historicalGap.points.count == 2 && historicalGap.points.allSatisfy { $0.usedPercent == nil }, + "all-time calculation preserves missing intervals rather than filling them with zero") + let yearSamples = (0...(365 * 6)).map { sample(Double($0 * 4), used: 0, reset: 20_000) } + let year = UsageTrendCalculator.calculateHistory( + samples: yearSamples, intervalHours: 4, + endingAt: Date(timeIntervalSince1970: 365 * 24 * hour) + ) + check(year.points.count == 365 * 6 && year.points.allSatisfy { $0.usedPercent == 0 }, + "all-time calculation handles a year of indexed samples in one pass") + + func trend(_ values: [Double?], hours: [Double] = [2, 4, 6]) -> UsageTrendSeries { + UsageTrendSeries( + points: zip(hours, values).map { end, value in + UsageTrendPoint(endAt: Date(timeIntervalSince1970: end * hour), usedPercent: value, + remainingPercent: nil, crossesReset: false, isEstimated: false) + }, + binHours: 2, axisMaximum: 10, axisTicks: [0, 5, 10], sampleCount: 3 + ) + } + let pro = trend([2, 3, 4]) + let maxFive = trend([1, nil, 2]) + let summed = UsageTrendAggregator.aggregate( + [WeightedUsageTrend(series: pro, capacity: 1), WeightedUsageTrend(series: maxFive, capacity: 5)], + baselineCapacity: 1 + ) + check(near(summed?.points[0].usedPercent, 7) && summed?.points[1].usedPercent == nil + && near(summed?.points[2].usedPercent, 14), + "total converts plan capacities before summing and leaves incomplete bins blank") + check(summed?.axisMaximum == 20 && summed?.axisTicks == [0, 10, 20], + "total axis expands to the weighted observed peak") + let maxBased = UsageTrendAggregator.aggregate( + [WeightedUsageTrend(series: maxFive, capacity: 5), WeightedUsageTrend(series: pro, capacity: 1)], + baselineCapacity: 5 + ) + check(near(maxBased?.points[0].usedPercent, 1.4), + "first account capacity changes the total display unit") + let upgraded = UsageTrendSeries(points: maxFive.points.enumerated().map { index, point in + UsageTrendPoint(endAt: point.endAt, usedPercent: point.usedPercent, remainingPercent: nil, + crossesReset: false, isEstimated: false, + planTier: index == 2 ? .claudeMax20 : .claudeMax5) + }, binHours: 2, axisMaximum: 10, axisTicks: [0, 5, 10], sampleCount: 3) + let upgradedTotal = UsageTrendAggregator.aggregate( + [WeightedUsageTrend(series: pro, capacity: 1), WeightedUsageTrend(series: upgraded, capacity: 5)], + baselineCapacity: 1 + ) + check(near(upgradedTotal?.points[0].usedPercent, 7) + && near(upgradedTotal?.points[2].usedPercent, 44), + "historical tier weights a past bin with its captured capacity, not today's tier") + let upgradedBreakdown = ProviderTrendOverview.build([ + TrendOverviewAccount(label: "Pro", tier: .claudePro, series: pro), + TrendOverviewAccount(label: "Upgraded", tier: .claudeMax5, series: upgraded) + ]).contributions(at: pro.points[2].endAt) + check(near(upgradedBreakdown?[1].equivalentPercent, 40), + "tooltip contribution uses the historical tier behind the plotted total") + let mismatched = trend([1, 2, 3], hours: [2, 5, 6]) + let mismatchedTotal = UsageTrendAggregator.aggregate( + [WeightedUsageTrend(series: pro, capacity: 1), WeightedUsageTrend(series: mismatched, capacity: 1)], + baselineCapacity: 1 + ) + check(mismatchedTotal?.points[1].usedPercent == nil, + "samples with different UTC interval ends cannot be combined by array position") + check(UsageTrendAggregator.aggregate([WeightedUsageTrend(series: pro, capacity: 0)], baselineCapacity: 1) == nil, + "unknown or invalid capacity does not create a misleading total") + + let sharedClaudeRange = UsageTrendColorScale.range(for: [pro, maxFive]) + let codexRange = UsageTrendColorScale.range(for: [trend([10, 20, 30])]) + check(sharedClaudeRange?.lowerBound == 1 && sharedClaudeRange?.upperBound == 4, + "all Claude account charts use one observed color range") + check(codexRange?.lowerBound == 10 && codexRange?.upperBound == 30, + "Codex account colors are independent from Claude") + check(UsageTrendColorScale.range(for: [summed!])?.lowerBound == 7 + && UsageTrendColorScale.range(for: [summed!])?.upperBound == 14, + "a total chart maps its own observed minimum and maximum") + check(UsageTrendColorScale.fraction(1, in: sharedClaudeRange) == 0 + && UsageTrendColorScale.fraction(4, in: sharedClaudeRange) == 1, + "observed bounds reach both ends of the palette instead of using zero and axis maximum") + check(UsageTrendColorScale.fraction(3, in: 3...3) == 0.5 + && UsageTrendColorScale.range(for: [trend([nil, nil, nil])]) == nil, + "flat or missing history gets a neutral color without a divide-by-zero") + + check(UsagePlanTier.detect(provider: .claude, rawPlan: "Pro") == .claudePro + && UsagePlanTier.detect(provider: .claude, rawPlan: "Max 20x") == .claudeMax20, + "explicit Claude plan tiers can be recognized without credentials") + check(UsagePlanTier.detect(provider: .codex, rawPlan: "Plus") == .codexPlus + && UsagePlanTier.detect(provider: .codex, rawPlan: "Pro 5x") == .codexPro5, + "explicit Codex plan tiers map only within Codex") + check(UsagePlanTier.detect(provider: .claude, rawPlan: "team") == nil + && UsagePlanTier.detect(provider: .claude, rawPlan: "max") == nil + && UsagePlanTier.detect(provider: .codex, rawPlan: "pro") == nil, + "ambiguous plan names never silently select a subscription multiplier") + check(UsagePlanTier.claudePro.capacityFactor == 1 + && UsagePlanTier.claudeMax5.capacityFactor == 5 + && UsagePlanTier.claudeMax20.capacityFactor == 20 + && UsagePlanTier.claudeTeamStandard.capacityFactor == 1.25 + && UsagePlanTier.claudeTeamPremium.capacityFactor == 6.25 + && UsagePlanTier.codexPlus.capacityFactor == 1 + && UsagePlanTier.codexPro5.capacityFactor == 5 + && UsagePlanTier.codexPro20.capacityFactor == 20, + "candidate proposal factors convert each tier to provider base capacity") + + let overview = ProviderTrendOverview.build([ + TrendOverviewAccount(label: "Pro account", tier: .claudePro, series: pro), + TrendOverviewAccount(label: "Max account", tier: .claudeMax5, series: maxFive) + ]) + check(overview.accountCount == 2 && overview.baselinePlan == .claudePro + && near(overview.series?.points[0].usedPercent, 7), + "provider overview uses the first account plan as its display unit") + let breakdown = overview.contributions(at: pro.points[0].endAt) + check(breakdown?.map(\.label) == ["Pro account", "Max account"] + && near(breakdown?[0].equivalentPercent, 2) + && near(breakdown?[1].equivalentPercent, 5) + && near(breakdown?.reduce(0) { $0 + $1.equivalentPercent }, 7), + "total tooltip breaks down each account in the same baseline units as its plotted total") + check(overview.contributions(at: pro.points[1].endAt) == nil, + "total tooltip does not invent account values for an incomplete interval") + let unknownOverview = ProviderTrendOverview.build([ + TrendOverviewAccount(tier: .claudePro, series: pro), + TrendOverviewAccount(tier: nil, series: maxFive) + ]) + check(unknownOverview.uncalibratedCount == 1 && unknownOverview.series == nil, + "one unknown subscription blocks a misleading partial total") + let pendingOverview = ProviderTrendOverview.build([ + TrendOverviewAccount(tier: .claudePro, series: pro), + TrendOverviewAccount(tier: .claudeMax5, series: nil) + ]) + check(pendingOverview.pendingHistoryCount == 1 && pendingOverview.series == nil, + "a missing account trend waits for history instead of treating it as zero") } diff --git a/Tests/CapBarChecks/ViewModelChecks.swift b/Tests/CapBarChecks/ViewModelChecks.swift index 1885da6..8b9b567 100644 --- a/Tests/CapBarChecks/ViewModelChecks.swift +++ b/Tests/CapBarChecks/ViewModelChecks.swift @@ -113,6 +113,25 @@ private actor GatedTrendLoader { check(model.settings.usageStatisticsEnabled && model.settings.samplingScheduleStartedAt == enabledAt, "enabling statistics records first-schedule time") model.setTrendMode(true) check(model.showsTrend, "trend mode is available while statistics are enabled") + check(model.trendScope == .individual, "opening the trend view defaults to individual accounts") + model.setTrendScope(.individual) + check(model.trendScope == .individual, "trend scope switches all providers to individual accounts") + model.setTrendMode(false) + model.setTrendMode(true) + check(model.trendScope == .individual, "temporarily viewing quotas keeps the selected trend scope") + let calibrationAccount = model.settings.accounts.first { $0.provider == .claude }! + model.setPlanOverride(.claudeTeamStandard, for: calibrationAccount) + check(model.settings.planOverrides.first?.account == calibrationAccount + && model.planTier(for: calibrationAccount) == .claudeTeamStandard, + "manual calibration is associated with the configured account") + check(samplingChanges.last?.planOverrides.first?.tier == .claudeTeamStandard, + "changing a plan tier updates the running sampling controller's settings") + model.setPlanOverride(.codexPlus, for: calibrationAccount) + check(model.planTier(for: calibrationAccount) == .claudeTeamStandard, + "a Codex tier cannot be assigned to a Claude account") + model.setPlanOverride(nil, for: calibrationAccount) + check(model.settings.planOverrides.isEmpty, + "clearing a calibration removes the per-account override") model.setSamplingInterval(6, now: enabledAt.addingTimeInterval(60)) check(model.settings.samplingIntervalHours == 6, "six-hour sampling choice applies") check(model.settings.samplingScheduleStartedAt == enabledAt.addingTimeInterval(60), "interval change realigns first UTC sample") @@ -120,7 +139,7 @@ private actor GatedTrendLoader { check(model.settings.samplingIntervalHours == 6, "unsupported stepper value is ignored") model.setUsageStatisticsEnabled(false, now: enabledAt.addingTimeInterval(180)) check(!model.showsTrend && !model.settings.usageStatisticsEnabled, "disabling statistics returns to quota view") - check(samplingChanges.count == 3, "only accepted settings changes notify scheduler") + check(samplingChanges.count == 5, "only accepted statistics and calibration changes notify scheduler") await model.flushSettings() let usageSaved = try await SettingsStore(url: root.appendingPathComponent("settings.json")).loadOrSeed() check(!usageSaved.usageStatisticsEnabled && usageSaved.samplingIntervalHours == 6, "statistics choice and interval persist") @@ -159,6 +178,14 @@ private actor GatedTrendLoader { let due = await sampler.nextDue(for: account) check(trendModel.trends[account]?.nextSampleAt == due, "empty-state hint uses the account's actual scheduled sampling time") + trendModel.setPlanOverride(.claudePro, for: account) + trendModel.setTrendScope(.total) + await trendModel.reloadTrends(endingAt: chartEnd) + for _ in 0..<100 where trendModel.historicalStatistics[.claude] == nil { + try? await Task.sleep(for: .milliseconds(10)) + } + check(trendModel.historicalStatistics[.claude]?.highInterval?.usedPercent == 10, + "total trend loads all-time records from SQLite for a calibrated provider") let gated = GatedTrendLoader(samples: [ UsageHistorySample(capturedAt: initial.capturedAt, usedPercent: 30, resetsAt: chartEnd.addingTimeInterval(86_400)), diff --git a/Tests/CapBarChecks/VisualRenderChecks.swift b/Tests/CapBarChecks/VisualRenderChecks.swift index 05647dc..b428528 100644 --- a/Tests/CapBarChecks/VisualRenderChecks.swift +++ b/Tests/CapBarChecks/VisualRenderChecks.swift @@ -29,7 +29,7 @@ private actor PreviewHangingProvider: UsageProvider { ("alex@example.com", "Team", "Example Studio", 0, 99, 2), ("sam@example.com", "Team", "Example Studio", 33, 0, 7), ("codex@example.com", "Plus", nil, 68, 42, 64), - ("work@example.com", "Team", "Example Org", 13, nil, 180), + ("work@example.com", "Pro 20x", nil, 13, 55, 180), ] var snapshots: [UsageSnapshot] = [] for (account, sample) in zip(accounts, samples) { @@ -78,31 +78,36 @@ private actor PreviewHangingProvider: UsageProvider { let currentUsed = 100 - weekly.remainingPercent let hasReset = account == accounts[1] let resetAt = Date(timeIntervalSince1970: gridEnd - 60 * 3_600) - for index in 0...84 { - if account == accounts[2] && (27...32).contains(index) { continue } + for index in 0...120 { + let chartIndex = index - 36 + if account == accounts[2] && (27...32).contains(chartIndex) { continue } let used: Double let nextReset: Date - if hasReset && index < 54 { - used = weightedUsed(index, count: 53, start: 12, end: 90, spike: 37) + if hasReset && chartIndex < 54 { + used = weightedUsed(max(0, chartIndex), count: 53, start: 12, end: 90, spike: 37) nextReset = resetAt } else if hasReset { - used = weightedUsed(index - 54, count: 30, start: 2, end: currentUsed, spike: 22) + used = weightedUsed(chartIndex - 54, count: 30, start: 2, end: currentUsed, spike: 22) nextReset = resetAt.addingTimeInterval(7 * 86_400) } else { - used = weightedUsed(index, count: 84, start: 0, end: currentUsed, spike: 38) + used = weightedUsed(max(0, chartIndex), count: 84, start: 0, end: currentUsed, spike: 38) nextReset = weekly.resetsAt ?? now.addingTimeInterval(4 * 86_400) } let historySnapshot = UsageSnapshot( identity: snapshot.identity, windows: [try QuotaWindow(kind: .sevenDay, remainingPercent: 100 - used, resetsAt: nextReset)], - capturedAt: Date(timeIntervalSince1970: gridEnd - Double(84 - index) * 2 * 3_600) + capturedAt: Date(timeIntervalSince1970: gridEnd - Double(120 - index) * 2 * 3_600) ) _ = try await historyStore.append(account: account, snapshot: historySnapshot) } } let trendSettings = UserSettings( accounts: accounts, defaultsSeeded: true, autoRefreshOnOpen: false, - refreshThresholdMinutes: 5, usageStatisticsEnabled: true + refreshThresholdMinutes: 5, usageStatisticsEnabled: true, + planOverrides: [ + UsagePlanOverride(account: accounts[0], tier: .claudeTeamStandard), + UsagePlanOverride(account: accounts[1], tier: .claudeTeamPremium) + ] ) let trendSnapshotStore = SnapshotStore(url: folder.appendingPathComponent("trend-snapshots.json")) for (index, account) in accounts.enumerated() { @@ -136,8 +141,51 @@ private actor PreviewHangingProvider: UsageProvider { } trendModel.setTrendMode(true, reload: false) await trendModel.reloadTrends() + trendModel.setTrendScope(.total) + for _ in 0..<100 where trendModel.historicalStatistics[.claude] == nil { + try await Task.sleep(for: .milliseconds(10)) + } try render(model: trendModel, appearance: .aqua, to: URL(fileURLWithPath: "/tmp/capbar-preview-trend-light.png")) try render(model: trendModel, appearance: .darkAqua, to: URL(fileURLWithPath: "/tmp/capbar-preview-trend-dark.png")) + let originalTrendSize = trendModel.settings.popoverSize + trendModel.setPopoverSize(width: 462, height: 862) + try render(model: trendModel, appearance: .aqua, + to: URL(fileURLWithPath: "/tmp/capbar-preview-trend-expanded-light.png")) + try render(model: trendModel, appearance: .darkAqua, + to: URL(fileURLWithPath: "/tmp/capbar-preview-trend-expanded-dark.png")) + trendModel.setPopoverSize(width: originalTrendSize.width, height: originalTrendSize.height) + trendModel.setTrendScope(.individual) + try render(model: trendModel, appearance: .aqua, to: URL(fileURLWithPath: "/tmp/capbar-preview-trend-individual-light.png")) + let tooltipEnd = Date(timeIntervalSince1970: gridEnd) + let tooltipNames = ["alex-claude.team@example.com", "sam-claude.team@example.com", + "lee-claude.team@example.com", "pat-claude.team@example.com", + "kim-claude.team@example.com", "taylor-claude.team@example.com"] + let tooltipOverview = ProviderTrendOverview.build(zip(tooltipNames, [3.0, 5, 0, 12, 15.04, 20]).map { name, usage in + let points = [0.0, usage / 2, usage].enumerated().map { index, value in + UsageTrendPoint(endAt: tooltipEnd.addingTimeInterval(Double(index - 2) * 2 * 3_600), + usedPercent: value, remainingPercent: nil, + crossesReset: false, isEstimated: false) + } + let series = UsageTrendSeries(points: points, binHours: 2, axisMaximum: 20, + axisTicks: [0, 10, 20], sampleCount: 2) + return TrendOverviewAccount(label: name, tier: .claudeTeamPremium, series: series) + }) + if let tooltipSeries = tooltipOverview.series, let point = tooltipSeries.points.last { + for (name, appearance) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] { + try render(view: VStack(alignment: .leading, spacing: 7) { + UsageTrendChart(series: tooltipSeries, totalOverview: tooltipOverview) + TotalTrendTooltip(point: point, series: tooltipSeries, + overview: tooltipOverview, calendar: .current) + .padding(.leading, UsageTrendChartLayout.leading) + .padding(.trailing, UsageTrendChartLayout.trailing) + } + .padding(16) + .frame(width: 448, height: 480, alignment: .top) + .background(Color(nsColor: .underPageBackgroundColor)), + size: CGSize(width: 448, height: 480), appearance: appearance, + to: URL(fileURLWithPath: "/tmp/capbar-preview-total-tooltip-\(name).png")) + } + } trendModel.setTrendMode(false) try render(model: trendModel, appearance: .darkAqua, to: URL(fileURLWithPath: "/tmp/capbar-preview-quota-expanded.png")) trendModel.showsSettings = true @@ -210,13 +258,20 @@ private actor PreviewHangingProvider: UsageProvider { } try render(model: stateModel, appearance: .aqua, to: URL(fileURLWithPath: "/tmp/capbar-preview-states.png")) await stateCoordinator.cancelAll() - print("Rendered /tmp/capbar-preview-{light,dark,settings,wide,settings-wide,states,trend-light,trend-dark,trend-empty,trend-single,quota-expanded,usage-settings}.png") + print("Rendered /tmp/capbar-preview-{light,dark,settings,wide,settings-wide,states,trend-light,trend-dark,trend-expanded-light,trend-expanded-dark,trend-empty,trend-single,quota-expanded,usage-settings}.png") } @MainActor private func render(model: CapBarViewModel, appearance: NSAppearance.Name, to url: URL) throws { - let view = NSHostingView(rootView: CapBarPopoverView(model: model)) + try render(view: CapBarPopoverView(model: model), + size: CGSize(width: model.settings.popoverSize.width, height: model.settings.popoverSize.height), + appearance: appearance, to: url) +} + +@MainActor private func render(view rootView: V, size: CGSize, + appearance: NSAppearance.Name, to url: URL) throws { + let view = NSHostingView(rootView: rootView) view.appearance = NSAppearance(named: appearance) - view.frame = NSRect(x: 0, y: 0, width: model.settings.popoverSize.width, height: model.settings.popoverSize.height) + view.frame = NSRect(origin: .zero, size: size) view.layoutSubtreeIfNeeded() guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { throw NSError(domain: "CapBarVisual", code: 2) diff --git a/design/README.md b/design/README.md index 7494b32..6f2fc68 100644 --- a/design/README.md +++ b/design/README.md @@ -4,13 +4,15 @@ 打开 [capbar-visual-study.html](capbar-visual-study.html) 查看可交互的独立设计稿。HTML 内含样式和演示交互,可直接在浏览器中打开;图片保存在 `assets/`。 +新增的 [总走势设计稿](usage-trend-overview-study.html) 展示在“走势”页切换“总走势 / 单账号”:Claude 与 Codex 各自以首账号的七日额度容量为基准,先换算各账号用量,再汇总同一 UTC 绘图区间;缺测区间留空。曲线默认使用 Google Research Turbo 色阶的柔和节选,页面外提供上一版配色对照。总走势中 Claude 与 Codex 各自按本图实际观测的最低、最高用量映射颜色;单账号走势中同一服务的所有账号共用观测范围。纵轴刻度独立计算,原有额度界面不改。识别方法和仍待验证的换算系数见[容量换算提案](usage-trend-capacity-proposal.md)。该页面使用虚构账号、示例容量系数与曲线;功能分支已实现对应的原生界面。 + 已确认的刷新、快照与配置规则记录在 [plan.md](plan.md),可验收的 [v1 规格](spec.md)和 [TDD 实施计划](implementation-plan.md)单独保存。主视觉方向已确认;下方状态对照补充了正常、刷新中、失败保留旧值和首次无快照的界面。 HTML 是最终菜单栏弹窗的视觉基准。实现时应对齐信息层级、尺寸、间距、状态色和交互位置;透明材质、系统字体与控件由 macOS 原生组件呈现,允许随系统版本和浅色/深色外观有细微差异。 菜单栏图标素材为 [capbar-menubar-template.png](assets/capbar-menubar-template.png),运行时按 macOS 模板图像使用。应用图标为 [capbar-app-icon.png](assets/capbar-app-icon.png),透明外角,构建时转换为 `CapBar.icns`。 -CapBar 区域使用 2026 年 9 月 25 日约 16:01 的本机账号与额度快照。**页面是静态设计稿,不连接账号,数值不会自行更新。** 设计稿包含真实账号邮箱,仅适合保存在私有仓库。 +设计稿使用虚构账号与示例额度。**页面是静态设计稿,不连接账号,数值不会自行更新。** 设计约定: diff --git a/design/aggregate-contribution-bar-study.html b/design/aggregate-contribution-bar-study.html new file mode 100644 index 0000000..55c9d08 --- /dev/null +++ b/design/aggregate-contribution-bar-study.html @@ -0,0 +1,99 @@ + + + + + + CapBar · 总走势分摊横条视觉稿 + + + +
+
+

总走势 · 账号列表行内横条

+

同一刻度:0% = 空条,20% = 满格;数值保持实际百分比

+
+
+
+
CapBar · 浅色悬停时在曲线下方展开
+

Claude 用量合计

6 个账号 · 以首账号 Team Premium 为基准

58%
+
+
+
+
+
CapBar · 深色悬停时在曲线下方展开
+

Claude 用量合计

6 个账号 · 以首账号 Team Premium 为基准

58%
+
+
+
+
+
演示值为匿名示例:3% + 5% + 0% + 12% + 18% + 20% = 58%。横条只表示各账号的等效用量,超过 20% 时满格但数值仍显示真实值。
+
+ + + diff --git a/design/aggregate-trend-history-study.html b/design/aggregate-trend-history-study.html new file mode 100644 index 0000000..7b07c01 --- /dev/null +++ b/design/aggregate-trend-history-study.html @@ -0,0 +1,256 @@ + + + + + + CapBar · 历史用量统计界面稿 + + + +
+
+

总走势 · 历史用量统计

四项历史纪录同时呈现用量与时间;示例账号和数值均为虚构数据。

+ +
+
+
+
CCapBar8 个账号 · 用量统计已开启
+
+ +
+
+ +
+ +
+

设计稿展示宽 462 px 的可调整弹窗;历史纪录位于现有 7 日统计之后,内容随弹窗高度滚动。所有数值均为示例,不读取真实账号。

+
+ + + diff --git a/design/aggregate-trend-statistics-study.html b/design/aggregate-trend-statistics-study.html new file mode 100644 index 0000000..3ea9237 --- /dev/null +++ b/design/aggregate-trend-statistics-study.html @@ -0,0 +1,210 @@ + + + + + + CapBar · 总走势统计界面稿 + + + +
+
+

总走势 · 统计界面稿

Claude 与 Codex 分别统计;示例账号和数值均为虚构数据。

+ +
+
+
+
CCapBar8 个账号 · 用量统计已开启
+
+ +
+
+ +
+ +
+

设计稿展示宽 462 px 的可调整弹窗;实际窗口较矮时内容在中间区域滚动。统计数值和曲线由示例数据生成,不读取真实账号。

+
+ + + diff --git a/design/assets/aggregate-trend-history-complete.png b/design/assets/aggregate-trend-history-complete.png new file mode 100644 index 0000000..90557ed Binary files /dev/null and b/design/assets/aggregate-trend-history-complete.png differ diff --git a/design/assets/aggregate-trend-history-dark.png b/design/assets/aggregate-trend-history-dark.png new file mode 100644 index 0000000..d071a22 Binary files /dev/null and b/design/assets/aggregate-trend-history-dark.png differ diff --git a/design/assets/aggregate-trend-history-incomplete.png b/design/assets/aggregate-trend-history-incomplete.png new file mode 100644 index 0000000..995a771 Binary files /dev/null and b/design/assets/aggregate-trend-history-incomplete.png differ diff --git a/design/assets/aggregate-trend-statistics-dark.png b/design/assets/aggregate-trend-statistics-dark.png new file mode 100644 index 0000000..4610588 Binary files /dev/null and b/design/assets/aggregate-trend-statistics-dark.png differ diff --git a/design/assets/aggregate-trend-statistics-light.png b/design/assets/aggregate-trend-statistics-light.png new file mode 100644 index 0000000..44ba19d Binary files /dev/null and b/design/assets/aggregate-trend-statistics-light.png differ diff --git a/design/assets/aggregate-trend-statistics-popover.png b/design/assets/aggregate-trend-statistics-popover.png new file mode 100644 index 0000000..d99f421 Binary files /dev/null and b/design/assets/aggregate-trend-statistics-popover.png differ diff --git a/design/usage-trend-capacity-proposal.md b/design/usage-trend-capacity-proposal.md new file mode 100644 index 0000000..b116930 --- /dev/null +++ b/design/usage-trend-capacity-proposal.md @@ -0,0 +1,46 @@ +# 总走势容量换算提案(候选估算,待验证) + +总走势展示每个绘图区间的**七日额度消耗**。Claude 与 Codex 分别汇总,各自以配置列表中的第一个账号为基准。先算每个账号该区间消耗的七日额度百分比,再换算容量: + +```text +基准等效用量 = Σ(账号区间消耗百分比 × 账号七日容量系数 ÷ 首账号七日容量系数) +``` + +例如 Claude 首账号为 Pro,系数为 1;另一个 Max 20x 账号系数假设为 20。两者各消耗 2% 时,总值为 `2% × 1 + 2% × 20 = 42% Pro 等效`。如果首账号是 Max 20x,同一组消耗相当于 `42% ÷ 20 = 2.1% Max 20x 等效`。纵轴允许超过 100%。不能把不同套餐的原始百分比直接相加。 + +## 识别方法 + +| 服务 | 现有自动识别 | 拟增加的细分识别 | 不能可靠识别时 | +| --- | --- | --- | --- | +| Claude | `claude auth status` 的 `subscriptionType`,现有代码只解析这个字段,通常可区分 Pro / Max / Team | 优先检查该账号的 CLI 身份结果是否直接提供细分档位;对 Max,可检查现有授权凭据中的非令牌元数据 `rateLimitTier` 是否明确包含 `5x` / `20x`。只保存识别出的档位,不保存令牌。 | 让用户确认 Max 5x/20x;不能仅凭 `max` 猜测。 | +| Claude Team | `subscriptionType = team`,组织名只用于显示 | 目前没有验证过能从现有探测结果可靠区分 Standard / Premium 座席。后续若发现稳定字段,再自动读取。 | 让用户确认 Standard / Premium;不能仅凭 `team` 猜测。 | +| Codex | `account/read` 的 `planType`,现有代码已解析 | 若接口明确返回 Plus / Pro 5x / Pro 20x,直接映射。现有代码和测试尚未证明它一定返回 Pro 的细分档位。 | 让用户确认 Pro 5x/20x;不能仅凭 `pro` 猜测。 | + +以上只是**识别方案**,尚未实测每种订阅的实际返回值。不会为了识别而额外启动 Claude 会话。读取 Claude 授权凭据元数据的步骤只有在明确说明后才执行,不把令牌写入历史数据库或设计稿。 + +## 候选容量系数 + +下表以各服务的基础个人套餐为 1。**这是待用户确认的七日总走势换算系数,不是已证实的七日额度比例。** + +| 服务 | 套餐 / 座席 | 候选系数 | 依据与限制 | +| --- | --- | ---: | --- | +| Claude | Pro | 1 | 基准单位 | +| Claude | Max 5x | 5 | 官方说明是 Pro 的 **每会话** 5 倍;七日容量比例未核实 | +| Claude | Max 20x | 20 | 官方说明是 Pro 的 **每会话** 20 倍;七日容量比例未核实 | +| Claude | Team Standard | 1.25 | 官方说明是 Pro 的 **每会话** 1.25 倍;七日容量比例未核实 | +| Claude | Team Premium | 6.25 | 官方说明是 Pro 的 **每会话** 6.25 倍;七日容量比例未核实。相对于 Team Standard 是 5 倍的每会话容量 | +| Codex | Plus | 1 | 基准单位 | +| Codex | Pro 5x | 5 | 官方五小时区间的示例量约为 Plus 的 5 倍;七日容量比例未核实 | +| Codex | Pro 20x | 20 | 官方五小时区间的示例量约为 Plus 的 20 倍;七日容量比例未核实 | + +Anthropic 官方资料:[个人套餐比较](https://support.claude.com/en/articles/11049762-choose-a-claude-plan)、[Team Standard 与 Premium](https://support.claude.com/en/articles/9266767-what-is-the-team-plan)。OpenAI 官方资料:[Work / Codex 用量说明](https://help.openai.com/en/articles/20001516-managing-usage-with-gpt-6-astra-in-work-and-codex)。这些资料分别说明了每会话或五小时容量,也说明七日限制存在,但没有给出可直接用于精确换算的全部七日容量数值。 + +## 拟议的安全显示规则 + +- 同一服务的所有账号必须拥有已确认的七日容量系数,才显示该服务的完整总走势;缺少系数时显示待校准账号数,并保留单账号走势图。 +- 每个区间必须覆盖该服务所有参与汇总的账号;缺测不按 0 处理,曲线在该区间留空。 +- 历史数据记录采样时的套餐和容量系数。升级或降级后,历史消耗仍按当时的容量换算,整个图始终使用**当前首账号容量**作为显示单位。 +- 显示“Pro 等效”“Team Standard 等效”或“Plus 等效”,避免把叠加值误称为某个账号的额度剩余百分比。 +- 如果采用上表未经七日容量验证的候选系数,界面明确标记“估算”,不能把曲线称作精确七日用量。 + +视觉方案见 [usage-trend-overview-study.html](usage-trend-overview-study.html)。功能分支暂按表中的候选系数计算,并在应用中明确标记“估算”;这些数值仍待七日容量验证。无法细分的套餐由用户在设置中选择具体档位,否则该服务的总走势暂不计算。历史采样保存当时识别或校准的档位;旧记录缺少档位时暂按当前校准档位估算。 diff --git a/design/usage-trend-overview-study.html b/design/usage-trend-overview-study.html new file mode 100644 index 0000000..9480816 --- /dev/null +++ b/design/usage-trend-overview-study.html @@ -0,0 +1,285 @@ + + + + + + CapBar · 总走势设计稿 + + + +
+

总走势:Claude 和 Codex 各一张图

+

可交互视觉稿。点击弹窗内“总走势 / 单账号”切换;总走势图先按各服务首账号的七日额度容量换算,再汇总同一时间区间的用量。所有账号、容量系数与曲线均为虚构示例,不读取本机数据。

+
走势色阶
仅用于比较设计,不属于应用界面
+
+
+
CCapBar8 个账号 · 用量统计已开启
+
+
各服务低 → 高近 7 日 · 4 小时 / 点
+
+
额度界面沿用现有设计,本稿仅展示走势图。
+
+ +
+
+
+
先换算,再汇总

各服务分别以首账号为基准:账号区间用量 × 该账号七日额度容量 ÷ 基准账号七日额度容量。示例中 Pro 用 2%、Max 20x 用 2%,若基准是 Pro 且容量比确为 20,合计是 42% Pro 等效,不是 4%。

+
缺测不算零

某区间只要有参与汇总的账号缺测,就留空,不画总值;鼠标悬停可查看覆盖账号数。重置附近仍按现有逐账号计算规则处理,图中不画额外虚线。

+
容量与颜色

图中倍率仅演示换算,不代表官方七日额度比例。走势默认使用 Google Turbo 的柔和节选;总走势按各服务的总图观测范围着色,单账号走势按同一服务全部账号的观测范围着色。纵轴仍单独调整,原有额度界面不改。

+
+
+ + + diff --git a/docs/README-zh.md b/docs/README-zh.md index 7da24bf..11c8249 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -1,7 +1,7 @@ # CapBar [![CI](https://github.com/kikkimo/CapBar/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kikkimo/CapBar/actions/workflows/ci.yml) -[![Version 0.1.4](https://img.shields.io/badge/version-0.1.4-2F80ED?style=flat-square)](../scripts/Info.plist) +[![Version 0.2.0](https://img.shields.io/badge/version-0.2.0-2F80ED?style=flat-square)](../scripts/Info.plist) [![macOS 14+](https://img.shields.io/badge/macOS-14%2B-333333?style=flat-square&logo=apple&logoColor=white)](#安装与使用) [![Swift 6](https://img.shields.io/badge/Swift-6-F05138?style=flat-square&logo=swift&logoColor=white)](../Package.swift) [![Apple Silicon](https://img.shields.io/badge/arch-Apple%20Silicon-0A7F75?style=flat-square)](#从源码构建) @@ -25,20 +25,29 @@ - **按需刷新:** 可以单独刷新账号,也可以刷新全部空闲账号。默认打开弹窗只显示已有快照;可选开启“打开时自动刷新”,按账号判断是否超过共用阈值,默认 5 分钟。 - **状态清楚:** 刷新时显示进度并保留旧额度;失败后继续显示上一次成功快照,同时标记失败。 - **符合 macOS 的交互:** 支持浅色和深色外观、可调整尺寸的弹窗、菜单栏右键菜单,以及可选的登录时启动。 -- **可选用量走势:** 按账号查看滚动 7 日额度消耗。开启统计后,CapBar 运行期间会定时采样;间隔默认 4 小时,可选 1、2、3、4、6、8 小时。手动刷新也会写入采样。走势图按所选间隔分组,最短绘图区间为 2 小时。 +- **可选用量走势:** 可切换 Claude、Codex 各一张总走势图与单账号走势图。总走势先按首账号容量折算各订阅的用量再相加;由于各套餐七日容量比例尚未证实,界面明确标记“估算”。无法细分的套餐须在设置中选择档位。曲线颜色按各服务的实际观测范围自适应。开启统计后,CapBar 运行期间会定时采样;间隔默认 4 小时,可选 1、2、3、4、6、8 小时。手动刷新也会写入采样。走势图按所选间隔分组,最短绘图区间为 2 小时。 +- **用量分析:** 服务总览显示近 7 日已观测用量、最高、最低、平均、数据覆盖、贡献最多的账号、活跃时段与近 24 小时变化。历史记录统计滚动 7 日最高和最低用量、最高单日及最高区间。缺测区间保持空白;卡片顶部显示最近一个完整观测区间。 | 深色用量走势 | 浅色用量走势 | | :---: | :---: | | ![CapBar 深色用量走势](images/trend-dark.png) | ![CapBar 浅色用量走势](images/trend-light.png) | +| 单账号走势 | +| :---: | +| ![CapBar 单账号用量走势](images/trend-individual-light.png) | + +| 深色服务用量分析 | 浅色服务用量分析 | +| :---: | :---: | +| ![CapBar 深色服务用量分析](images/trend-expanded-dark.png) | ![CapBar 浅色服务用量分析](images/trend-expanded-light.png) | + *走势示意图使用虚构账号与采样数据。* ## 安装与使用 -CapBar 0.1.4 需要 **macOS 14 或更高版本**及 **Apple Silicon Mac**。从 [GitHub Releases](https://github.com/kikkimo/CapBar/releases/latest) 下载安装包与校验文件;安装包由 CI 从对应版本源码构建。 +CapBar 0.2.0 需要 **macOS 14 或更高版本**及 **Apple Silicon Mac**。从 [GitHub Releases](https://github.com/kikkimo/CapBar/releases/latest) 下载安装包与校验文件;安装包由 CI 从对应版本源码构建。 -1. 下载 `CapBar-0.1.4.pkg` 和 `SHA256SUMS`,在下载目录运行 `shasum -a 256 -c SHA256SUMS`。 -2. 打开 `CapBar-0.1.4.pkg`;应用会安装到 `/Applications/CapBar.app`。 +1. 下载 `CapBar-0.2.0.pkg` 和 `SHA256SUMS`,在下载目录运行 `shasum -a 256 -c SHA256SUMS`。 +2. 打开 `CapBar-0.2.0.pkg`;应用会安装到 `/Applications/CapBar.app`。 3. 从“应用程序”启动 CapBar。它只显示在菜单栏,不显示 Dock 图标。 4. 点击菜单栏图标查看快照;需要最新额度时,手动点“全部刷新”或某个账号旁的刷新按钮。 @@ -56,7 +65,7 @@ CapBar 0.1.4 需要 **macOS 14 或更高版本**及 **Apple Silicon Mac**。从 弹窗默认尺寸为 448 × 620 pt。宽、高可以输入精确数值后提交,也可以用上下按钮每次调整 10 pt;窗口加宽后,账号信息会横向排布。设置和额度快照以 JSON 文件保存在 `~/Library/Application Support/CapBar/`。 -用量统计默认关闭。开启后,应用运行期间会定时探测额度;关闭统计会停止定时采样。成功采集的记录保存在本机 SQLite 中。走势图按 UTC 区间计算,以本地时区显示日期;只有采样覆盖完整区间后才显示用量,缺测区间不会当作 0%。 +用量统计默认关闭。开启后,应用运行期间会定时探测额度;关闭统计会停止定时采样。成功采集的记录保存在本机 SQLite 中。走势图按 UTC 区间计算,以本地时区显示日期;只有采样覆盖完整区间后才显示用量,缺测区间不会当作 0%。新采样会一并记录当时识别或手动选择的套餐档位;旧采样没有档位时,总走势暂按当前校准档位估算。 ## 额度如何刷新 @@ -74,7 +83,7 @@ cd CapBar ./scripts/package-installer.sh ``` -脚本会运行项目检查,构建并验证 `dist/CapBar.app`,生成 `dist/CapBar-0.1.4.pkg`,同时检查安装包固定安装到 `/Applications`,不会定位到开发目录中的旧应用。 +脚本会运行项目检查,构建并验证 `dist/CapBar.app`,生成 `dist/CapBar-0.2.0.pkg`,同时检查安装包固定安装到 `/Applications`,不会定位到开发目录中的旧应用。 ```sh swift run CapBarChecks diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png index 6f7e199..1139e23 100644 Binary files a/docs/images/overview-dark.png and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png index 7f154a1..c63143b 100644 Binary files a/docs/images/overview-light.png and b/docs/images/overview-light.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png index 9ac24b2..2eefd94 100644 Binary files a/docs/images/settings.png and b/docs/images/settings.png differ diff --git a/docs/images/trend-dark.png b/docs/images/trend-dark.png index 94852fc..7270592 100644 Binary files a/docs/images/trend-dark.png and b/docs/images/trend-dark.png differ diff --git a/docs/images/trend-expanded-dark.png b/docs/images/trend-expanded-dark.png new file mode 100644 index 0000000..77f889d Binary files /dev/null and b/docs/images/trend-expanded-dark.png differ diff --git a/docs/images/trend-expanded-light.png b/docs/images/trend-expanded-light.png new file mode 100644 index 0000000..0a6912c Binary files /dev/null and b/docs/images/trend-expanded-light.png differ diff --git a/docs/images/trend-individual-light.png b/docs/images/trend-individual-light.png new file mode 100644 index 0000000..1b2ef5d Binary files /dev/null and b/docs/images/trend-individual-light.png differ diff --git a/docs/images/trend-light.png b/docs/images/trend-light.png index 4d4aa68..c705043 100644 Binary files a/docs/images/trend-light.png and b/docs/images/trend-light.png differ diff --git a/docs/releases/v0.2.0.md b/docs/releases/v0.2.0.md new file mode 100644 index 0000000..f02387c --- /dev/null +++ b/docs/releases/v0.2.0.md @@ -0,0 +1,21 @@ +CapBar 0.2.0 adds optional usage analysis for Claude Code and Codex accounts. + +- Switch between individual 7-day usage charts and a provider-wide total. Totals convert account usage to the first account's subscription capacity before adding it, and are marked as estimates because provider capacity ratios are not verified. +- Hover over a total chart to compare each account's contribution with aligned bars. The charts leave unobserved intervals blank. +- See observed 7-day usage statistics, recent 24-hour change, and historical records for rolling 7-day windows, single days, and intervals. Historical summaries are cached in SQLite and recalculated when samples change. +- Enable usage statistics to sample while CapBar is running. Choose a 1, 2, 3, 4, 6, or 8-hour interval; manual refreshes also add samples. The feature is off by default. +- The total headline now uses the latest fully observed interval. Older samples without subscription metadata no longer create a false chart gap when a later sample records the tier; a confirmed tier change still leaves the affected interval blank. + +CapBar 0.2.0 新增可选的 Claude Code 与 Codex 用量分析。 + +- 可在单账号 7 日走势与各服务的总走势之间切换。合计前会将各账号用量折算到首账号的订阅容量;由于套餐容量比例尚未核实,界面将结果标记为估算。 +- 悬停在总走势图上,可用对齐的横条比较各账号贡献;没有完整观测的区间仍保持空白。 +- 查看近 7 日已观测用量指标、近 24 小时变化,以及滚动 7 日、单日和单区间的历史记录。历史汇总缓存在 SQLite 中,采样变化后会重新计算。 +- 开启用量统计后,CapBar 运行期间按所选的 1、2、3、4、6 或 8 小时间隔采样;手动刷新也会增加采样。该功能默认关闭。 +- 总走势卡片显示最近一个完整观测区间。旧样本缺少订阅档位时,不再因为后续样本补充档位而错误断线;明确发生档位变化的区间仍留空。 + +### Install / 安装 + +Download `CapBar-0.2.0.pkg` and `SHA256SUMS` below. Verify the package with `shasum -a 256 -c SHA256SUMS`, then install it on an Apple Silicon Mac running macOS 14 or later. The package installs CapBar at `/Applications/CapBar.app`. + +The installer has no Apple Developer ID signature or notarization. macOS may require an explicit choice in Privacy & Security to open the app. diff --git a/scripts/Info.plist b/scripts/Info.plist index 809ba4a..0fb2923 100644 --- a/scripts/Info.plist +++ b/scripts/Info.plist @@ -9,8 +9,8 @@ CFBundleInfoDictionaryVersion6.0 CFBundleNameCapBar CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.4 - CFBundleVersion5 + CFBundleShortVersionString0.2.0 + CFBundleVersion6 LSMinimumSystemVersion14.0 LSUIElement NSHighResolutionCapable diff --git a/scripts/render-readme-screenshots.sh b/scripts/render-readme-screenshots.sh index 51df84c..75df788 100755 --- a/scripts/render-readme-screenshots.sh +++ b/scripts/render-readme-screenshots.sh @@ -11,5 +11,8 @@ cp /tmp/capbar-preview-light.png "$CAPBAR_IMAGES/overview-light.png" cp /tmp/capbar-preview-settings.png "$CAPBAR_IMAGES/settings.png" cp /tmp/capbar-preview-trend-dark.png "$CAPBAR_IMAGES/trend-dark.png" cp /tmp/capbar-preview-trend-light.png "$CAPBAR_IMAGES/trend-light.png" +cp /tmp/capbar-preview-trend-expanded-dark.png "$CAPBAR_IMAGES/trend-expanded-dark.png" +cp /tmp/capbar-preview-trend-expanded-light.png "$CAPBAR_IMAGES/trend-expanded-light.png" +cp /tmp/capbar-preview-trend-individual-light.png "$CAPBAR_IMAGES/trend-individual-light.png" print "README screenshots ready in $CAPBAR_IMAGES"