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
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ PLIST
<string>$PYTHON</string>
<string>$SCRAPE_SCRIPT</string>
</array>
<key>StartInterval</key><integer>60</integer>
<key>StartInterval</key><integer>15</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>$HOME/Library/Logs/codexmeter-claude-scrape.log</string>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/codexmeter-claude-scrape.err.log</string>
Expand Down
36 changes: 32 additions & 4 deletions ios/CodexMeterApp/CodexMeterApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}

Expand Down
67 changes: 46 additions & 21 deletions ios/CodexMeterApp/CodexMeterAppWidget/UsageWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct UsageProvider: TimelineProvider {
func getTimeline(in context: Context, completion: @escaping (Timeline<UsageEntry>) -> 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)))
}
}
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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("%")
Expand All @@ -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 {
Expand Down
Loading