Skip to content

Commit d99a241

Browse files
authored
feat: add reset time formatting (#1)
Add configurable reset-time rendering for provider, local, and UTC modes. Wire the setting through state.json, CODEXBAR_RESET_TIME_FORMAT, the Waybar tooltip, and the GTK settings view. Keep provider output as the default for upgrade compatibility. Also preserve cached provider snapshots when a requested provider returns empty, invalid, or errored output, so transient refresh failures do not make providers disappear from the tooltip.
1 parent bdabf7f commit d99a241

4 files changed

Lines changed: 216 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Reset time format picker.** A new `resetTimeFormat` setting (`provider`,
12+
`local`, or `utc`) controls how the "Resets ..." line in both the tooltip and
13+
the popover is rendered. Provider keeps the upstream string as-is (default,
14+
no behavior change on upgrade); Local reformats `resetsAt` in the system
15+
timezone with an explicit TZ suffix; UTC does the same with a literal "UTC".
16+
All three modes tier the format by proximity (today drops the date,
17+
this-year drops the year). Configurable from the popover's Settings view,
18+
via `~/.config/codexbar-waybar/state.json`, or per-instance with the
19+
`CODEXBAR_RESET_TIME_FORMAT` env var. Useful when the provider's
20+
description is timezone-ambiguous -- for example Codex emits `"7:10 AM"`
21+
for a `resetsAt` that's actually in UTC.
22+
1023
## [0.2.1] — 2026-05-30
1124

1225
### Fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ definition or your shell profile.
161161
| `CODEXBAR_PROVIDERS` | from `config.json` | Space-separated provider IDs to query, bypassing `~/.codexbar/config.json`. Set per-Waybar instance if you want different sets per monitor. |
162162
| `CODEXBAR_BAR_PROVIDER` | from `state.json` | Pin a specific provider's session/weekly to the bar regardless of state. Set to a provider ID, or unset for `Highest`. |
163163
| `CODEXBAR_ANTIGRAVITY_CREDS` | `~/.gemini/oauth_creds.json` | Path to the Antigravity Google OAuth creds (written by `agy login`) the wrapper feeds to the CLI. |
164+
| `CODEXBAR_RESET_TIME_FORMAT` | from `state.json` | Reset-time rendering mode: `provider` (use the provider's `resetDescription` as-is, default), `local` (reformat `resetsAt` in the system timezone with a TZ suffix), or `utc` (same tiering, in UTC). The popover's Settings view exposes the same toggle. |
164165
| `CODEXBAR_LAYER_SHELL_LIB` | auto-detected | Override path to `libgtk4-layer-shell.so` if your distro stashes it somewhere unusual. |
165166
| `XDG_CACHE_HOME` | `~/.cache` | Where `last.json` snapshots live. |
166167
| `XDG_DATA_HOME` | `~/.local/share` | Where provider icons live (under `codexbar-waybar/icons/`). |

codexbar-popup.py

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from __future__ import annotations
1414

15+
import datetime
1516
import json
1617
import os
1718
import signal
@@ -378,6 +379,10 @@ def make_icon(pid: str, size: int = 18) -> Gtk.Widget | None:
378379
_RESET_SPACE_BEFORE_PAREN = re.compile(r"(?<=\S)\(")
379380
_RESET_SPACE_AFTER_COMMA = re.compile(r",(?=\S)")
380381
_RESET_SPACE_BEFORE_AMPM = re.compile(r"(?<=\d)(?=[AaPp][Mm]\b)")
382+
_RESET_STARTS_WITH_RESETS = re.compile(r"^[Rr]esets")
383+
_RESET_RELATIVE = re.compile(r"^[Rr]esets in ")
384+
385+
RESET_FORMATS = ("provider", "local", "utc")
381386

382387

383388
def normalize_reset_description(text: str) -> str:
@@ -393,6 +398,61 @@ def normalize_reset_description(text: str) -> str:
393398
return text
394399

395400

401+
def current_reset_format(state: dict | None = None) -> str:
402+
"""Resolve the active reset time format. Env var overrides state.json;
403+
unknown values fall back to `provider` (current behavior)."""
404+
env = os.environ.get("CODEXBAR_RESET_TIME_FORMAT")
405+
if env in RESET_FORMATS:
406+
return env
407+
if state is None:
408+
state = load_state()
409+
value = state.get("resetTimeFormat")
410+
return value if value in RESET_FORMATS else "provider"
411+
412+
413+
def _from_description(desc: str) -> str:
414+
if not desc:
415+
return ""
416+
return desc if _RESET_STARTS_WITH_RESETS.match(desc) else f"Resets {desc}"
417+
418+
419+
def format_reset_label(window: dict, mode: str) -> str:
420+
"""Render the reset label for a usage window in the chosen format.
421+
422+
Mirrors `reset_phrase` in codexbar.sh so the popover and tooltip never
423+
drift. Returns a string like "Resets 6:12 PM CDT" or "" for no info.
424+
Relative phrases ("Resets in 2 hours") are preserved even in absolute
425+
modes, since "in 2 hours" is more useful than a wall-clock time.
426+
"""
427+
clean = normalize_reset_description(window.get("resetDescription") or "")
428+
from_desc = _from_description(clean)
429+
if mode == "provider":
430+
return from_desc
431+
if _RESET_RELATIVE.match(clean):
432+
return from_desc
433+
resets_at = window.get("resetsAt")
434+
if not resets_at:
435+
return from_desc
436+
try:
437+
ts = datetime.datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
438+
except (TypeError, ValueError):
439+
return from_desc
440+
if mode == "utc":
441+
ts = ts.astimezone(datetime.timezone.utc)
442+
tz_suffix = "UTC"
443+
else:
444+
ts = ts.astimezone()
445+
tz_suffix = ts.tzname() or ""
446+
now = datetime.datetime.now(ts.tzinfo)
447+
if ts.date() == now.date():
448+
body = ts.strftime("%-I:%M %p")
449+
elif ts.year == now.year:
450+
body = ts.strftime("%b %-d at %-I:%M %p")
451+
else:
452+
body = ts.strftime("%b %-d %Y at %-I:%M %p")
453+
return f"Resets {body} {tz_suffix}".rstrip()
454+
455+
396456
def fetch_fresh() -> list:
397457
try:
398458
subprocess.run([str(WRAPPER)], check=False, capture_output=True, timeout=30)
@@ -701,6 +761,22 @@ def _render_settings_body(self):
701761
# Divider between sections.
702762
self.body.append(self._divider())
703763

764+
# --- Section: reset time format ---
765+
reset_title = Gtk.Label(label="Reset times", xalign=0.0)
766+
reset_title.add_css_class("codexbar-section-title")
767+
self.body.append(reset_title)
768+
reset_hint = Gtk.Label(
769+
label="How to render the “Resets …” label. Provider keeps the raw "
770+
"string each backend emits; Local/UTC reformat the reset "
771+
"timestamp with an explicit timezone.",
772+
xalign=0.0, wrap=True, max_width_chars=44)
773+
reset_hint.add_css_class("codexbar-subtitle")
774+
self.body.append(reset_hint)
775+
self.body.append(self._build_reset_format_picker())
776+
777+
# Divider between sections.
778+
self.body.append(self._divider())
779+
704780
# --- Section: enabled providers ---
705781
section_title = Gtk.Label(label="Providers", xalign=0.0)
706782
section_title.add_css_class("codexbar-section-title")
@@ -779,6 +855,36 @@ def _on_bar_provider_change(self, pid: str | None):
779855
# Nudge waybar so the bar text updates immediately.
780856
subprocess.Popen(["pkill", "-RTMIN+8", "waybar"])
781857

858+
def _build_reset_format_picker(self) -> Gtk.Widget:
859+
wrap = Gtk.FlowBox()
860+
wrap.add_css_class("codexbar-bar-picker")
861+
wrap.set_selection_mode(Gtk.SelectionMode.NONE)
862+
wrap.set_homogeneous(False)
863+
wrap.set_max_children_per_line(8)
864+
current = current_reset_format()
865+
labels = (("provider", "Provider"), ("local", "Local"), ("utc", "UTC"))
866+
for value, label in labels:
867+
classes = ["codexbar-tab"]
868+
if value == current:
869+
classes.append("active")
870+
wrap.append(self._make_pill(
871+
label, classes,
872+
lambda v=value: self._on_reset_format_change(v)))
873+
return wrap
874+
875+
def _on_reset_format_change(self, value: str):
876+
state = load_state()
877+
if value == "provider":
878+
state.pop("resetTimeFormat", None)
879+
else:
880+
state["resetTimeFormat"] = value
881+
save_state(state)
882+
# Re-render the Settings view so the chip highlight tracks the click,
883+
# and signal waybar so the tooltip picks up the new format on its
884+
# next refresh.
885+
self.render()
886+
subprocess.Popen(["pkill", "-RTMIN+8", "waybar"])
887+
782888
def _settings_row(self, pid: str, enabled: bool, *, enabled_ui: bool) -> Gtk.Widget:
783889
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
784890
row.add_css_class("codexbar-settings-row")
@@ -925,9 +1031,8 @@ def _section(self, title: str, window: dict) -> Gtk.Widget:
9251031
left.add_css_class("codexbar-section-detail-left")
9261032
details.append(left)
9271033

928-
reset = normalize_reset_description(window.get("resetDescription") or "")
929-
if reset:
930-
reset_text = reset if reset.lower().startswith("reset") else f"Resets {reset}"
1034+
reset_text = format_reset_label(window, current_reset_format())
1035+
if reset_text:
9311036
r = Gtk.Label(label=reset_text, xalign=1.0)
9321037
r.add_css_class("codexbar-section-detail-right")
9331038
details.append(r)

codexbar.sh

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,25 @@ mkdir -p "$CACHE_DIR"
2323
# Per-instance bar provider selection (written by the popup's Settings view).
2424
# `null` (or unset) means "show the highest used% across providers".
2525
BAR_PROVIDER=""
26+
# How to render reset times in the tooltip / popover:
27+
# provider — use the provider's `resetDescription` as-is (default; backward
28+
# compatible — Claude OAuth gives "May 17 at 6:20AM" with no TZ,
29+
# Codex gives "7:10 AM" in UTC with no TZ marker, etc.)
30+
# local — format `resetsAt` (ISO 8601 UTC) in the system local timezone
31+
# with a today / this-year / future-year tier and a TZ suffix
32+
# utc — same tiering, formatted in UTC with a literal "UTC" suffix
33+
RESET_TIME_FORMAT="provider"
2634
if [[ -f "$STATE_PATH" ]] && command -v jq >/dev/null 2>&1; then
2735
BAR_PROVIDER="$(jq -r '.barProvider // empty' "$STATE_PATH" 2>/dev/null)"
36+
rf="$(jq -r '.resetTimeFormat // empty' "$STATE_PATH" 2>/dev/null)"
37+
[[ -n "$rf" ]] && RESET_TIME_FORMAT="$rf"
2838
fi
2939
[[ -n "${CODEXBAR_BAR_PROVIDER:-}" ]] && BAR_PROVIDER="$CODEXBAR_BAR_PROVIDER"
40+
[[ -n "${CODEXBAR_RESET_TIME_FORMAT:-}" ]] && RESET_TIME_FORMAT="$CODEXBAR_RESET_TIME_FORMAT"
41+
case "$RESET_TIME_FORMAT" in
42+
provider|local|utc) ;;
43+
*) RESET_TIME_FORMAT="provider" ;;
44+
esac
3045

