From 4761af5ed64e4fbaad17d579742a6b253208a92e Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 11:35:37 -0400 Subject: [PATCH 1/3] Show "% used" on iOS to match claude.ai + tighten refresh to ~40s Display: - iOS label "% remaining" -> "% used" to match claude.ai's framing - Bar now fills as you consume; color is green<50% used, orange<80%, red>=80% - usedPct = 100 - sessionPct/weeklyPct, computed on the iOS side, no protocol change so firmware/daemon contract stays the same. Refresh cadence: - Chrome plan scrape LaunchAgent StartInterval: 60s -> 15s - Claude daemon CODEXMETER_POLL_INTERVAL: 30s -> 10s (env in plist) - iOS fetchTimer: 30s -> 15s - Worst-case lag from claude.ai change to phone display drops from ~2 min to ~40s. Co-Authored-By: Claude Opus 4.7 --- install.sh | 2 +- ios/CodexMeterApp/CodexMeterApp/ContentView.swift | 10 ++++++---- .../CodexMeterApp/ViewModels/MeterViewModel.swift | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/install.sh b/install.sh index 76ef229..acf68ad 100755 --- a/install.sh +++ b/install.sh @@ -79,7 +79,7 @@ PLIST $PYTHON $SCRAPE_SCRIPT - StartInterval60 + StartInterval15 RunAtLoad StandardOutPath$HOME/Library/Logs/codexmeter-claude-scrape.log StandardErrorPath$HOME/Library/Logs/codexmeter-claude-scrape.err.log diff --git a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift index 66e2a88..7766c92 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift @@ -705,7 +705,7 @@ struct UsageCard: View { HStack { Text(title).font(.headline) Spacer() - Text("\(Int(pct.rounded()))% remaining") + Text("\(Int(usedPct.rounded()))% used") .font(.title2).fontWeight(.bold) .foregroundColor(barColor) } @@ -717,7 +717,7 @@ struct UsageCard: View { .frame(height: 12) RoundedRectangle(cornerRadius: 4) .fill(barColor.gradient) - .frame(width: geometry.size.width * pct / 100, height: 12) + .frame(width: geometry.size.width * usedPct / 100, height: 12) } } .frame(height: 12) @@ -732,9 +732,11 @@ struct UsageCard: View { .clipShape(RoundedRectangle(cornerRadius: 12)) } + var usedPct: Double { max(0, min(100, 100 - pct)) } + var barColor: Color { - if pct <= 20 { return .red } - if pct <= 50 { return .orange } + if usedPct >= 80 { return .red } + if usedPct >= 50 { return .orange } return .green } diff --git a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift index fb1730d..a885e82 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift @@ -226,7 +226,7 @@ final class MeterViewModel: ObservableObject { Task { await fetchUsage() } - fetchTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + fetchTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in await self?.fetchUsage() } } From d85abe72a26e455501eef45a533369e6529b4876 Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 11:47:00 -0400 Subject: [PATCH 2/3] Widget: show "% used" matching main app + claude.ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget was rendering the raw sessionPct/weeklyPct payload fields, which are "remaining %" — so when the main app showed "61% used" the widget showed "39% left." Same data, opposite framing. User couldn't tell at a glance that they were synced. - New helpers usedPct(from:) and usedColor(forUsed:) convert the on-the-wire remaining% to displayed used%. - AtomProgressBar now fills LEFT-to-RIGHT as the user consumes; color goes green -> orange (>=50% used) -> red (>=80% used). - Atom-style widget labels switch from "X LEFT" / "WK LEFT" to "5H USED" / "7D USED" and show the inverted percentage. - Accessory circular and rectangular variants display used% too. - Bumped widget timeline reload from 15 min to 5 min so iOS gets a more aggressive refresh hint (the app already calls WidgetCenter.reloadAllTimelines on every fetch, but the timeline policy bounds how patient iOS is). The protocol fields (s/w) still carry remaining%; only the widget's display layer changed. Firmware contract is untouched. Co-Authored-By: Claude Opus 4.7 --- .../CodexMeterAppWidget/UsageWidget.swift | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift index 94f2409..38f2749 100644 --- a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift +++ b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift @@ -63,7 +63,7 @@ struct UsageProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { fetchLiveEntries { entry in let fallback = self.loadEntry() ?? UsageEntry(date: Date(), payload: UsagePayload(sourceName: "Codex", sessionPct: 0, weeklyPct: 0, sessionResetMins: -1, weeklyResetMins: -1, status: "Open app to sync", isOk: false, lastUpdated: nil)) - let next = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date().addingTimeInterval(900) + let next = Calendar.current.date(byAdding: .minute, value: 5, to: Date()) ?? Date().addingTimeInterval(300) completion(Timeline(entries: [entry ?? fallback], policy: .after(next))) } } @@ -247,9 +247,15 @@ private func formatResetTime(mins: Int) -> String { return "\(h)h \(m)m" } -private func remainingColor(for pct: Int) -> Color { - if pct <= 20 { return Color.red } - if pct <= 50 { return Color.orange } +/// Convert a "remaining %" value from the daemon payload to the "used %" we +/// want to display so framings line up with claude.ai and the main app. +private func usedPct(from remainingPct: Int) -> Int { + max(0, min(100, 100 - remainingPct)) +} + +private func usedColor(forUsed pct: Int) -> Color { + if pct >= 80 { return Color.red } + if pct >= 50 { return Color.orange } return Color.green } @@ -282,12 +288,12 @@ struct AtomProgressBar: View { .fill(barBg) .padding(1) - if active && pct > 0 { - let clamped = min(max(pct, 0), 100) - let fillWidth = (geo.size.width - 2) * CGFloat(clamped) / 100.0 + if active { + let used = usedPct(from: pct) + let fillWidth = (geo.size.width - 2) * CGFloat(used) / 100.0 if fillWidth > 0 { RoundedRectangle(cornerRadius: 2) - .fill(remainingColor(for: pct)) + .fill(usedColor(forUsed: used)) .frame(width: fillWidth) .padding(1) } @@ -339,33 +345,33 @@ struct AtomWidgetStyleView: View { } else { // Session (Today) - Text(entry.isOk ? "\(formatResetTime(mins: entry.sessionResetMins).uppercased()) LEFT" : "TODAY") + Text(entry.isOk ? "5H USED" : "TODAY") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - - Text(entry.isOk ? "\(entry.sessionPct)%" : "--") + + Text(entry.isOk ? "\(usedPct(from: entry.sessionPct))%" : "--") .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundStyle(atomText) - + AtomProgressBar(pct: entry.sessionPct, active: entry.isOk) .frame(height: 10) .padding(.vertical, 2) - + Text(entry.isOk ? "reset \(formatResetTime(mins: entry.sessionResetMins))" : entry.status) .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(atomDim) .lineLimit(1) - + Spacer(minLength: 4) - + // Weekly HStack(alignment: .bottom, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Text(entry.isOk ? "WK LEFT" : "WEEK") + Text(entry.isOk ? "7D USED" : "WEEK") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - - Text(entry.isOk ? "\(entry.weeklyPct)%" : "--") + + Text(entry.isOk ? "\(usedPct(from: entry.weeklyPct))%" : "--") .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(atomText) } @@ -479,8 +485,9 @@ private func relativeTime(_ date: Date) -> String { struct AccessoryCircularView: View { let entry: UsageEntry + private var used: Int { usedPct(from: entry.sessionPct) } private var fraction: Double { - let value = Double(entry.sessionPct) / 100.0 + let value = Double(used) / 100.0 guard entry.isOk else { return max(0, value) } return max(0.01, value) } @@ -493,7 +500,7 @@ struct AccessoryCircularView: View { .stroke(.white, style: StrokeStyle(lineWidth: 4, lineCap: .round)) .rotationEffect(.degrees(-90)) VStack(spacing: -1) { - Text("\(entry.sessionPct)") + Text("\(used)") .font(.system(size: 17, weight: .bold, design: .rounded)) .monospacedDigit() Text("%") @@ -518,7 +525,7 @@ struct AccessoryRectangularView: View { .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) .tracking(1.2) - Text("\(entry.sessionPct)% left") + Text("\(usedPct(from: entry.sessionPct))% used") .font(.system(size: 16, weight: .bold, design: .rounded)) .monospacedDigit() if !entry.status.isEmpty { From 27816e6755ec7d0adc5bfe7e8172d9a39654b6f8 Mon Sep 17 00:00:00 2001 From: justin Date: Fri, 22 May 2026 12:02:04 -0400 Subject: [PATCH 3/3] Add "Used / Left" display framing toggle in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the user pick whether percentages are shown as "X% used" (fills as you consume, matches claude.ai — default) or "X% left" (drains as you consume, the original behavior). - Settings screen gets a new "Display > Percentages" segmented picker. - Stored as display_framing in sharedGroup so both the main app and the widget read the same value. - Bar color stays keyed on usage pressure (red when heavily used) regardless of framing, so visual urgency doesn't invert. - WidgetCenter.reloadAllTimelines() is called on toggle so the home screen picks up the new framing. Co-Authored-By: Claude Opus 4.7 --- .../CodexMeterApp/ContentView.swift | 30 ++++++++++++++-- .../CodexMeterAppWidget/UsageWidget.swift | 36 ++++++++++++++----- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift index 7766c92..6ec422d 100644 --- a/ios/CodexMeterApp/CodexMeterApp/ContentView.swift +++ b/ios/CodexMeterApp/CodexMeterApp/ContentView.swift @@ -353,6 +353,8 @@ struct SettingsView: View { @State private var selectedProvider: MeterViewModel.UsageProviderKind = .codex @AppStorage("widget_usage_mode", store: UserDefaults.sharedGroup) private var widgetUsageMode: String = "codex" + @AppStorage("display_framing", store: UserDefaults.sharedGroup) + private var displayFraming: String = "used" @AppStorage("selected_pet", store: UserDefaults.sharedGroup) private var selectedPet: String = "sukuna" @State private var showPetPicker = false @@ -389,6 +391,23 @@ struct SettingsView: View { } } + Section { + Picker("Percentages", selection: $displayFraming) { + Text("Used").tag("used") + Text("Left").tag("left") + } + .pickerStyle(.segmented) + .onChange(of: displayFraming) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "display_framing") + UserDefaults.sharedGroup.set(newValue, forKey: "display_framing") + WidgetCenter.shared.reloadAllTimelines() + } + } header: { + Text("Display") + } footer: { + Text("\"Used\" matches claude.ai (fills as you consume). \"Left\" shows how much you have remaining.") + } + Section { TextField("http://100.x.x.x:9595", text: $urlText) .keyboardType(.URL) @@ -699,13 +718,15 @@ struct UsageCard: View { let title: String let pct: Double let resetMins: Int + @AppStorage("display_framing", store: UserDefaults.sharedGroup) + private var displayFraming: String = "used" var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(title).font(.headline) Spacer() - Text("\(Int(usedPct.rounded()))% used") + Text("\(Int(displayValue.rounded()))% \(displayFraming == "left" ? "left" : "used")") .font(.title2).fontWeight(.bold) .foregroundColor(barColor) } @@ -717,7 +738,7 @@ struct UsageCard: View { .frame(height: 12) RoundedRectangle(cornerRadius: 4) .fill(barColor.gradient) - .frame(width: geometry.size.width * usedPct / 100, height: 12) + .frame(width: geometry.size.width * displayValue / 100, height: 12) } } .frame(height: 12) @@ -733,8 +754,13 @@ struct UsageCard: View { } var usedPct: Double { max(0, min(100, 100 - pct)) } + var leftPct: Double { max(0, min(100, pct)) } + /// What the big number AND the bar fill width represent. + var displayValue: Double { displayFraming == "left" ? leftPct : usedPct } var barColor: Color { + // Color stays keyed on usage pressure regardless of framing: + // red when heavily used, green when fresh. if usedPct >= 80 { return .red } if usedPct >= 50 { return .orange } return .green diff --git a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift index 38f2749..5826fcf 100644 --- a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift +++ b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift @@ -259,6 +259,23 @@ private func usedColor(forUsed pct: Int) -> Color { return Color.green } +private enum DisplayFraming: String { + case used, left + + static var current: DisplayFraming { + let raw = UserDefaults.sharedGroup.string(forKey: "display_framing") ?? "used" + return DisplayFraming(rawValue: raw) ?? .used + } + + var suffix: String { self == .left ? "LEFT" : "USED" } + var lowerSuffix: String { self == .left ? "left" : "used" } +} + +/// Number to render and bar width to use, given a "remaining %" from the payload. +private func displayNumber(remaining: Int) -> Int { + DisplayFraming.current == .left ? max(0, min(100, remaining)) : usedPct(from: remaining) +} + private func sourceIconName(_ sourceName: String) -> String { sourceName.lowercased() == "claude" ? "ClaudeCodeIcon" : "CodexIcon" } @@ -290,7 +307,8 @@ struct AtomProgressBar: View { if active { let used = usedPct(from: pct) - let fillWidth = (geo.size.width - 2) * CGFloat(used) / 100.0 + let display = displayNumber(remaining: pct) + let fillWidth = (geo.size.width - 2) * CGFloat(display) / 100.0 if fillWidth > 0 { RoundedRectangle(cornerRadius: 2) .fill(usedColor(forUsed: used)) @@ -345,11 +363,11 @@ struct AtomWidgetStyleView: View { } else { // Session (Today) - Text(entry.isOk ? "5H USED" : "TODAY") + Text(entry.isOk ? "5H \(DisplayFraming.current.suffix)" : "TODAY") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - Text(entry.isOk ? "\(usedPct(from: entry.sessionPct))%" : "--") + Text(entry.isOk ? "\(displayNumber(remaining: entry.sessionPct))%" : "--") .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundStyle(atomText) @@ -367,11 +385,11 @@ struct AtomWidgetStyleView: View { // Weekly HStack(alignment: .bottom, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Text(entry.isOk ? "7D USED" : "WEEK") + Text(entry.isOk ? "7D \(DisplayFraming.current.suffix)" : "WEEK") .font(.system(size: 8, weight: .bold, design: .rounded)) .foregroundStyle(atomDim) - Text(entry.isOk ? "\(usedPct(from: entry.weeklyPct))%" : "--") + Text(entry.isOk ? "\(displayNumber(remaining: entry.weeklyPct))%" : "--") .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(atomText) } @@ -485,9 +503,9 @@ private func relativeTime(_ date: Date) -> String { struct AccessoryCircularView: View { let entry: UsageEntry - private var used: Int { usedPct(from: entry.sessionPct) } + private var shown: Int { displayNumber(remaining: entry.sessionPct) } private var fraction: Double { - let value = Double(used) / 100.0 + let value = Double(shown) / 100.0 guard entry.isOk else { return max(0, value) } return max(0.01, value) } @@ -500,7 +518,7 @@ struct AccessoryCircularView: View { .stroke(.white, style: StrokeStyle(lineWidth: 4, lineCap: .round)) .rotationEffect(.degrees(-90)) VStack(spacing: -1) { - Text("\(used)") + Text("\(shown)") .font(.system(size: 17, weight: .bold, design: .rounded)) .monospacedDigit() Text("%") @@ -525,7 +543,7 @@ struct AccessoryRectangularView: View { .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) .tracking(1.2) - Text("\(usedPct(from: entry.sessionPct))% used") + Text("\(displayNumber(remaining: entry.sessionPct))% \(DisplayFraming.current.lowerSuffix)") .font(.system(size: 16, weight: .bold, design: .rounded)) .monospacedDigit() if !entry.status.isEmpty {