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 {