Skip to content
1 change: 1 addition & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ struct L {

// MARK: Usage recap
var recapOpen: String { t("사용량 돌아보기", "Usage recap", "使用量のふりかえり", "Resumen de uso", "Récap d'utilisation", "Resumo de uso", "Nutzungsrückblick", "Итоги расхода") }
var recapDetails: String { t("상세", "Details", "詳細", "Detalle", "Détails", "Detalhes", "Details", "Детали") }
var recapTokensUnit: String { t("토큰", "tokens", "トークン", "tokens", "tokens", "tokens", "Tokens", "токенов") }
func recapScopeName(_ scope: RecapScope) -> String {
switch scope {
Expand Down
51 changes: 42 additions & 9 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,16 @@ struct PopoverView: View {
}

// Keep period totals and saved-history navigation visible on quiet days too.
HStack(spacing: 14) {
periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil)
periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil)
Spacer()
Button { nav.showingRecap = true } label: {
Image(systemName: "chart.bar.xaxis")
ViewThatFits(in: .horizontal) {
HStack(spacing: 14) {
periodTotals.fixedSize(horizontal: true, vertical: false)
Spacer(minLength: 0)
recapButton
}
VStack(alignment: .trailing, spacing: 6) {
periodTotals.frame(maxWidth: .infinity, alignment: .leading)
recapButton
}
.buttonStyle(.borderless)
.help(l.recapOpen)
.accessibilityLabel(l.recapOpen)
}
.padding(.top, 2)

Expand Down Expand Up @@ -257,6 +257,39 @@ struct PopoverView: View {
store.snapshot(preferring: nav.providerID)
}

private var periodTotals: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 14) { periodLabels }
.fixedSize(horizontal: true, vertical: false)
VStack(alignment: .leading, spacing: 4) { periodLabels }
}
}

private var periodLabels: some View {
Group {
periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil)
.fixedSize(horizontal: true, vertical: false)
periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil)
.fixedSize(horizontal: true, vertical: false)
}
}

