@@ -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.
5264struct 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.
64128fn 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+ }
0 commit comments