From b691a91c534ad3bec6aae29542818d4adce755fe Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 19 Jan 2026 18:32:46 -0800 Subject: [PATCH] feat: achievements --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/achievements.rs | 604 ++++++++++++++++++ src-tauri/src/commands.rs | 21 + src-tauri/src/lib.rs | 3 + src-tauri/src/stats.rs | 36 +- src-tauri/src/wsl_bridge.rs | 95 ++- .../components/AchievementNotification.svelte | 154 +++++ src/lib/components/AchievementsPanel.svelte | 223 +++++++ src/lib/components/StatusBar.svelte | 24 + src/lib/stores/achievements.ts | 445 +++++++++++++ src/lib/tauri.ts | 4 + src/lib/types/achievements.ts | 71 ++ src/routes/+page.svelte | 7 +- 14 files changed, 1687 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/achievements.rs create mode 100644 src/lib/components/AchievementNotification.svelte create mode 100644 src/lib/components/AchievementsPanel.svelte create mode 100644 src/lib/stores/achievements.ts create mode 100644 src/lib/types/achievements.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a95f0e3..8632aab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -442,8 +442,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1411,6 +1413,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hikari-desktop" version = "0.1.0" dependencies = [ + "chrono", "parking_lot", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cb622d0..e377513 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-store = "2.4.2" tauri-plugin-notification = "2" tauri-plugin-os = "2" tempfile = "3" +chrono = { version = "0.4.43", features = ["serde"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs new file mode 100644 index 0000000..5497597 --- /dev/null +++ b/src-tauri/src/achievements.rs @@ -0,0 +1,604 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use chrono::{DateTime, Utc, Timelike, Datelike}; +use tauri_plugin_store::StoreExt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum AchievementId { + // Token Milestones + FirstSteps, // 1,000 tokens + GrowingStrong, // 10,000 tokens + BlossomingCoder, // 100,000 tokens + TokenMaster, // 1,000,000 tokens + + // Code Generation + HelloWorld, // First code block + CodeWizard, // 100 code blocks + ThousandBlocks, // 1,000 code blocks + + // File Operations + FileManipulator, // 10 files edited + FileArchitect, // 100 files edited + + // Conversation milestones + ConversationStarter, // 10 messages + ChattyKathy, // 100 messages + Conversationalist, // 1,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 + + // Special achievements + FirstMessage, // First message sent + FirstTool, // First tool used + FirstCodeBlock, // First code generated + FirstFileEdit, // First file edit + Polyglot, // 5+ languages in one session + SpeedCoder, // 10 code blocks in 10 minutes + ClaudeConnoisseur, // Used all Claude models + MarathonCoder, // 10k tokens in one session +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Achievement { + pub id: AchievementId, + pub name: String, + pub description: String, + pub icon: String, + pub unlocked_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AchievementProgress { + pub unlocked: HashSet, + pub newly_unlocked: Vec, // Achievements unlocked but not yet notified + #[serde(skip)] + pub session_start: Option>, +} + +impl AchievementProgress { + pub fn new() -> Self { + Self { + unlocked: HashSet::new(), + newly_unlocked: Vec::new(), + session_start: None, + } + } + + pub fn unlock(&mut self, achievement: AchievementId) -> bool { + if self.unlocked.insert(achievement.clone()) { + self.newly_unlocked.push(achievement); + true + } else { + false + } + } + + pub fn take_newly_unlocked(&mut self) -> Vec { + std::mem::take(&mut self.newly_unlocked) + } + + pub fn is_unlocked(&self, achievement: &AchievementId) -> bool { + self.unlocked.contains(achievement) + } + + pub fn start_session(&mut self) { + self.session_start = Some(Utc::now()); + } +} + +impl Default for AchievementProgress { + fn default() -> Self { + Self::new() + } +} + +pub fn get_achievement_info(id: &AchievementId) -> Achievement { + match id { + // Token Milestones + AchievementId::FirstSteps => Achievement { + id: id.clone(), + name: "First Steps!".to_string(), + description: "Used 1,000 tokens".to_string(), + icon: "🌱".to_string(), + unlocked_at: None, + }, + AchievementId::GrowingStrong => Achievement { + id: id.clone(), + name: "Growing Strong!".to_string(), + description: "Used 10,000 tokens".to_string(), + icon: "🌸".to_string(), + unlocked_at: None, + }, + AchievementId::BlossomingCoder => Achievement { + id: id.clone(), + name: "Blossoming Coder!".to_string(), + description: "Used 100,000 tokens".to_string(), + icon: "🌺".to_string(), + unlocked_at: None, + }, + AchievementId::TokenMaster => Achievement { + id: id.clone(), + name: "Token Master!".to_string(), + description: "Used 1,000,000 tokens".to_string(), + icon: "🌟".to_string(), + unlocked_at: None, + }, + + // Code Generation + AchievementId::HelloWorld => Achievement { + id: id.clone(), + name: "Hello World!".to_string(), + description: "Generated your first code block".to_string(), + icon: "📝".to_string(), + unlocked_at: None, + }, + AchievementId::CodeWizard => Achievement { + id: id.clone(), + name: "Code Wizard!".to_string(), + description: "Generated 100 code blocks".to_string(), + icon: "🎯".to_string(), + unlocked_at: None, + }, + AchievementId::ThousandBlocks => Achievement { + id: id.clone(), + name: "Thousand Blocks".to_string(), + description: "1,000 code blocks! You're a code machine!".to_string(), + icon: "🏗️".to_string(), + unlocked_at: None, + }, + + // File Operations + AchievementId::FileManipulator => Achievement { + id: id.clone(), + name: "File Manipulator".to_string(), + description: "Edited 10 files".to_string(), + icon: "📝".to_string(), + unlocked_at: None, + }, + AchievementId::FileArchitect => Achievement { + id: id.clone(), + name: "File Architect".to_string(), + description: "Created or edited 100 files".to_string(), + icon: "🏛️".to_string(), + unlocked_at: None, + }, + + // Conversation milestones + AchievementId::ConversationStarter => Achievement { + id: id.clone(), + name: "Conversation Starter".to_string(), + description: "Exchanged 10 messages".to_string(), + icon: "💬".to_string(), + unlocked_at: None, + }, + AchievementId::ChattyKathy => Achievement { + id: id.clone(), + name: "Chatty Kathy".to_string(), + description: "100 messages exchanged".to_string(), + icon: "🗣️".to_string(), + unlocked_at: None, + }, + AchievementId::Conversationalist => Achievement { + id: id.clone(), + name: "Master Conversationalist".to_string(), + description: "1,000 messages! We're really connecting!".to_string(), + icon: "💖".to_string(), + unlocked_at: None, + }, + + // Tool usage + AchievementId::Toolsmith => Achievement { + id: id.clone(), + name: "Toolsmith".to_string(), + description: "Used 5 different tools".to_string(), + icon: "🔨".to_string(), + unlocked_at: None, + }, + AchievementId::ToolMaster => Achievement { + id: id.clone(), + name: "Tool Master".to_string(), + description: "Used 10 different tools efficiently".to_string(), + icon: "🛠️".to_string(), + unlocked_at: None, + }, + + // Time-based achievements + AchievementId::EarlyBird => Achievement { + id: id.clone(), + name: "Early Bird".to_string(), + description: "Started a session between 5 AM and 7 AM".to_string(), + icon: "🌅".to_string(), + unlocked_at: None, + }, + AchievementId::NightOwl => Achievement { + id: id.clone(), + name: "Night Owl".to_string(), + description: "Coding after midnight".to_string(), + icon: "🦉".to_string(), + unlocked_at: None, + }, + AchievementId::AllNighter => Achievement { + id: id.clone(), + name: "All Nighter".to_string(), + description: "Worked through the night (2 AM - 5 AM)".to_string(), + icon: "🌙".to_string(), + unlocked_at: None, + }, + AchievementId::WeekendWarrior => Achievement { + id: id.clone(), + name: "Weekend Warrior".to_string(), + description: "Coding on a weekend".to_string(), + icon: "⚔️".to_string(), + unlocked_at: None, + }, + AchievementId::DedicatedDeveloper => Achievement { + id: id.clone(), + name: "Dedicated Developer".to_string(), + description: "Coded for 30 days in a row".to_string(), + icon: "🏆".to_string(), + unlocked_at: None, + }, + + // Search and exploration + AchievementId::Explorer => Achievement { + id: id.clone(), + name: "Explorer".to_string(), + description: "Used search tools 50 times".to_string(), + icon: "🔍".to_string(), + unlocked_at: None, + }, + AchievementId::MasterSearcher => Achievement { + id: id.clone(), + name: "Master Searcher".to_string(), + description: "Searched 500 times across files".to_string(), + icon: "🕵️‍♀️".to_string(), + unlocked_at: None, + }, + + // Session achievements + AchievementId::QuickSession => Achievement { + id: id.clone(), + name: "Quick Session".to_string(), + description: "Completed a productive session in under 5 minutes".to_string(), + icon: "⚡".to_string(), + unlocked_at: None, + }, + AchievementId::FocusedWork => Achievement { + id: id.clone(), + name: "Focused Work".to_string(), + description: "Worked for 30 minutes straight".to_string(), + icon: "🎯".to_string(), + unlocked_at: None, + }, + AchievementId::DeepDive => Achievement { + id: id.clone(), + name: "Deep Dive".to_string(), + description: "Worked for 2 hours continuously".to_string(), + icon: "🏊‍♀️".to_string(), + unlocked_at: None, + }, + AchievementId::MarathonSession => Achievement { + id: id.clone(), + name: "Marathon Session".to_string(), + description: "5+ hour coding session!".to_string(), + icon: "🏃‍♀️".to_string(), + unlocked_at: None, + }, + + // Special achievements + AchievementId::FirstMessage => Achievement { + id: id.clone(), + name: "First Message".to_string(), + description: "Sent your first message to Hikari".to_string(), + icon: "✨".to_string(), + unlocked_at: None, + }, + AchievementId::FirstTool => Achievement { + id: id.clone(), + name: "First Tool".to_string(), + description: "Used your first tool".to_string(), + icon: "🔧".to_string(), + unlocked_at: None, + }, + AchievementId::FirstCodeBlock => Achievement { + id: id.clone(), + name: "First Code".to_string(), + description: "Generated your first code block".to_string(), + icon: "📦".to_string(), + unlocked_at: None, + }, + AchievementId::FirstFileEdit => Achievement { + id: id.clone(), + name: "First Edit".to_string(), + description: "Made your first file edit".to_string(), + icon: "✏️".to_string(), + unlocked_at: None, + }, + AchievementId::Polyglot => Achievement { + id: id.clone(), + name: "Polyglot".to_string(), + description: "Generated code in 5+ languages in one session".to_string(), + icon: "🌍".to_string(), + unlocked_at: None, + }, + AchievementId::SpeedCoder => Achievement { + id: id.clone(), + name: "Speed Coder".to_string(), + description: "Generated 10 code blocks in 10 minutes".to_string(), + icon: "🚀".to_string(), + unlocked_at: None, + }, + AchievementId::ClaudeConnoisseur => Achievement { + id: id.clone(), + name: "Claude Connoisseur".to_string(), + description: "Used all available Claude models".to_string(), + icon: "🎨".to_string(), + unlocked_at: None, + }, + AchievementId::MarathonCoder => Achievement { + id: id.clone(), + name: "Marathon Coder".to_string(), + description: "10,000 tokens in a single session".to_string(), + icon: "🏃‍♂️".to_string(), + unlocked_at: None, + }, + } +} + +// Check which achievements should be unlocked based on current stats +pub fn check_achievements( + stats: &crate::stats::UsageStats, + progress: &mut AchievementProgress, +) -> Vec { + let mut newly_unlocked = Vec::new(); + + println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}", + stats.messages_exchanged, + stats.total_input_tokens + stats.total_output_tokens, + stats.code_blocks_generated); + println!("Currently unlocked: {:?}", progress.unlocked); + + // Token milestones + let total_tokens = stats.total_input_tokens + stats.total_output_tokens; + if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) { + println!("Unlocked FirstSteps achievement!"); + newly_unlocked.push(AchievementId::FirstSteps); + } + if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) { + newly_unlocked.push(AchievementId::GrowingStrong); + } + if total_tokens >= 100_000 && progress.unlock(AchievementId::BlossomingCoder) { + newly_unlocked.push(AchievementId::BlossomingCoder); + } + if total_tokens >= 1_000_000 && progress.unlock(AchievementId::TokenMaster) { + newly_unlocked.push(AchievementId::TokenMaster); + } + + // Code generation + if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::HelloWorld) { + newly_unlocked.push(AchievementId::HelloWorld); + } + if stats.code_blocks_generated >= 100 && progress.unlock(AchievementId::CodeWizard) { + newly_unlocked.push(AchievementId::CodeWizard); + } + if stats.code_blocks_generated >= 1000 && progress.unlock(AchievementId::ThousandBlocks) { + newly_unlocked.push(AchievementId::ThousandBlocks); + } + + // File operations + if stats.files_edited >= 10 && progress.unlock(AchievementId::FileManipulator) { + newly_unlocked.push(AchievementId::FileManipulator); + } + let total_files = stats.files_edited + stats.files_created; + if total_files >= 100 && progress.unlock(AchievementId::FileArchitect) { + newly_unlocked.push(AchievementId::FileArchitect); + } + + // Conversation milestones + if stats.messages_exchanged >= 1 && progress.unlock(AchievementId::FirstMessage) { + newly_unlocked.push(AchievementId::FirstMessage); + } + if stats.messages_exchanged >= 10 && progress.unlock(AchievementId::ConversationStarter) { + newly_unlocked.push(AchievementId::ConversationStarter); + } + if stats.messages_exchanged >= 100 && progress.unlock(AchievementId::ChattyKathy) { + newly_unlocked.push(AchievementId::ChattyKathy); + } + if stats.messages_exchanged >= 1000 && progress.unlock(AchievementId::Conversationalist) { + newly_unlocked.push(AchievementId::Conversationalist); + } + + // Tool usage + let unique_tools = stats.tools_usage.len(); + if unique_tools >= 5 && progress.unlock(AchievementId::Toolsmith) { + newly_unlocked.push(AchievementId::Toolsmith); + } + if unique_tools >= 10 && progress.unlock(AchievementId::ToolMaster) { + newly_unlocked.push(AchievementId::ToolMaster); + } + + // Search and exploration + let search_tools = ["Glob", "Grep", "search", "Task"]; + let search_count: u64 = search_tools.iter() + .filter_map(|tool| stats.tools_usage.get(*tool)) + .sum(); + if search_count >= 50 && progress.unlock(AchievementId::Explorer) { + newly_unlocked.push(AchievementId::Explorer); + } + if search_count >= 500 && progress.unlock(AchievementId::MasterSearcher) { + newly_unlocked.push(AchievementId::MasterSearcher); + } + + // Session duration achievements + let session_secs = stats.session_duration_seconds; + if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) { + newly_unlocked.push(AchievementId::QuickSession); + } + if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) { + newly_unlocked.push(AchievementId::FocusedWork); + } + if session_secs >= 7200 && progress.unlock(AchievementId::DeepDive) { + newly_unlocked.push(AchievementId::DeepDive); + } + if session_secs >= 18000 && progress.unlock(AchievementId::MarathonSession) { + newly_unlocked.push(AchievementId::MarathonSession); + } + + // Session token achievement + let session_tokens = stats.session_input_tokens + stats.session_output_tokens; + if session_tokens >= 10000 && progress.unlock(AchievementId::MarathonCoder) { + newly_unlocked.push(AchievementId::MarathonCoder); + } + + // Special first-time achievements + if stats.tools_usage.len() >= 1 && progress.unlock(AchievementId::FirstTool) { + newly_unlocked.push(AchievementId::FirstTool); + } + if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) { + newly_unlocked.push(AchievementId::FirstCodeBlock); + } + if stats.files_edited >= 1 && progress.unlock(AchievementId::FirstFileEdit) { + newly_unlocked.push(AchievementId::FirstFileEdit); + } + + // Speed coder - need to track time for this + // TODO: Implement tracking for 10 code blocks in 10 minutes + + // Polyglot - need to track languages + // TODO: Implement tracking for multiple programming languages + + // Claude Connoisseur - check model usage + // TODO: Track different Claude models used + + // Time-based achievements + if let Some(session_start) = progress.session_start { + let hour = session_start.hour(); + let weekday = session_start.weekday(); + + // Early bird - 5 AM to 7 AM + if hour >= 5 && hour <= 7 && progress.unlock(AchievementId::EarlyBird) { + newly_unlocked.push(AchievementId::EarlyBird); + } + + // Night owl - after midnight + let current_hour = Utc::now().hour(); + if current_hour < 6 && progress.unlock(AchievementId::NightOwl) { + newly_unlocked.push(AchievementId::NightOwl); + } + + // All nighter - 2 AM to 5 AM + if current_hour >= 2 && current_hour <= 5 && progress.unlock(AchievementId::AllNighter) { + newly_unlocked.push(AchievementId::AllNighter); + } + + // Weekend warrior + use chrono::Weekday; + if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) { + newly_unlocked.push(AchievementId::WeekendWarrior); + } + } + + // Dedicated Developer - need to track consecutive days + // TODO: Implement 30 days in a row tracking + + newly_unlocked +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AchievementUnlockedEvent { + pub achievement: Achievement, +} + +// Save achievements to persistent store +pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> { + let store = app.store("achievements.json") + .map_err(|e| e.to_string())?; + + // Create a serializable version with just the unlocked achievement IDs + let unlocked_list: Vec = progress.unlocked.iter().cloned().collect(); + + println!("Saving achievements: {:?}", unlocked_list); + + store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string())?; + + println!("Achievements saved successfully"); + Ok(()) +} + +// Load achievements from persistent store +pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { + println!("Loading achievements from store..."); + + let store = match app.store("achievements.json") { + Ok(s) => s, + Err(e) => { + println!("Failed to open achievements store: {}", e); + return AchievementProgress::new(); + } + }; + + let mut progress = AchievementProgress::new(); + + // Get unlocked achievements + if let Some(unlocked_value) = store.get("unlocked") { + println!("Found unlocked value in store: {:?}", unlocked_value); + if let Ok(unlocked_list) = serde_json::from_value::>(unlocked_value.clone()) { + println!("Loaded {} achievements", unlocked_list.len()); + for achievement_id in unlocked_list { + progress.unlocked.insert(achievement_id); + } + } else { + println!("Failed to parse unlocked achievements"); + } + } else { + println!("No unlocked achievements found in store"); + } + + progress +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_achievement_unlock() { + let mut progress = AchievementProgress::new(); + + // First unlock should return true + assert!(progress.unlock(AchievementId::FirstSteps)); + assert!(progress.is_unlocked(&AchievementId::FirstSteps)); + + // Second unlock of same achievement should return false + assert!(!progress.unlock(AchievementId::FirstSteps)); + + // Newly unlocked should contain the achievement + let newly = progress.take_newly_unlocked(); + assert_eq!(newly.len(), 1); + assert_eq!(newly[0], AchievementId::FirstSteps); + + // After taking, newly unlocked should be empty + let newly = progress.take_newly_unlocked(); + assert!(newly.is_empty()); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4976260..1bafa39 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,6 +4,7 @@ use tauri_plugin_store::StoreExt; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; use crate::wsl_bridge::SharedBridge; +use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; const CONFIG_STORE_KEY: &str = "config"; @@ -79,3 +80,23 @@ pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result Result, String> { + use chrono::Utc; + + // Load achievements from persistent store + let progress = load_achievements(&app).await; + + // Create events for all previously unlocked achievements + let mut events = Vec::new(); + for achievement_id in &progress.unlocked { + let mut info = get_achievement_info(achievement_id); + info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now + events.push(AchievementUnlockedEvent { + achievement: info, + }); + } + + Ok(events) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a88cd14..d7646eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod achievements; mod commands; mod config; mod notifications; @@ -11,6 +12,7 @@ mod windows_toast; use commands::*; use notifications::*; use wsl_bridge::create_shared_bridge; +use commands::load_saved_achievements; use wsl_notifications::*; use vbs_notification::*; use windows_toast::*; @@ -37,6 +39,7 @@ pub fn run() { get_config, save_config, get_usage_stats, + load_saved_achievements, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index ee35b96..d174bd0 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; +use crate::achievements::{AchievementProgress, check_achievements}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UsageStats { @@ -26,11 +27,17 @@ pub struct UsageStats { pub session_duration_seconds: u64, #[serde(skip)] pub session_start: Option, + + // Achievement tracking + #[serde(skip)] + pub achievements: AchievementProgress, } impl UsageStats { pub fn new() -> Self { - Self::default() + let mut stats = Self::default(); + stats.achievements.start_session(); + stats } pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { @@ -57,6 +64,7 @@ impl UsageStats { self.session_tools_usage.clear(); self.session_duration_seconds = 0; self.session_start = Some(Instant::now()); + self.achievements.start_session(); } pub fn increment_messages(&mut self) { @@ -94,6 +102,32 @@ impl UsageStats { } self.session_duration_seconds } + + pub fn check_achievements(&mut self) -> Vec { + let stats_copy = UsageStats { + total_input_tokens: self.total_input_tokens, + total_output_tokens: self.total_output_tokens, + total_cost_usd: self.total_cost_usd, + session_input_tokens: self.session_input_tokens, + session_output_tokens: self.session_output_tokens, + session_cost_usd: self.session_cost_usd, + model: self.model.clone(), + messages_exchanged: self.messages_exchanged, + session_messages_exchanged: self.session_messages_exchanged, + code_blocks_generated: self.code_blocks_generated, + session_code_blocks_generated: self.session_code_blocks_generated, + files_edited: self.files_edited, + session_files_edited: self.session_files_edited, + files_created: self.files_created, + session_files_created: self.session_files_created, + tools_usage: self.tools_usage.clone(), + session_tools_usage: self.session_tools_usage.clone(), + session_duration_seconds: self.session_duration_seconds, + session_start: self.session_start, + achievements: AchievementProgress::new(), // Dummy for copy + }; + check_achievements(&stats_copy, &mut self.achievements) + } } // Pricing as of January 2025 diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 337536b..310f2c8 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -13,6 +13,7 @@ use crate::config::ClaudeStartOptions; use crate::stats::{UsageStats, StatsUpdateEvent}; use parking_lot::RwLock; use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; +use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; @@ -89,11 +90,32 @@ impl WslBridge { } } + pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self { + let mut bridge = Self::new(); + + // Load saved achievements into the stats + let achievements = crate::achievements::load_achievements(app).await; + println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len()); + bridge.stats.write().achievements = achievements; + + bridge + } + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); } + // Load saved achievements when starting a new session + let app_clone = app.clone(); + let stats = self.stats.clone(); + tauri::async_runtime::spawn(async move { + println!("Loading saved achievements..."); + let achievements = crate::achievements::load_achievements(&app_clone).await; + println!("Loaded {} unlocked achievements", achievements.unlocked.len()); + stats.write().achievements = achievements; + }); + let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); @@ -256,6 +278,14 @@ impl WslBridge { // Reset session stats when starting new session self.stats.write().reset_session(); + // Load saved achievements + let app_handle = app.clone(); + let stats_clone = self.stats.clone(); + tokio::spawn(async move { + let saved_progress = crate::achievements::load_achievements(&app_handle).await; + stats_clone.write().achievements = saved_progress; + }); + if let Some(stdout) = stdout { let app_clone = app.clone(); let stats_clone = self.stats.clone(); @@ -510,8 +540,38 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Always emit updated stats on result message (less frequent) // This includes the latest session duration - { - stats.write().get_session_duration(); + let newly_unlocked = { + let mut stats_guard = stats.write(); + stats_guard.get_session_duration(); + println!("Checking achievements after result message..."); + let unlocked = stats_guard.check_achievements(); + println!("Newly unlocked achievements: {:?}", unlocked); + unlocked + }; + + // Emit achievement events for any newly unlocked achievements + for achievement_id in &newly_unlocked { + let info = get_achievement_info(achievement_id); + let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { + achievement: info, + }); + } + + // Save achievements after unlocking new ones + if !newly_unlocked.is_empty() { + println!("Saving newly unlocked achievements: {:?}", newly_unlocked); + let app_handle = app.clone(); + let achievements_progress = stats.read().achievements.clone(); + + // Use Tauri's async runtime instead of tokio::spawn + tauri::async_runtime::spawn(async move { + println!("Spawned save task for achievements"); + if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + eprintln!("Failed to save achievements: {}", e); + } else { + println!("Achievement save task completed successfully"); + } + }); } let current_stats = stats.read().clone(); @@ -556,6 +616,37 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc ClaudeMessage::User { .. } => { // Increment message count for user messages stats.write().increment_messages(); + + // Check achievements after user message + let newly_unlocked = { + let mut stats_guard = stats.write(); + println!("User sent message, checking achievements..."); + stats_guard.check_achievements() + }; + + // Emit achievement events for any newly unlocked achievements + for achievement_id in &newly_unlocked { + println!("User message unlocked achievement: {:?}", achievement_id); + let info = get_achievement_info(achievement_id); + let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { + achievement: info, + }); + } + + // Save achievements after unlocking new ones + if !newly_unlocked.is_empty() { + println!("Saving newly unlocked achievements from user message"); + let app_handle = app.clone(); + let achievements_progress = stats.read().achievements.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + eprintln!("Failed to save achievements: {}", e); + } else { + println!("Achievements saved after user message"); + } + }); + } + emit_state_change(app, CharacterState::Thinking, None); } } diff --git a/src/lib/components/AchievementNotification.svelte b/src/lib/components/AchievementNotification.svelte new file mode 100644 index 0000000..50e6cb0 --- /dev/null +++ b/src/lib/components/AchievementNotification.svelte @@ -0,0 +1,154 @@ + + +{#if showNotification && currentAchievement} +
+ +
+ +
+ + +
+ + +
+ +
+
{currentAchievement.achievement.icon}
+ + +
+
+
+
+ + +
+

