generated from nhcarrigan/template
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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, ¤t_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 {
|
||||
|
||||
+1128
-83
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user