3146
# Read enabled providers from the codexbar CLI's config, fall back to a
3247
# sensible default if it's missing or unreadable. Override with the
@@ -204,27 +219,52 @@ for p in "${PROVIDERS[@]}"; do
204219
done
205220
merged+="]"
206221

207-
# Fill in errored providers with the last successful snapshot (marks them stale).
208-
if [[ -f "$LAST_GOOD" ]] && [[ "$merged" != "[]" ]]; then
209-
merged="$(jq -c --argjson prev "$(cat "$LAST_GOOD")" '
222+
# Persist fresh successful provider snapshots without dropping older successful
223+
# entries for providers that failed this refresh.
224+
if [[ "$merged" != "[]" ]] && echo "$merged" | jq -e 'any(.error | not)' >/dev/null 2>&1; then
225+
if [[ -f "$LAST_GOOD" ]]; then
226+
merged_for_cache="$(jq -c --argjson prev "$(cat "$LAST_GOOD")" '
227+
([$prev[]? | select((.error | not) and (.stale != true))
228+
| {key: .provider, value: .}] | from_entries) as $ok_prev
229+
| ([.[]? | select(.error | not)
230+
| {key: .provider, value: (del(.stale))}] | from_entries) as $ok_fresh
231+
| ($ok_prev + $ok_fresh) | [.[]]
232+
' <<< "$merged")"
233+
echo "$merged_for_cache" > "$LAST_GOOD"
234+
else
235+
echo "$merged" | jq -c 'map(select(.error | not) | del(.stale))' > "$LAST_GOOD"
236+
fi
237+
fi
238+
239+
# Fill in errored or missing providers with the last successful snapshot for
240+
# display (marks them stale). Missing happens when a provider returns
241+
# empty/invalid output instead of a JSON array.
242+
if [[ -f "$LAST_GOOD" ]]; then
243+
providers_json="$(printf '%s\n' "${PROVIDERS[@]}" | jq -R . | jq -s .)"
244+
merged="$(jq -c \
245+
--argjson prev "$(cat "$LAST_GOOD")" \
246+
--argjson requested "$providers_json" '
210247
([$prev[]? | select(.error | not) | {key: .provider, value: .}] | from_entries) as $ok_prev
248+
| (map(.provider) | unique) as $seen
211249
| map(if .error and $ok_prev[.provider]
212250
then $ok_prev[.provider] + {stale: true}
213-
else . end)
251+
else . end) as $current
252+
| ($requested
253+
| map(. as $pid | select(($seen | index($pid)) | not))
254+
| map($ok_prev[.]? | select(. != null) + {stale: true})) as $missing
255+
| $current + $missing
214256
' <<< "$merged")"
215257
fi
216258