+ Achievement Unlocked! +

+

+ {currentAchievement.achievement.name} +

+

+ {currentAchievement.achievement.description} +

+ + +
+ + {getAchievementRarity(currentAchievement.achievement.id)} + +
+
+
+ + +
+ {#each Array(10) as _, i} +
+ {/each} +
+
+
+
+{/if} + + \ No newline at end of file diff --git a/src/lib/components/AchievementsPanel.svelte b/src/lib/components/AchievementsPanel.svelte new file mode 100644 index 0000000..f06d610 --- /dev/null +++ b/src/lib/components/AchievementsPanel.svelte @@ -0,0 +1,223 @@ + + + +{#if isOpen} +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" + aria-label="Close achievements panel" + transition:slide={{ duration: 300, easing: quintOut }} + >
+ +
+ +
+
+

Achievements

+ +
+ + +
+
+ {progress.unlocked} / {progress.total} Unlocked + {progress.percentage}% +
+
+
+
+
+
+ + +
+ {#each achievementCategories as category} + {@const achievements = getAchievementsForCategory(category.ids)} + {@const unlockedCount = achievements.filter(a => a.unlocked).length} + +
+ + + {#if selectedCategory === category.name} +
+ {#each achievements as achievement} +
+
+ +
+ {achievement.icon} +
+ + +
+
+

+ {achievement.name} +

+ + {achievement.rarity} + +
+

+ {achievement.description} +

+ + {#if achievement.unlocked && achievement.unlockedAt} +

+ Unlocked {formatDate(achievement.unlockedAt)} +

+ {:else if achievement.maxProgress && achievement.progress !== undefined} + +
+
+ Progress + {achievement.progress} / {achievement.maxProgress} +
+
+
+
+
+ {/if} +
+
+
+ {/each} +
+ {/if} +
+ {/each} +
+ + + {#if achievementsState.lastUnlocked} +
+

Last Unlocked:

+
+ {achievementsState.lastUnlocked.icon} +
+

{achievementsState.lastUnlocked.name}

+

{formatDate(achievementsState.lastUnlocked.unlockedAt)}

+
+
+
+ {/if} +
+{/if} + + \ No newline at end of file diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 2cca841..4c0e821 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,4 +1,10 @@
+