Skip to content

Commit 229c562

Browse files
HduSyclaude
andcommitted
feat: persist 100M-milestone state so celebrations survive restarts
Previously the milestone baseline lived only in memory and was re-baselined on every launch, so a 100M crossing that happened while the app wasn't running (it reads Claude's logs regardless) was silently lost. - Persist a snapshot {week_id, week_floor, month_id, month_floor} to the app data dir (~/Library/Application Support/tokenscope/milestones.json), which survives app restarts, reboots, and updates and isn't purged like Caches. - Load it on startup; the first run ever still baselines without celebrating pre-existing usage. - Catch-up: a crossing observed after the fact still fires once on the next observation. Period ids (week/month) distinguish a real crossing from a period reset, which re-baselines silently. - Extract the decision into a pure milestone_fire() with unit tests. - Bump version to 0.1.17. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b421d04 commit 229c562

4 files changed

Lines changed: 155 additions & 24 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tokenscope"
3-
version = "0.1.16"
3+
version = "0.1.17"
44
description = "Claude CLI token usage dashboard"
55
authors = ["you"]
66
edition = "2021"

src-tauri/src/lib.rs

Lines changed: 152 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,36 +45,104 @@ fn refresh(app: &tauri::AppHandle) {
4545
let _ = app.emit("dashboard-updated", &dash);
4646
}
4747

48-
/// 100M-token celebration state. `floors` holds the last-seen ⌊total/100M⌋ for
49-
/// (week, month); `None` until the first observation baselines it (so we never
50-
/// fire retroactively for usage that already happened before launch). `active`
51-
/// guards against overlapping celebrations.
48+
/// Persisted 100M-token milestone snapshot. Stored in the app *data* dir so it
49+
/// survives app restarts, reboots, and updates (which only replace the .app
50+
/// bundle, never the data dir). The per-period ids let us tell a real crossing
51+
/// from a period reset.
52+
#[derive(Clone, serde::Serialize, serde::Deserialize)]
53+
struct MilestoneState {
54+
week_id: String,
55+
week_floor: i64,
56+
month_id: String,
57+
month_floor: i64,
58+
}
59+
60+
/// 100M-token celebration tracking. `state` is the last persisted snapshot
61+
/// (`None` only before the very first observation ever, so the first run
62+
/// baselines without celebrating pre-existing usage). `active` guards against
63+
/// overlapping celebrations.
5264
struct Celebration {
53-
floors: std::sync::Mutex<Option<(i64, i64)>>,
65+
state: std::sync::Mutex<Option<MilestoneState>>,
5466
active: AtomicBool,
5567
}
5668

