feat: add persistent lifetime stats and sync achievements

- Add lifetime stats persistence to Rust backend
- Sync achievement state between frontend and backend on startup
- Add commands for loading/saving stats to disk
- Expand achievement definitions with 150+ new achievements
- Fix stats store to properly track total vs session metrics
This commit is contained in:
2026-01-25 20:06:36 -08:00
committed by Naomi Carrigan
parent 8a19f35922
commit 42c3b4ee83
7 changed files with 1462 additions and 106 deletions
+13
View File
@@ -102,6 +102,19 @@ pub async fn get_usage_stats(
manager.get_usage_stats(&conversation_id)
}
/// Load persisted lifetime stats from store (no bridge required)
#[tauri::command]
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
let mut stats = UsageStats::new();
// Load persisted stats if available
if let Some(persisted) = crate::stats::load_stats(&app).await {
stats.apply_persisted(persisted);
}
Ok(stats)
}
#[tauri::command]
pub async fn validate_directory(
path: String,
+1
View File
@@ -95,6 +95,7 @@ pub fn run() {
get_config,
save_config,
get_usage_stats,
get_persisted_stats,
load_saved_achievements,
answer_question,
send_windows_notification,
+106
View File
@@ -3,6 +3,7 @@ use chrono::{Local, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
@@ -241,6 +242,111 @@ pub struct StatsUpdateEvent {
pub stats: UsageStats,
}
/// Serializable struct for persisting only lifetime (total) stats
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedStats {
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cost_usd: f64,
pub messages_exchanged: u64,
pub code_blocks_generated: u64,
pub files_edited: u64,
pub files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
pub morning_sessions: u64,
pub night_sessions: u64,
pub last_session_date: Option<String>,
}
impl From<&UsageStats> for PersistedStats {
fn from(stats: &UsageStats) -> Self {
PersistedStats {
total_input_tokens: stats.total_input_tokens,
total_output_tokens: stats.total_output_tokens,
total_cost_usd: stats.total_cost_usd,
messages_exchanged: stats.messages_exchanged,
code_blocks_generated: stats.code_blocks_generated,
files_edited: stats.files_edited,
files_created: stats.files_created,
tools_usage: stats.tools_usage.clone(),
sessions_started: stats.sessions_started,
consecutive_days: stats.consecutive_days,
total_days_used: stats.total_days_used,
morning_sessions: stats.morning_sessions,
night_sessions: stats.night_sessions,
last_session_date: stats.last_session_date.clone(),
}
}
}
impl UsageStats {
/// Apply persisted stats to restore lifetime totals
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
self.total_input_tokens = persisted.total_input_tokens;
self.total_output_tokens = persisted.total_output_tokens;
self.total_cost_usd = persisted.total_cost_usd;
self.messages_exchanged = persisted.messages_exchanged;
self.code_blocks_generated = persisted.code_blocks_generated;
self.files_edited = persisted.files_edited;
self.files_created = persisted.files_created;
self.tools_usage = persisted.tools_usage;
self.sessions_started = persisted.sessions_started;
self.consecutive_days = persisted.consecutive_days;
self.total_days_used = persisted.total_days_used;
self.morning_sessions = persisted.morning_sessions;
self.night_sessions = persisted.night_sessions;
self.last_session_date = persisted.last_session_date;
}
}
/// Save lifetime stats to persistent store
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
let store = app.store("stats.json").map_err(|e| e.to_string())?;
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
+38 -3
View File
@@ -112,7 +112,7 @@ impl WslBridge {
return Err("Process already running".to_string());
}
// Load saved achievements when starting a new session
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
tauri::async_runtime::spawn(async move {
@@ -122,7 +122,17 @@ impl WslBridge {
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
stats.write().achievements = achievements;
println!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write();
stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats {
println!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted);
}
});
let working_dir = &options.working_dir;
@@ -440,6 +450,18 @@ impl WslBridge {
self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
// Save lifetime stats before resetting session
let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
println!("Saving stats on session stop...");
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
eprintln!("Failed to save stats: {}", e);
} else {
println!("Stats saved successfully on session stop");
}
});
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
@@ -733,10 +755,23 @@ fn process_json_line(
let current_stats = stats.read().clone();
let stats_event = StatsUpdateEvent {
stats: current_stats,
stats: current_stats.clone(),
};
let _ = app.emit("claude:stats", stats_event);
// Save stats periodically (every 10 messages to avoid excessive disk writes)
if current_stats.session_messages_exchanged.is_multiple_of(10)
&& current_stats.session_messages_exchanged > 0
{
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
println!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await {
eprintln!("Failed to save stats: {}", e);
}
});
}
// Only emit error results - success content is already sent via Assistant message
if subtype != "success" {
if let Some(text) = result {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -104,10 +104,11 @@ export async function initStatsListener() {
stats.set(newStats);
});
// Load initial stats from backend
// Load initial persisted stats from backend (no bridge required)
try {
const initialStats = await invoke<UsageStats>("get_usage_stats");
const initialStats = await invoke<UsageStats>("get_persisted_stats");
stats.set(initialStats);
console.log("Loaded persisted stats:", initialStats);
} catch (error) {
console.error("Failed to load initial stats:", error);
}
+173 -18
View File
@@ -14,34 +14,52 @@ export type AchievementId =
| "GrowingStrong" // 10,000 tokens
| "BlossomingCoder" // 100,000 tokens
| "TokenMaster" // 1,000,000 tokens
| "TokenBillionaire" // 10,000,000 tokens
| "TokenTreasure" // 50,000,000 tokens
// Code Generation
| "HelloWorld" // First code block
| "CodeWizard" // 100 code blocks
| "ThousandBlocks" // 1,000 code blocks
| "CodeFactory" // 5,000 code blocks
| "CodeEmpire" // 10,000 code blocks
// File Operations
| "FileManipulator" // 10 files edited
| "FileArchitect" // 100 files edited
| "FileEngineer" // 500 files edited
| "FileLegend" // 1,000 files edited
// Conversation milestones
| "ConversationStarter" // 10 messages
| "ChattyKathy" // 100 messages
| "Conversationalist" // 1,000 messages
| "ChatMarathon" // 5,000 messages
| "ChatLegend" // 10,000 messages
// Tool usage
| "Toolsmith" // 5 different tools
| "ToolMaster" // 10 different tools
// Time-based achievements
| "EarlyBird" // Started session 5-7 AM
| "NightOwl" // Coding after midnight
| "AllNighter" // Worked 2-5 AM
| "WeekendWarrior" // Coding on weekend
| "DedicatedDeveloper" // 30 days in a row
// Search and exploration
| "Explorer" // 50 searches
| "MasterSearcher" // 500 searches
// Session achievements
| "QuickSession" // Productive session < 5 min
| "FocusedWork" // 30 min session
| "DeepDive" // 2 hour session
| "MarathonSession" // 5+ hour session
| "UltraMarathon" // 8 hour session
| "CodingRetreat" // 12 hour session
// Special achievements
| "FirstMessage" // First message sent
| "FirstTool" // First tool used
@@ -51,28 +69,165 @@ export type AchievementId =
| "SpeedCoder" // 10 code blocks in 10 minutes
| "ClaudeConnoisseur" // Used all Claude models
| "MarathonCoder" // 10k tokens in one session
// Relationship & Greetings
| "GoodMorning" // Said good morning
| "GoodNight" // Said good night
| "ThankYou" // Said thank you
| "LoveYou" // Said love you
| "GoodMorning" // Say "good morning"
| "GoodNight" // Say "good night" or "goodnight"
| "ThankYou" // Say "thank you" or "thanks"
| "LoveYou" // Say "love you" or "ily"
| "HelloHikari" // Say "hello hikari" or "hi hikari"
| "HowAreYou" // Ask "how are you"
| "MissedYou" // Say "missed you"
| "BackAgain" // Say "i'm back" or "back again"
// Personality & Fun
| "EmojiUser" // Used 20+ emojis
| "CapsLock" // ALL CAPS MESSAGE
| "QuestionMaster" // Asked 50 questions
| "PleaseAndThankYou" // Polite user
| "EmojiUser" // Use an emoji in a message
| "QuestionMaster" // Use "?" in 20 messages
| "CapsLock" // Send a message in ALL CAPS
| "PleaseAndThankYou" // Use "please" in messages
// Emotional
| "Frustrated" // Say "frustrated" or "ugh" or "argh"
| "Excited" // Say "excited" or "yay" or "woohoo"
| "Confused" // Say "confused" or "don't understand"
| "Curious" // Ask "why" or "how does"
| "Impressed" // Say "wow" or "amazing" or "incredible"
// Git & Development
| "CommitMaster" // 100 commits
| "PRO" // Created 10 PRs
| "Reviewer" // Reviewed 10 PRs
| "IssueTracker" // Created 25 issues
| "GitGuru" // Used git commands
| "GitGuru" // Use git commands 10 times
| "TestWriter" // Create test files
| "Debugger" // Fix bugs (messages with "fix", "bug", "error")
| "CommitKing" // 50 commits
| "CommitLegend" // 200 commits
| "BranchMaster" // Create 10 branches
| "MergeExpert" // Merge 20 PRs
| "ConflictResolver" // Resolve merge conflicts
// Tool Mastery
| "BashMaster" // Used bash 100 times
| "FileExplorer" // Searched files 100 times
| "SearchExpert" // Advanced searches
| "AgentCommander" // Used task agents
| "MCPMaster"; // Used MCP tools
| "BashMaster" // Use Bash tool 50 times
| "FileExplorer" // Use Read tool 100 times
| "SearchExpert" // Use Grep tool 50 times
| "EditMaster" // Use Edit tool 100 times
| "WriteMaster" // Use Write tool 50 times
| "GlobMaster" // Use Glob tool 100 times
| "TaskMaster" // Use Task tool 50 times
| "WebFetcher" // Use WebFetch tool 20 times
| "McpExplorer" // Use MCP tools 50 times
// Daily Streaks
| "WeekStreak" // 7 days in a row
| "TwoWeekStreak" // 14 days in a row
| "MonthStreak" // 30 days in a row
| "QuarterStreak" // 90 days in a row
// Time Challenges
| "MorningPerson" // 10 sessions started before 9 AM
| "NightCoder" // 10 sessions after 10 PM
| "LunchBreakCoder" // Session during 12-1 PM
| "CoffeeTime" // Session during 3-4 PM
// Day-specific
| "MondayMotivation" // Coding on Monday
| "FridayFinisher" // Coding on Friday
| "HumpDay" // Coding on Wednesday
// Seasonal/Special Times
| "NewYearCoder" // Coding on January 1st
| "ValentinesDev" // Coding on February 14th
| "SpookyCode" // Coding on October 31st
| "HolidayCoder" // Coding on December 25th
| "LeapDayCoder" // Coding on February 29th
// Message Content
| "LongMessage" // Send a message over 500 characters
| "NovelWriter" // Send a message over 2000 characters
| "ShortAndSweet" // Complete a task with messages under 50 chars each
| "CodeInMessage" // Include code block in user message
| "MarkdownMaster" // Use markdown formatting in message
// Programming Languages
| "RustDeveloper" // Generate Rust code
| "PythonDeveloper" // Generate Python code
| "JavaScriptDev" // Generate JavaScript code
| "TypeScriptDev" // Generate TypeScript code
| "GoDeveloper" // Generate Go code
| "CppDeveloper" // Generate C++ code
| "JavaDeveloper" // Generate Java code
| "HtmlCssDev" // Generate HTML/CSS code
| "SqlDeveloper" // Generate SQL code
| "ShellScripter" // Generate shell/bash scripts
| "FullStackDev" // Generate code in 10+ languages
// Project Types
| "FrontendDev" // Work on frontend files
| "BackendDev" // Work on backend files
| "ConfigEditor" // Edit config files
| "DocWriter" // Edit documentation
// Error Handling
| "ErrorHunter" // Fix 10 errors
| "ExceptionSlayer" // Fix 50 errors
| "BugExterminator" // Fix 100 bugs
// Refactoring
| "CleanCoder" // Refactor code
| "Optimizer" // Optimize performance
| "Simplifier" // Simplify complex code
// Testing
| "TestNovice" // Write 10 tests
| "TestEnthusiast" // Write 50 tests
| "TestMaster" // Write 100 tests
| "CoverageKing" // Achieve test coverage mentions
// Documentation
| "Documenter" // Write documentation
| "CommentWriter" // Add comments to code
| "ReadmeHero" // Create/edit README files
// API & Integration
| "ApiExplorer" // Work with APIs
| "DatabaseDev" // Work with databases
| "CloudCoder" // Work with cloud services
// Special Milestones
| "CenturyClub" // 100 sessions
| "ThousandSessions" // 1000 sessions
| "Veteran" // Used Hikari for 30+ days total
| "OldTimer" // Used Hikari for 90+ days total
| "Loyalist" // Used Hikari for 365+ days total
// Fun & Easter Eggs
| "Perfectionist" // Redo something 5 times
| "Persistent" // Ask same question 3 times
| "Patient" // Wait for long response
| "Speedy" // Send 10 messages in 1 minute
// UI Exploration
| "MultiTasker" // Multiple conversations
| "Minimalist" // Use compact mode
| "PrivacyFirst" // Use streamer mode
| "ThemeChanger" // Change themes
| "SettingsTweaker" // Adjust settings
// Achievement Meta
| "AchievementHunter" // Unlock 25 achievements
| "Completionist" // Unlock 50 achievements
| "MasterUnlocker" // Unlock 100 achievements
| "PlatinumStatus" // Unlock all achievements
// Clipboard & Snippets
| "ClipboardCollector" // Use clipboard history
| "SnippetCreator" // Create a snippet
| "SnippetMaster" // Create 20 snippets
// Other Features
| "QuickActionUser" // Use quick actions
| "HistoryBuff" // Browse history
| "Archivist" // Export sessions
| "SessionExporter" // Export a session
| "GitPanelUser" // Use Git panel
| "FeatureExplorer"; // Use 10+ features
export interface Achievement {
id: AchievementId;