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..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(pct.rounded()))% remaining")
+ 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 * pct / 100, height: 12)
+ .frame(width: geometry.size.width * displayValue / 100, height: 12)
}
}
.frame(height: 12)
@@ -732,9 +753,16 @@ struct UsageCard: View {
.clipShape(RoundedRectangle(cornerRadius: 12))
}
+ 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 {
- if pct <= 20 { return .red }
- if pct <= 50 { return .orange }
+ // 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/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() }
}
diff --git a/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift b/ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift
index 94f2409..5826fcf 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,12 +247,35 @@ 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
}
+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"
}
@@ -282,12 +305,13 @@ 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 display = displayNumber(remaining: pct)
+ let fillWidth = (geo.size.width - 2) * CGFloat(display) / 100.0
if fillWidth > 0 {
RoundedRectangle(cornerRadius: 2)
- .fill(remainingColor(for: pct))
+ .fill(usedColor(forUsed: used))
.frame(width: fillWidth)
.padding(1)
}
@@ -339,33 +363,33 @@ struct AtomWidgetStyleView: View {
} else {
// Session (Today)
- Text(entry.isOk ? "\(formatResetTime(mins: entry.sessionResetMins).uppercased()) LEFT" : "TODAY")
+ Text(entry.isOk ? "5H \(DisplayFraming.current.suffix)" : "TODAY")
.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundStyle(atomDim)
-
- Text(entry.isOk ? "\(entry.sessionPct)%" : "--")
+
+ Text(entry.isOk ? "\(displayNumber(remaining: 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 \(DisplayFraming.current.suffix)" : "WEEK")
.font(.system(size: 8, weight: .bold, design: .rounded))
.foregroundStyle(atomDim)
-
- Text(entry.isOk ? "\(entry.weeklyPct)%" : "--")
+
+ Text(entry.isOk ? "\(displayNumber(remaining: entry.weeklyPct))%" : "--")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(atomText)
}
@@ -479,8 +503,9 @@ private func relativeTime(_ date: Date) -> String {
struct AccessoryCircularView: View {
let entry: UsageEntry
+ private var shown: Int { displayNumber(remaining: entry.sessionPct) }
private var fraction: Double {
- let value = Double(entry.sessionPct) / 100.0
+ let value = Double(shown) / 100.0
guard entry.isOk else { return max(0, value) }
return max(0.01, value)
}
@@ -493,7 +518,7 @@ struct AccessoryCircularView: View {
.stroke(.white, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: -1) {
- Text("\(entry.sessionPct)")
+ Text("\(shown)")
.font(.system(size: 17, weight: .bold, design: .rounded))
.monospacedDigit()
Text("%")
@@ -518,7 +543,7 @@ struct AccessoryRectangularView: View {
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
.tracking(1.2)
- Text("\(entry.sessionPct)% left")
+ Text("\(displayNumber(remaining: entry.sessionPct))% \(DisplayFraming.current.lowerSuffix)")
.font(.system(size: 16, weight: .bold, design: .rounded))
.monospacedDigit()
if !entry.status.isEmpty {