57-
/// Fire one full-screen celebration whenever the week OR month total crosses a
58-
/// new 100M-token boundary. We watch only week ∪ month, not day: today is always
59-
/// within both the current week and month, so a day crossing is already implied
60-
/// by the month — but a calendar week can straddle a month boundary, so early in
61-
/// a month the week total can lead the (freshly reset) month, hence both.
62-
/// Dedup is inherent: a single chunk of usage advancing both floors at once
63-
/// still calls celebrate() once.
69+
/// `~/Library/Application Support/tokenscope/milestones.json` (platform
70+
/// equivalent elsewhere). Deliberately the data dir, not the Caches dir the
71+
/// event store uses — Caches can be purged by the OS, milestones must not be.
72+
fn milestones_path() -> Option<std::path::PathBuf> {
73+
let dir = dirs::data_dir()?.join("tokenscope");
74+
let _ = std::fs::create_dir_all(&dir);
75+
Some(dir.join("milestones.json"))
76+
}
77+
78+
fn load_milestones() -> Option<MilestoneState> {
79+
let t = std::fs::read_to_string(milestones_path()?).ok()?;
80+
serde_json::from_str(&t).ok()
81+
}
82+
83+
fn save_milestones(m: &MilestoneState) {
84+
if let Some(p) = milestones_path() {
85+
if let Ok(t) = serde_json::to_string(m) {
86+
let _ = std::fs::write(p, t);
87+
}
88+
}
89+
}
90+
91+
/// Current calendar-week and calendar-month identifiers, matching parser.rs's
92+
/// period definitions (Monday-based week, calendar month), so a stored floor is
93+
/// only ever compared within the same period.
94+
fn period_ids() -> (String, String) {
95+
use chrono::Datelike;
96+
let d = chrono::Local::now().date_naive();
97+
let iso = d.iso_week();
98+
(
99+
format!("{}-W{:02}", iso.year(), iso.week()),
100+
format!("{}-{:02}", d.year(), d.month()),
101+
)
102+
}
103+
104+
/// Decide whether to celebrate: fire if either period advanced to a higher
105+
/// 100M floor *within the same period*. `None` (first ever observation) never
106+
/// fires. A period-id mismatch means that period reset, so it re-baselines
107+
/// silently rather than comparing floors. Returns a single bool, so a jump
108+
/// across several boundaries — or week and month advancing together — is one
109+
/// celebration.
110+
fn milestone_fire(prev: Option<&MilestoneState>, cur: &MilestoneState) -> bool {
111+
match prev {
112+
None => false,
113+
Some(p) => {
114+
(p.week_id == cur.week_id && cur.week_floor > p.week_floor)
115+
|| (p.month_id == cur.month_id && cur.month_floor > p.month_floor)
116+
}
117+
}
118+
}
119+
120+
/// Observe the latest totals, persist the snapshot, and celebrate on a new
121+
/// 100M-token milestone. We watch week ∪ month, not day: today is always within
122+
/// both the current week and month, so a day crossing is already implied by the
123+
/// month — but a calendar week can straddle a month boundary, so early in a
124+
/// month the week total can lead the (freshly reset) month, hence both. Because
125+
/// the snapshot is persisted, a crossing that happened while the app wasn't
126+
/// running (it reads the logs Claude writes regardless) still catches up on the
127+
/// next observation.
64128
fn check_milestones(app: &tauri::AppHandle, dash: &Dashboard) {
65129
let Some(state) = app.try_state::<Celebration>() else {
66130
return;
67131
};
68132
// total_tokens is already in millions, so a 100M milestone is total / 100.
69-
let wf = (dash.week.metrics.total_tokens / 100.0).floor() as i64;
70-
let mf = (dash.month.metrics.total_tokens / 100.0).floor() as i64;
71-
let mut g = state.floors.lock().unwrap();
72-
let fire = match *g {
73-
None => false, // first observation: baseline only, never celebrate
74-
Some((pw, pm)) => wf > pw || mf > pm,
133+
let (week_id, month_id) = period_ids();
134+
let cur = MilestoneState {
135+
week_id,
136+
week_floor: (dash.week.metrics.total_tokens / 100.0).floor() as i64,
137+
month_id,
138+
month_floor: (dash.month.metrics.total_tokens / 100.0).floor() as i64,
75139
};
76-
*g = Some((wf, mf)); // always update (also walks the baseline back down on a period reset)
140+
141+
let mut g = state.state.lock().unwrap();
142+
let fire = milestone_fire(g.as_ref(), &cur);
143+
*g = Some(cur.clone());
77144
drop(g);
145+
save_milestones(&cur);
78146
if fire {
79147
celebrate(app);
80148
}
@@ -404,9 +472,11 @@ pub fn run() {
404472
#[cfg(target_os = "macos")]
405473
app.manage(TrayAnchor(std::sync::Mutex::new(None)));
406474

407-
// 100M-token celebration tracking (baselined on first observation).
475+
// 100M-token celebration tracking. Load the persisted snapshot so
476+
// milestones survive restarts/reboots/updates; the first run ever
477+
// (no file) baselines on first observation without celebrating.
408478
app.manage(Celebration {
409-
floors: std::sync::Mutex::new(None),
479+
state: std::sync::Mutex::new(load_milestones()),
410480
active: AtomicBool::new(false),
411481
});
412482

@@ -566,3 +636,64 @@ pub fn run() {
566636
.run(tauri::generate_context!())
567637
.expect("error while running tauri application");
568638
}
639+
640+
#[cfg(test)]
641+
mod tests {
642+
use super::*;
643+
644+
fn ms(wk: &str, wf: i64, mo: &str, mf: i64) -> MilestoneState {
645+
MilestoneState {
646+
week_id: wk.into(),
647+
week_floor: wf,
648+
month_id: mo.into(),
649+
month_floor: mf,
650+
}
651+
}
652+
653+
#[test]
654+
fn first_ever_observation_baselines_without_firing() {
655+
// No prior snapshot → never celebrate pre-existing usage on first run.
656+
assert!(!milestone_fire(None, &ms("2026-W24", 3, "2026-06", 3)));
657+
}
658+
659+
#[test]
660+
fn no_change_does_not_fire() {
661+
let prev = ms("2026-W24", 1, "2026-06", 3);
662+
assert!(!milestone_fire(Some(&prev), &ms("2026-W24", 1, "2026-06", 3)));
663+
}
664+
665+
#[test]
666+
fn month_crossing_fires() {
667+
let prev = ms("2026-W24", 1, "2026-06", 3);
668+
assert!(milestone_fire(Some(&prev), &ms("2026-W24", 1, "2026-06", 4)));
669+
}
670+
671+
#[test]
672+
fn week_crossing_fires_even_when_month_flat() {
673+
// Early in a month the week (straddling from the previous month) can lead.
674+
let prev = ms("2026-W24", 0, "2026-06", 0);
675+
assert!(milestone_fire(Some(&prev), &ms("2026-W24", 1, "2026-06", 0)));
676+
}
677+
678+
#[test]
679+
fn multi_boundary_jump_is_a_single_fire() {
680+
// 3 → 7 is still one celebration (fire is a bool, not a count).
681+
let prev = ms("2026-W24", 1, "2026-06", 3);
682+
assert!(milestone_fire(Some(&prev), &ms("2026-W24", 1, "2026-06", 7)));
683+
}
684+
685+
#[test]
686+
fn new_month_rebaselines_silently() {
687+
// Period id changed → that period reset; re-baseline, don't compare floors
688+
// (so a new month opening below last month's floor never fires).
689+
let prev = ms("2026-W24", 1, "2026-06", 3);
690+
assert!(!milestone_fire(Some(&prev), &ms("2026-W27", 0, "2026-07", 0)));
691+
}
692+
693+
#[test]
694+
fn new_week_does_not_fire_on_reset() {
695+
let prev = ms("2026-W24", 2, "2026-06", 3);
696+
// New week (id changed), month unchanged and flat → no fire.
697+
assert!(!milestone_fire(Some(&prev), &ms("2026-W25", 0, "2026-06", 3)));
698+
}
699+
}

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Tokenscope",
4-
"version": "0.1.16",
4+
"version": "0.1.17",
55
"identifier": "com.tokenscope.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

0 commit comments

Comments
 (0)