217-
# Persist snapshot if at least one provider succeeded.
218-
if [[ "$merged" != "[]" ]] && echo "$merged" | jq -e 'any(.error | not)' >/dev/null 2>&1; then
219-
echo "$merged" > "$LAST_GOOD"
220-
fi
221-
222259
if [[ "$merged" == "[]" ]]; then
223260
printf '{"text":"","tooltip":"CodexBar: no provider data","class":"stale","percentage":0}\n'
224261
exit 0
225262
fi
226263

227-
echo "$merged" | jq -c --arg now "$(date -u +%FT%TZ)" --arg bar_provider "$BAR_PROVIDER" '
264+
echo "$merged" | jq -c \
265+
--arg now "$(date -u +%FT%TZ)" \
266+
--arg bar_provider "$BAR_PROVIDER" \
267+
--arg reset_format "$RESET_TIME_FORMAT" '
228268
# Collect all usage windows across providers
229269
def provider_name(p):
230270
{codex:"Codex", claude:"Claude", gemini:"Gemini",
@@ -242,16 +282,54 @@ echo "$merged" | jq -c --arg now "$(date -u +%FT%TZ)" --arg bar_provider "$BAR_P
242282
| gsub(",(?=\\S)"; ", ")
243283
| gsub("(?<=\\d)(?=[AaPp][Mm]\\b)"; " ");
244284
245-
def reset_phrase(d):
246-
if d == null then ""
247-
else (d | normalize_reset) as $clean
248-
| if ($clean | test("^[Rr]esets")) then " — \($clean)"
249-
else " — resets \($clean)" end
285+
# Format a UNIX timestamp in the system local timezone, tiered by how far
286+
# away the reset is. Today drops the date; this year drops the year.
287+
def fmt_local_ts:
288+
. as $ts
289+
| ($ts | strflocaltime("%Y-%m-%d")) as $rd
290+
| (now | strflocaltime("%Y-%m-%d")) as $td
291+
| ($ts | strflocaltime("%Y")) as $ry
292+
| (now | strflocaltime("%Y")) as $cy
293+
| if $rd == $td then ($ts | strflocaltime("%-I:%M %p %Z"))
294+
elif $ry == $cy then ($ts | strflocaltime("%b %-d at %-I:%M %p %Z"))
295+
else ($ts | strflocaltime("%b %-d %Y at %-I:%M %p %Z")) end;
296+
297+
# Same tiering, formatted in UTC. We hardcode the "UTC" suffix because
298+
# `strftime("%Z")` after `gmtime` is empty on some libc builds.
299+
def fmt_utc_ts:
300+
. as $ts
301+
| ($ts | gmtime | strftime("%Y-%m-%d")) as $rd
302+
| (now | gmtime | strftime("%Y-%m-%d")) as $td
303+
| ($ts | gmtime | strftime("%Y")) as $ry
304+
| (now | gmtime | strftime("%Y")) as $cy
305+
| if $rd == $td then ($ts | gmtime | strftime("%-I:%M %p UTC"))
306+
elif $ry == $cy then ($ts | gmtime | strftime("%b %-d at %-I:%M %p UTC"))
307+
else ($ts | gmtime | strftime("%b %-d %Y at %-I:%M %p UTC")) end;
308+
309+
# Build the trailing " — resets …" fragment for a usage window. Mode is
310+
# one of "provider" (preserve the provider string), "local", or "utc".
311+
# Relative phrases like "Resets in 2 hours" are kept verbatim even in
312+
# absolute modes — "in 2 hours" is more useful than a wall-clock time.
313+
def reset_phrase(w):
314+
if w == null then ""
315+
else (w.resetDescription // "" | normalize_reset) as $clean
316+
| (if $clean == "" then ""
317+
elif ($clean | test("^[Rr]esets")) then " — \($clean)"
318+
else " — resets \($clean)" end) as $from_desc
319+
| if $reset_format == "provider" then $from_desc
320+
elif ($clean | test("^[Rr]esets in ")) then $from_desc
321+
elif w.resetsAt != null then
322+
(try (w.resetsAt | fromdateiso8601) catch null) as $ts
323+
| if $ts == null then $from_desc
324+
elif $reset_format == "utc" then " — resets \($ts | fmt_utc_ts)"
325+
else " — resets \($ts | fmt_local_ts)" end
326+
else $from_desc
327+
end
250328
end;
251329
252330
def fmt_window(w; name):
253331
if w == null or w.usedPercent == null then empty
254-
else "\(name): \(w.usedPercent)%" + reset_phrase(w.resetDescription)
332+
else "\(name): \(w.usedPercent)%" + reset_phrase(w)
255333
end;
256334
257335
def provider_lines(entry):

0 commit comments

Comments
 (0)