Skip to content

feat: pace-based utilization tracking (#16) - #24

Merged
mpecan merged 12 commits into
mainfrom
feat/pace-tracking
Jul 19, 2026
Merged

feat: pace-based utilization tracking (#16)#24
mpecan merged 12 commits into
mainfrom
feat/pace-tracking

Conversation

@mpecan

@mpecan mpecan commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Ports pace-based utilization tracking from upstream eddmann/ClaudeMeter PR #33 — a pace ratio (usage_fraction / elapsed_time_fraction; 1.0 = exactly sustainable) surfaced in the tray and popover, so you can tell whether your current burn rate will exhaust a window early or leave paid capacity unused. Closes #16.

Implemented layer by layer (each layer went through an implement → 4-viewpoint review → remediate cycle, then a final whole-feature review):

  • meter-core — the pace math and numeric oracle: pace_ratio, expected_usage_percent (elapsed capped at the pacing span), projected_end_percent, projected_limit_date (bypasses the 5%-elapsed grace, honours the 5%-utilization floor), a PaceBand enum, and PaceKind/PaceSignal with the exact upstream tooltip strings. All 20 upstream PaceSignalTests ported and passing.
  • meter-render — a 4-band pace palette, flame (hot) / snowflake (cold) badge (pure black in template mode), and pace-first overrides across all six icon styles, with the palette band + rendered-ratio-string folded into the icon cache key.
  • src-tauriweekly_pace_days (5–7, clamped), pace_first_display, and pace_tracking_enabled settings with live-updating commands; the tray pace badge; and the Linux tray-menu ratio text (StatusNotifierItem has no tooltip).
  • frontendsrc/pacing.ts kept numerically identical to the Rust; the expected-by-now tick, pace/projection lines, verdict badge, snowflake, and the pace-first primary/secondary swap; plus Display-Mode + Weekly-Basis settings controls.

Enable / disable

A master pace_tracking_enabled switch (default on) with a checkbox at the top of Settings → Pace turns the whole feature on/off. When off: the popover computes no pace at all (ratio/expected/projection null, quota-first cards), the tray shows no pace ratio or badge, and the sub-controls hide. The sub-settings keep their stored values, so re-enabling restores the prior configuration.

Semantics & decisions

  • Pace ratio = min(utilization, 100) / expected%, expected = elapsed fraction of the pacing span capped at 1.0; None under 5% elapsed, but the limit-hit projection bypasses that grace while honouring the 5%-utilization floor.
  • pace_signal is headline-only (session + weekly, never scoped) and hybrid: overuse signals on either window (max wins), underuse only on the weekly window.
  • Weekly quota can be paced over a 5/6/7-day working week; the same basis applies to scoped weekly cards.
  • The flame/snowflake badge + "Used X% vs Y%" text are gated behind pace-first display; the popover's per-card ratio/projection compute whenever pace tracking is enabled.
  • The upstream oracle is PR feat: notification testing, reset-spam fix, and Spark Bolt icon #33's branch (main lacks the pace files) — the numbers/strings are pinned against it.

Verification

  • just check green: cargo fmt, clippy pedantic+nursery -D warnings, cargo test --workspace, file-size gate, cargo deny, cargo dupes (ceiling held), cargo llvm-cov (coverage 89% lines / 88% functions, above the floor), tsc --noEmit, and vitest (96 frontend tests).
  • Rust pacing.rs and src/pacing.ts verified numerically identical; the master-switch "no pace when disabled" behaviour is unit-tested.
  • CLAUDE.md documents the pace-tracking contract.

Notes for review

Worth eyeballing live (just dev on this branch): the pace-first tray icon, the popover projection lines, and the Settings → Pace enable/disable + Display-Mode/Weekly-Basis controls.

🤖 Generated with Claude Code

mpecan and others added 12 commits July 18, 2026 19:32
Port the pace-based utilization tracking from eddmann/ClaudeMeter PR #33
into meter-core (pure domain). Upstream's PaceSignalTests are the numeric
oracle; all 20 cases are ported as Rust tests.

- pacing.rs: add UNDERUSE_THRESHOLD (0.8), HEAVY_OVERUSE_THRESHOLD (2.5),
  MIN_USAGE_FOR_PROJECTION (5.0) and weekly_pacing_duration(days). Add
  UsageWindow::{expected_usage_percent, pace_ratio, projected_end_percent,
  projected_limit_date}, each threading pacing_duration: Option<SignedDuration>
  with elapsed measured against the pacing span (capped) for expected/ratio and
  against the full window for the projections. Add the 4-band PaceBand enum.
  Refactor PacingAssessment to delegate to pace_ratio (no duplicated math).
- pace_signal.rs: PaceKind {Hot, Cold} and PaceSignal with the exact-string
  tooltip() builder.
- snapshot.rs: UsageSnapshot::pace_signal(now, weekly_pace_days) implementing
  the hybrid rule over the headline windows only (scoped limits excluded, per
  upstream; at_risk keeps its scoped behaviour).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pace_signal tie-break now prefers the session (5-hour) signal on an
  exact ratio tie, matching Swift's max(by:) which keeps the first
  maximal element (hotSignals is built session-first).
- Cap utilization at 100 in src/pacing.ts's isAtRisk to restore
  numeric identity with meter_core::pacing::pace_ratio for >100% usage.
- Factor UsageWindow::elapsed_secs helper, de-duplicating the
  elapsed-time computation across expected_usage_percent,
  projected_end_percent and projected_limit_date.
- Merge the split jiff imports in snapshot.rs.
- Add regression tests: session-wins tie-break, >100% utilization cap
  (Rust + TS), and projected_end_percent's reset/grace guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the meter-render half of ClaudeMeter PR #33's pace-first display:

- IconState gains pace_kind, pace_band and pace_ratio. Eq/Hash are now
  hand-written: the f64 ratio is keyed by its one-decimal rendered form
  ("1.8") so a hair's nudge still hits the icon cache, while the colour
  band is keyed separately so ratios that round equal but straddle a band
  boundary (1.16 vs 1.24) never borrow each other's colour. `with_pace`
  derives the band from the ratio so the number and colour can't drift.
- palette.rs: the 4-band pace scale (blue #007AFF underuse / green
  sustainable / orange overuse / red heavy overuse, reusing the existing
  status greens/oranges/reds like PacePalette.swift), plus the shared
  override logic (primary_label, label_ink, override_color, draw_label)
  and a badge() dispatcher that draws a flame (hot) or snowflake (cold)
  in place of the at-risk dot. All ink, glyph included, stays pure black
  in monochrome/template mode.
- All six styles route their primary metric through the shared helpers:
  Battery/Minimal/DualBar/Circular swap the number for the ratio and
  recolour it; Segments recolours its active bars; Gauge recolours the
  dial. The subset font lacks "." and "×", so pace_label draws them as
  vector shapes and leaves the digit rendering byte-identical.
- Snapshots regenerated with seven pace cases; existing snapshots
  unchanged. Tests cover the flame/snowflake, the override text, the
  drawn punctuation and the band-straddle cache key.
- PaceKind/PaceBand gain Hash (needed for the cache key). src-tauri
  IconState literals get the three new None fields to keep the workspace
  compiling; the tray pace-signal wiring is a separate task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Key the IconCache off exactly what renders, not a parallel computation:

- pace_ratio_key now returns the same `format!("{ratio:.1}")` string the
  override text displays, instead of `(ratio*10.0).round() as i32`. The two
  disagree at `.x5` boundaries (0.85 renders "0.8" but scale-rounds to 9,
  colliding with 0.851's "0.9"), so two ratios that render different text in
  the same band could share a cache entry and show a stale ratio.
- Fold at_risk into the key only when pace_kind is None: palette::badge draws
  the at-risk dot solely in that case, so states differing only in at_risk
  under a flame/snowflake render byte-identical SVG and must share one entry.
- Add a circular.rs unit test pinning the asymmetric pace-first split (arc
  keeps the quota-status colour, only the centre number takes the pace band).
- Add a Scale::X2 pace snapshot (the production scale) so the flame badge's
  legibility at 44px is regression-guarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the src-tauri shell layer for issue #16's pace-based utilization
tracking: AppSettings gains weekly_pace_days (5-7, clamped, default 7) and
pace_first_display (default off), each with its own set_*/store_* command
pair mirroring the existing show_reset_time pattern. The tray build path
computes UsageSnapshot::pace_signal from the headline windows, gated behind
pace_first_display, and overlays the flame/snowflake badge plus the pace
ratio onto IconState; the "Used X% vs Y% expected" tooltip surfaces as an
extra tray-menu line since Linux's StatusNotifierItem has no tooltip.

commands.rs and tray/model.rs were split (commands/pace.rs, a
model_tests.rs test file) to stay under the file-size gate, and the new
pace settings share IconOptions/PaceOptions structs to stay under both the
too-many-arguments lint and the duplication ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_weekly_pace_days persisted the new 5/6/7 span but never pushed it
into the live tray: apply_state only reads the cached
weekly_pace_days/pace_first_display in TrayResources, which are only
ever written by tray::init and tray::set_pace_options (previously
called only from set_pace_first_display). The tray's own pace ratio,
badge and menu line -- the primary Linux surface -- kept computing
against the stale span indefinitely, contrary to the doc comment's
claim that it self-heals on the next scheduler tick. Both pace
commands now share a broadcast_and_push_pace helper that emits
settings-changed and calls tray::set_pace_options together, so a
weekly-pace-basis change takes effect immediately like every other
tray-visible setting. The resulting exact-duplicate pair between the
two thin command wrappers (Tauri requires each command as its own
named function) is acknowledged via .dupes-ignore.toml rather than
raising the ratchet ceiling.

Also split tray/model.rs's #[path]-based test module into a proper
tray/model/mod.rs + tray/model/spec.rs nested-module layout. Verified
via a real --lcov run that the #[path] trick (and, empirically, any
file literally named `tests.rs`/`*_tests.rs`) makes cargo-llvm-cov
silently drop that file's ~486 lines from coverage entirely; renaming
the leaf file away from that pattern restores its own SF/LF record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…is settings (#16)

Port the frontend layer of ClaudeMeter PR #33's pace-based utilization
tracking, in numeric lockstep with meter-core's pacing.rs and its Swift
oracle:

- pacing.ts: extend beyond isAtRisk with paceRatio, expectedUsagePercent,
  projectedEndPercent, projectedLimitDate, the PaceBand classifier and
  weeklyPacingDurationMs — same constants/formulas as pacing.rs, with
  vitest cases pinned to the same upstream oracle numbers.
- view-model.ts / render.ts: expected-by-now tick, pace line, projection
  line ("Hits limit ~1:10 PM, N before reset" / "On pace to end at ~X%
  (Y% unused)"), Overusing/Underusing/On Pace verdict badge, snowflake
  glyph, and the pace-first primary/secondary swap. Weekly and scoped
  cards pass the weekly pacing basis and showsUnderuse; the session card
  never signals underuse.
- types.ts / ipc.ts / settings: weekly_pace_days + pace_first_display on
  AppSettings (+ DEFAULT_SETTINGS and the setWeeklyPaceDays /
  setPaceFirstDisplay backends), a Display Mode (Consumption vs Pace) and
  Weekly Pace Basis (5/6/7) segmented control wired into settings-view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Key card pacing cadence off window.window rather than the synthetic card
id, so a scoped limit whose own window is a five-hour kind is paced over
its 5-hour window (like the session card) instead of the weekly span.

Fold buildViewModel's settings-derived trailing params into a CardOptions
options bag, and factor the three segmented-control click handlers into a
shared bindSegmented helper.

Add format.test.ts coverage for describeRemaining/formatHitTime and a
view-model.test.ts case exercising a scoped five-hour card's pace fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ace (#16)

In pace-first display the tray icon only overrode the quota % with a pace
ratio when the hybrid PaceSignal was hot/cold. For the common on-pace case
(no signal) it silently fell back to the raw quota percentage, so turning
on pace-first display looked identical to quota-first mode most of the time.

Mirror upstream MenuBarManager.updateIcon's fallback chain
`paceSignal?.ratio ?? session.paceRatio ?? weekly.paceRatio`: the primary
metric always becomes a ratio once pace math is meaningful, its band driving
the colour, while the flame/snowflake badge (pace_kind) and the menu's
pace-line tooltip stay gated on the hybrid signal being present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a pace_tracking_enabled setting (default on) with a checkbox at the top of
the Settings > Pace section that turns the whole feature on/off. When off:
the popover computes no pace at all (ratio/expected/projection all null, cards
render quota-first) and the Display-mode + Weekly-basis sub-controls hide; the
tray shows no pace ratio or flame/snowflake badge. The sub-settings keep their
stored values so re-enabling restores the prior configuration.

The tray folds the switch into its effective pace-first flag
(pace_tracking_enabled && pace_first_display) — no extra field threaded through
the tray. New command + store helper mirror the existing pace commands; the
frontend gates paceRatio/expected/projection in the view-model on the flag.
The macOS-only NSPopover/activation-policy code read as dead or const-eligible
on the Linux clippy leg (a local macOS `just check` can't see this — there
those paths are live):
- gate MAIN_WINDOW_LABEL / is_main_label to cfg(any(macos, test)) — only the
  macOS focus-loss handler and the unit test use them;
- make the non-macOS set_app_foreground stub a const fn;
- make configure_popover_window (and its call) macOS-only — its whole job is
  the NSPopover conversion, a no-op on Linux.

Pre-existing on main from the NSPopover merge; surfaces here via PR CI.
@mpecan
mpecan merged commit 3123c3e into main Jul 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pace-based utilization tracking (port ClaudeMeter PR #33)

1 participant