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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,17 @@ public enum PhoneLedgerOverlay {
monthDaily: enrichment.monthDaily?.map {
PhoneDailyTrend(date: $0.date, totalTokens: $0.totalTokens, totalCost: $0.totalCost,
isToday: $0.date == todayKey)
})
// Same rule as the Mac's snapshot list: a tab for usage today, or a carrier tab while a
// 5-hour block from late last night is still active.
if today != nil || (enrichment.activeBlock?.totalTokens ?? 0) > 0 {
},
lastUsage: enrichment.lastUsage)
// The Mac's tab rule lives in one place (`hasDisplayableUsage`) so the two devices
// cannot drift again: this used to require usage today or a live block, while the Mac
// had moved on to keeping idle days and the 7-day quota window (upstream #377/#402).
if counted[provider.id]!.hasDisplayableUsage(now: now) {
countedVisible.insert(provider.id)
}
}

// Every provider contributes to the totals; only ones with something current get a tab.
// Every provider contributes to the totals; only ones with something displayable get a tab.
var all: [(snapshot: PhoneProviderSnapshot, visible: Bool)] = []
for mac in macPayload?.providers ?? [] {
if let replacement = counted.removeValue(forKey: mac.id) {
Expand All @@ -70,8 +72,10 @@ public enum PhoneLedgerOverlay {
weekTokens: sameWeek ? mac.weekTokens : 0, weekCost: sameWeek ? mac.weekCost : 0,
monthTokens: sameMonth ? mac.monthTokens : 0, monthCost: sameMonth ? mac.monthCost : 0,
tokensPerMinute: burnFresh ? mac.tokensPerMinute : nil,
monthDaily: sameMonth ? mac.monthDaily : nil)
all.append((kept, kept.todayTokens > 0 || (kept.tokensPerMinute ?? 0) > 0))
monthDaily: sameMonth ? mac.monthDaily : nil,
// An absolute date: still true after the Mac's week or month has rolled over.
lastUsage: mac.lastUsage)
all.append((kept, kept.hasDisplayableUsage(now: now)))
}
for provider in manifest.providers {
guard let snapshot = counted[provider.id] else { continue }
Expand Down
21 changes: 20 additions & 1 deletion PokeTokenBarShared/Sources/PokeTokenBarShared/PhonePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -477,20 +477,26 @@ public struct PhoneProviderSnapshot: Codable, Sendable, Equatable {
public let monthCost: Double?
public let tokensPerMinute: Double?
public let monthDaily: [PhoneDailyTrend]?
/// Newest entry with tokens. Week and month totals reset at calendar boundaries, so this is
/// what keeps a provider used last Friday on screen on Monday (`hasDisplayableUsage`).
/// nil from Macs that predate the field.
public let lastUsage: Date?

public init(id: String, displayName: String, todayTokens: Int, todayCost: Double,
inputTokens: Int = 0, outputTokens: Int = 0,
cacheWriteTokens: Int = 0, cacheReadTokens: Int = 0,
reportsCost: Bool = true,
weekTokens: Int? = nil, weekCost: Double? = nil,
monthTokens: Int? = nil, monthCost: Double? = nil,
tokensPerMinute: Double? = nil, monthDaily: [PhoneDailyTrend]? = nil) {
tokensPerMinute: Double? = nil, monthDaily: [PhoneDailyTrend]? = nil,
lastUsage: Date? = nil) {
self.weekTokens = weekTokens
self.weekCost = weekCost
self.monthTokens = monthTokens
self.monthCost = monthCost
self.tokensPerMinute = tokensPerMinute
self.monthDaily = monthDaily
self.lastUsage = lastUsage
self.id = id
self.displayName = displayName
self.todayTokens = todayTokens
Expand All @@ -501,6 +507,19 @@ public struct PhoneProviderSnapshot: Codable, Sendable, Equatable {
self.cacheReadTokens = cacheReadTokens
self.reportsCost = reportsCost
}

/// The Mac's tab rule (`ProviderSnapshot.hasDisplayableUsage`, upstream #377/#402): any usage
/// today, this week or this month, a live 5-hour block, or use within the weekly quota window.
/// A provider is shown while its official limits can still mean something, not only on days
/// it was used. Callers pass period-gated numbers; `lastUsage` is an absolute date and is not.
public func hasDisplayableUsage(now: Date) -> Bool {
todayTokens > 0
|| (tokensPerMinute ?? 0) > 0
|| (weekTokens ?? 0) > 0
|| (monthTokens ?? 0) > 0
|| monthDaily?.contains(where: { $0.totalTokens > 0 }) == true
|| lastUsage.map { now.timeIntervalSince($0) <= UsageAggregation.recentUseWindow } == true
}
}

// MARK: - Burn Forecast
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public struct ProviderMetadata: Sendable, Equatable, Identifiable {
ProviderMetadata(id: "grok", displayName: "Grok CLI"),
ProviderMetadata(id: "hermes", displayName: "Hermes Agent"),
ProviderMetadata(id: "kiro", displayName: "Kiro"),
ProviderMetadata(id: "kimi_code", displayName: "Kimi Code"),
ProviderMetadata(id: "pi", displayName: "Pi"),
ProviderMetadata(id: "omp", displayName: "Oh My Prompt"),
]
Expand Down
20 changes: 15 additions & 5 deletions PokeTokenBarShared/Sources/PokeTokenBarShared/TokenFormatter.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Foundation

/// Cross-platform token formatting utilities. No AppKit/SwiftUI dependencies.
/// Mirrors the Mac's `TokenFormatter`, including its rounding rules (upstream #365, #418): a unit is
/// chosen from the value the user will see, not the raw one, so no readout ever says "1000.0K",
/// "$100.0" or "80.0%".
public enum TokenFormatter {
/// 987 → "987", 12_345 → "12.3K", 190_612_940 → "190.6M", 1_240_000_000 → "1.24B"
public static func compact(_ value: Int) -> String {
Expand All @@ -9,9 +12,10 @@ public enum TokenFormatter {
switch v {
case ..<1_000:
return "\(value)"
case ..<1_000_000:
// K and M use one decimal: promote when rounding would display 1000.0.
case ..<999_950:
return sign + trim(v / 1_000, decimals: 1) + "K"
case ..<1_000_000_000:
case ..<999_950_000:
return sign + trim(v / 1_000_000, decimals: 1) + "M"
default:
return sign + trim(v / 1_000_000_000, decimals: 2) + "B"
Expand All @@ -22,14 +26,20 @@ public enum TokenFormatter {
String(format: "$%.2f", usd)
}

/// Short cost readout: $9.5 / $311 / $1.2K
/// The band is picked from the *rounded string*, not the raw value — 99.96 is "$100", not "$100.0".
public static func costCompact(_ usd: Double) -> String {
if usd < 100 { return String(format: "$%.1f", usd) }
if usd < 10_000 { return String(format: "$%.0f", usd) }
let tenths = String(format: "%.1f", usd)
if let v = Double(tenths), v < 100 { return "$" + tenths }
let whole = String(format: "%.0f", usd)
if let v = Double(whole), v < 10_000 { return "$" + whole }
return String(format: "$%.1fK", usd / 1_000)
}

/// 79.96 → "80%" (not "80.0%"), 88.35 → "88.3%"
public static func percent(_ value: Double) -> String {
value == value.rounded() ? String(format: "%.0f%%", value) : String(format: "%.1f%%", value)
let tenths = String(format: "%.1f", value)
return (tenths.hasSuffix(".0") ? String(tenths.dropLast(2)) : tenths) + "%"
}

private static func trim(_ value: Double, decimals: Int) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ private func manifest(_ providers: [PhoneUsageLedgerManifest.Provider] = [claude
PhoneUsageLedgerManifest(updatedAt: now, providers: providers, records: [])
}

private func macSnapshot(_ id: String, today: Int, week: Int, month: Int, tpm: Double? = nil) -> PhoneProviderSnapshot {
private func macSnapshot(_ id: String, today: Int, week: Int, month: Int, tpm: Double? = nil,
lastUsage: Date? = nil) -> PhoneProviderSnapshot {
PhoneProviderSnapshot(id: id, displayName: id, todayTokens: today, todayCost: 1,
weekTokens: week, weekCost: 2, monthTokens: month, monthCost: 3,
tokensPerMinute: tpm,
monthDaily: [PhoneDailyTrend(date: "2026-09-01", totalTokens: month - today, totalCost: 2)])
monthDaily: [PhoneDailyTrend(date: "2026-09-01", totalTokens: month - today, totalCost: 2)],
lastUsage: lastUsage)
}

private func macPayload(at date: Date, providers: [PhoneProviderSnapshot]) -> PhonePayload {
Expand Down Expand Up @@ -73,7 +75,7 @@ struct PhoneUsageLedgerCodecTests {

struct PhoneLedgerOverlayTests {
/// The goal: Mac asleep since yesterday, cloud turns today. Claude is recounted from entries;
/// the Mac-only provider keeps its week/month but shows nothing for today.
/// the Mac-only provider keeps its week/month (and so its tab, as on the Mac) but shows zero today.
@Test func macAsleepSinceYesterdayCountsLedgerAndZeroesOtherProvidersToday() {
let mac = macPayload(at: local(2026, 9, 15, 22), providers: [
macSnapshot("claude_code", today: 999, week: 999, month: 999),
Expand All @@ -82,7 +84,8 @@ struct PhoneLedgerOverlayTests {
let entries = ["claude_code": [entry("y", local(2026, 9, 15, 20)), entry("t", local(2026, 9, 16, 11))]]
let out = PhoneLedgerOverlay.apply(macPayload: mac, manifest: manifest(), entries: entries, countedAt: now, now: now)

#expect(out.providers.map(\.id) == ["claude_code"], "codex has nothing current, so no tab")
#expect(out.providers.map(\.id) == ["claude_code", "codex"])
#expect(out.providers[1].todayTokens == 0, "yesterday's Mac total is not today's")
#expect(out.todayTokens == 1000)
#expect(out.weekTokens == 2000 + 500)
#expect(out.monthTokens == 2000 + 5000)
Expand Down Expand Up @@ -152,6 +155,60 @@ struct PhoneLedgerOverlayTests {
#expect((out.burn?.tokensPerMinute ?? 0) > 0)
}

/// The Mac keeps a tab for a provider with history this month even on a day it was not used
/// (upstream #377). The phone used to drop it the moment today was zero and no block was live.
@Test func aRecountedProviderIdleTodayKeepsItsTabWhileThisMonthHasUsage() {
let out = PhoneLedgerOverlay.apply(macPayload: nil, manifest: manifest(),
entries: ["claude_code": [entry("w", local(2026, 9, 14, 10))]],
countedAt: now, now: now)
#expect(out.providers.map(\.id) == ["claude_code"])
#expect(out.providers[0].todayTokens == 0)
#expect(out.providers[0].monthTokens == 1000)
}

/// Week and month reset at calendar boundaries; the newest entry does not. A provider used
/// last Friday is still shown on the 1st of the next month (upstream #402, the 7-day quota window),
/// while one idle for longer is not.
@Test func aRecountedProviderUsedInsideTheQuotaWindowSurvivesAMonthRollover() {
let firstOfMonth = local(2026, 10, 1, 9)
let recent = ["claude_code": [entry("fri", local(2026, 9, 25, 15))]] // 6 days back
let stale = ["claude_code": [entry("old", local(2026, 9, 20, 15))]] // 11 days back
let kept = PhoneLedgerOverlay.apply(macPayload: nil, manifest: manifest(), entries: recent,
countedAt: firstOfMonth, now: firstOfMonth)
let dropped = PhoneLedgerOverlay.apply(macPayload: nil, manifest: manifest(), entries: stale,
countedAt: firstOfMonth, now: firstOfMonth)
#expect(kept.providers.map(\.id) == ["claude_code"])
#expect(kept.providers[0].monthTokens == 0, "a tab, not a number — month is a fresh zero")
#expect(dropped.providers.isEmpty)
}

/// A provider the phone does not recount follows the same rule from the Mac's numbers. Period
/// values are zeroed when the Mac's period is over, but `lastUsage` is an absolute date.
@Test func aMacOnlyProviderKeepsItsTabFromLastUsageAfterTheMacPeriodRolledOver() {
let firstOfMonth = local(2026, 10, 1, 9)
let mac = macPayload(at: local(2026, 9, 30, 23), providers: [
macSnapshot("codex", today: 0, week: 500, month: 5000, lastUsage: local(2026, 9, 28, 12)),
macSnapshot("gemini", today: 0, week: 0, month: 5000, lastUsage: local(2026, 9, 10, 12)),
macSnapshot("cursor", today: 0, week: 0, month: 5000), // older Mac: no lastUsage field
])
let out = PhoneLedgerOverlay.apply(macPayload: mac, manifest: manifest([]), entries: [:],
countedAt: firstOfMonth, now: firstOfMonth)
#expect(out.providers.map(\.id) == ["codex"])
#expect(out.providers[0].monthTokens == 0)
#expect(out.monthTokens == 0, "hidden providers still contribute nothing stale to the totals")
}

/// Payloads from Macs that predate the field decode with `lastUsage == nil`.
@Test func providerSnapshotWithoutLastUsageDecodes() throws {
let json = """
{"id":"codex","displayName":"Codex","todayTokens":1,"todayCost":0,"inputTokens":0,"outputTokens":0,
"cacheWriteTokens":0,"cacheReadTokens":0,"reportsCost":true}
""".data(using: .utf8)!
let snapshot = try JSONDecoder().decode(PhoneProviderSnapshot.self, from: json)
#expect(snapshot.lastUsage == nil)
#expect(snapshot.hasDisplayableUsage(now: now))
}

@Test func aPastDepletionForecastIsDropped() {
var mac = macPayload(at: now.addingTimeInterval(-3600), providers: [])
mac = PhonePayload(todayTokens: 0, todayCost: 0, weekTokens: 0, monthTokens: 0, lastUpdated: mac.lastUpdated,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,42 @@ struct PhonePayloadTests {
#expect(TokenFormatter.compact(1_240_000_000) == "1.24B")
}

/// Same cases as the Mac's `testCompactPromotesUnitAtRoundingBoundary`: the unit follows the
/// rounded display value, so a value that would read "1000.0K" is promoted to "1M".
@Test func compactPromotesUnitAtRoundingBoundary() {
let cases: [(Int, String)] = [
(999_949, "999.9K"), (999_950, "1M"),
(999_999, "1M"), (1_000_000, "1M"),
(999_949_999, "999.9M"), (999_950_000, "1B"),
(999_999_999, "1B"), (1_000_000_000, "1B"),
(1_240_000_000, "1.24B"),
]
for (value, expected) in cases {
#expect(TokenFormatter.compact(value) == expected, "value: \(value)")
#expect(TokenFormatter.compact(-value) == "-" + expected, "value: \(-value)")
}
}

@Test func costFormatting() {
#expect(TokenFormatter.cost(9.50) == "$9.50")
#expect(TokenFormatter.costCompact(9.5) == "$9.5")
#expect(TokenFormatter.costCompact(311) == "$311")
#expect(TokenFormatter.costCompact(12_400) == "$12.4K")
}

/// The band is decided from the rounded string, not the raw value (Mac `testRoundingBoundaries`).
@Test func costCompactAndPercentPickPrecisionFromTheRoundedValue() {
#expect(TokenFormatter.costCompact(99.96) == "$100")
#expect(TokenFormatter.costCompact(99.94) == "$99.9")
#expect(TokenFormatter.costCompact(9_999.6) == "$10.0K")
#expect(TokenFormatter.costCompact(9_999.4) == "$9999")
#expect(TokenFormatter.costCompact(100) == "$100")
#expect(TokenFormatter.percent(79.96) == "80%")
#expect(TokenFormatter.percent(99.97) == "100%")
#expect(TokenFormatter.percent(0.04) == "0%")
#expect(TokenFormatter.percent(79.94) == "79.9%")
}

@Test func percentFormatting() {
#expect(TokenFormatter.percent(84.0) == "84%")
#expect(TokenFormatter.percent(84.5) == "84.5%")
Expand Down
3 changes: 2 additions & 1 deletion Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
monthDaily: snapshot.monthDaily?.map {
PhoneDailyTrend(date: $0.date, totalTokens: $0.totalTokens,
totalCost: $0.totalCost, isToday: $0.date == todayKey)
})
},
lastUsage: snapshot.lastUsage)
}
}

Expand Down
5 changes: 4 additions & 1 deletion Tests/PokeTokenBarTests/CloudSessionSyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,14 @@ final class PhoneProviderSnapshotBuilderTests: XCTestCase {
monthTotal: PeriodUsage(period: "m", totalTokens: 1000, totalCost: 3),
monthDaily: [DailyUsage(date: today, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0,
cacheReadTokens: 0, totalTokens: 10, totalCost: 1.5)],
fetchedAt: Date())
fetchedAt: Date(),
lastUsage: Date(timeIntervalSince1970: 1_700_000_000))
let out = AppDelegate.phoneProviderSnapshots([snapshot], todayKey: today)
XCTAssertEqual(out.first?.weekTokens, 100)
XCTAssertEqual(out.first?.monthCost, 3)
XCTAssertEqual(out.first?.tokensPerMinute, 7)
XCTAssertEqual(out.first?.monthDaily, [PhoneDailyTrend(date: today, totalTokens: 10, totalCost: 1.5, isToday: true)])
// The phone applies the Mac's tab rule itself (`hasDisplayableUsage`), so it needs this date.
XCTAssertEqual(out.first?.lastUsage, Date(timeIntervalSince1970: 1_700_000_000))
}
}
Loading
Loading