private var recapButton: some View {
Button { nav.showingRecap = true } label: {
HStack(spacing: 3) {
Image(systemName: "chart.bar.xaxis")
Text(l.recapDetails).font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.primary.opacity(0.08), in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(l.recapOpen)
.accessibilityLabel(l.recapOpen)
}

private var providerTabBar: some View {
ProviderTabBar(
snapshots: store.snapshots,
Expand Down
69 changes: 54 additions & 15 deletions Tests/PokeTokenBarTests/ZeroUsageHomeRenderingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import XCTest
private struct QuietHomeProvider: UsageProvider {
let id = "fixture"
let displayName = "Fixture"
let reportsCost = false
var reportsCost = false
let enrichment: ProviderEnrichment
func fetchDaily() async throws -> DailyUsage? { nil }
func fetchEnrichment() async -> ProviderEnrichment { enrichment }
Expand Down Expand Up @@ -53,14 +53,24 @@ final class ZeroUsageHomeRenderingTests: XCTestCase {
try await render(history: false)
}

private func render(history: Bool) async throws {
func testRecapLabelKeepsPeriodCostsOnOneLine() async throws {
for language in [AppLanguage.en, .de] {
try await render(history: true, language: language, costs: .source)
}
}

func testUnavailablePeriodCostsKeepGermanHomeWithinPopover() async throws {
try await render(history: true, language: .de, costs: .unavailable)
}

private func render(history: Bool, language: AppLanguage = .ko, costs: CostCoverage? = nil) async throws {
let suite = "QuietHome-\(UUID())"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let file = FileManager.default.temporaryDirectory.appendingPathComponent("\(suite).json")
defer { try? FileManager.default.removeItem(at: file) }
var state = CompanionState()
state.language = .ko
state.language = language
state.active = MonState(baseID: 129, pathIDs: [129, 130], stageIndex: 1,
usedAtStage: 170_100_000, rarity: .common, totalForms: 2, nature: .hardy)
try JSONEncoder().encode(state).write(to: file)
Expand All @@ -79,18 +89,25 @@ final class ZeroUsageHomeRenderingTests: XCTestCase {
}
let weekKey = formatter.string(from: LocalUsageReader.startOfWeek(now))
let enrichment = ProviderEnrichment(blocksOK: true,
weekTotal: PeriodUsage(period: weekKey, daily: series.filter { $0.date >= weekKey }),
monthTotal: PeriodUsage(period: LocalUsageReader.monthKey(now), daily: series),
weekTotal: costs != nil
? PeriodUsage(period: weekKey, totalTokens: 4_800_000, totalCost: costs == .unavailable ? 0 : 31.64,
costCoverage: costs ?? .source)
: PeriodUsage(period: weekKey, daily: series.filter { $0.date >= weekKey }),
monthTotal: costs != nil
? PeriodUsage(period: LocalUsageReader.monthKey(now), totalTokens: 17_900_000,
totalCost: costs == .unavailable ? 0 : 121.30,
costCoverage: costs ?? .source)
: PeriodUsage(period: LocalUsageReader.monthKey(now), daily: series),
monthDaily: series, periodsOK: true)
let store = UsageStore(providers: [QuietHomeProvider(enrichment: enrichment)],
let store = UsageStore(providers: [QuietHomeProvider(reportsCost: costs != nil, enrichment: enrichment)],
claudeLimitsProvider: QuietHomeClaude(), discoverClaudeConfigDirs: { [] },
readDefaultIdentity: { nil }, claudeUsageEntries: { _ in [] },
codexLimitsProvider: QuietHomeCodex(), antigravityLimitsProvider: QuietHomeAntigravity(),
cursorLimitsProvider: QuietHomeCursor(), statusProvider: QuietHomeStatus(),
autoRefresh: false, defaults: defaults)
await store.refresh(scheduleEmptyRetry: false)
XCTAssertEqual(store.todayTotalTokens, 0)
XCTAssertEqual(store.monthTotalTokens, series.reduce(0) { $0 + $1.totalTokens })
XCTAssertEqual(store.monthTotalTokens, costs != nil ? 17_900_000 : series.reduce(0) { $0 + $1.totalTokens })

let height = 604.0
let host = NSHostingView(rootView: PopoverView()
Expand Down Expand Up @@ -122,7 +139,11 @@ final class ZeroUsageHomeRenderingTests: XCTestCase {
XCTAssertGreaterThanOrEqual(CGFloat(bitmap.pixelsHigh) / bounds.height, 2)
host.cacheDisplay(in: bounds, to: bitmap)
let request = VNRecognizeTextRequest()
request.recognitionLanguages = ["ko-KR", "en-US"]
switch language {
case .en: request.recognitionLanguages = ["en-US"]
case .de: request.recognitionLanguages = ["de-DE", "en-US"]
default: request.recognitionLanguages = ["ko-KR", "en-US"]
}
request.recognitionLevel = .accurate
try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request])
let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") ?? ""
Expand All @@ -149,17 +170,35 @@ final class ZeroUsageHomeRenderingTests: XCTestCase {
XCTAssertTrue(normalizedText.contains(label.replacingOccurrences(of: " ", with: "")),
"Missing visible \(label): \(text)\n\(diagnostics)")
}
if history && day > 1 {
XCTAssertTrue(chartVisible,
"Historical chart missing: \(text)\n\(diagnostics)")
} else {
XCTAssertFalse(chartVisible,
"Empty history must not invent a chart\n\(diagnostics)")
if costs == .source {
// A split currency value creates separate OCR observations; do not join their digits.
for amount in ["$31.64", "$121.30"] {
XCTAssertTrue((request.results ?? []).contains {
$0.topCandidates(1).first?.string.replacingOccurrences(of: " ", with: "").contains(amount) == true
}, "Split or missing period cost \(amount) in \(language.rawValue): \(text)\n\(diagnostics)")
}
}
if costs == .unavailable {
let unavailable = companion.l.costUnavailable.replacingOccurrences(of: " ", with: "")
XCTAssertEqual((request.results ?? []).filter {
$0.topCandidates(1).first?.string.replacingOccurrences(of: " ", with: "").contains(unavailable) == true
}.count, 2, "Both unavailable period costs must stay visible\n\(diagnostics)")
}
if costs == nil {
// The original quiet-day fixtures own chart coverage; cost fixtures target the period row.
if history && day > 1 {
XCTAssertTrue(chartVisible,
"Historical chart missing: \(text)\n\(diagnostics)")
} else {
XCTAssertFalse(chartVisible,
"Empty history must not invent a chart\n\(diagnostics)")
}
}
if let output = ProcessInfo.processInfo.environment["PTB_ZERO_USAGE_SCREENSHOT_DIR"] {
let directory = URL(fileURLWithPath: output)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let name = history ? "ko-zero-today-with-history.png" : "ko-zero-all-periods.png"
let name = costs != nil ? "\(language.rawValue)-period-costs-\(costs == .source ? "known" : "unavailable").png"
: (history ? "ko-zero-today-with-history.png" : "ko-zero-all-periods.png")
try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent(name))
}
}
Expand Down
Binary file added docs/pr-images/432/after-de-cost.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/432/after-de-unavailable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/432/after-en-cost.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/432/before-de-cost.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/432/before-de-unavailable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-images/432/before-en-cost.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions docs/reference/defect-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -1293,3 +1293,25 @@ read_when:
fail the historical-chart assertion. Keep explicit pixel density, candidate diagnostics,
and both CI fixture PNGs so recognition failures can be checked against rendered pixels.
Other OCR harnesses passed the same CI runs; this repair is limited to the failing fixture.

- **A wider navigation label must not squeeze numeric period summaries.** Adding the localized
recap capsule to the home's fixed-width summary row left too little space for its week/month
labels and costs: the real English and German popovers split `$31.64` and `$121.30` across
lines. The existing production render used Korean with costs disabled, so it never exercised
that combination. Preserve the period totals' intrinsic width and let `ViewThatFits` move
the recap button to a trailing row when the inline arrangement does not fit. If the two period
groups alone exceed the content width (German `Nicht verfügbar`), stack those groups too;
forcing their combined intrinsic width expands and clips the entire popover. The sibling
Claude account period row has no recap control and does not share this trigger.
`testRecapLabelKeepsPeriodCostsOnOneLine` renders the full production popover in English
and German with costs enabled and requires each complete amount in one top-ranked OCR
observation. Restoring the previous inline row makes both languages fail; joining OCR
observations would conceal the broken digits. `testUnavailablePeriodCostsKeepGermanHomeWithinPopover`
also requires the full leading home captions and both unavailable labels, so a clipped
popover cannot pass merely because its trailing recap button remains visible.
Match OCR recognition languages to each fixture: inheriting Korean for English made CI
read `This` as `Thls` despite correct pixels. The period-cost fixtures retain strict labels
and complete amounts; the original Korean quiet-day fixtures own the strict chart assertion.
On the same CI image, English-only recognition corrected the period label but still read
the unrelated chart caption `Daily` as `Dally`; do not teach OCR that misspelling or relax
the original chart guard to make a period-row regression pass.
Loading