feat: stats and achievements #45

Merged
naomi merged 6 commits from feat/stats into main 2026-01-19 20:51:53 -08:00
14 changed files with 1687 additions and 4 deletions
Showing only changes of commit b691a91c53 - Show all commits
+3
View File
@@ -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",
+1
View File
@@ -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 = [
+604
View File
@@ -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<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AchievementProgress {
pub unlocked: HashSet<AchievementId>,
pub newly_unlocked: Vec<AchievementId>, // Achievements unlocked but not yet notified
#[serde(skip)]
pub session_start: Option<DateTime<Utc>>,
}
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<AchievementId> {
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<AchievementId> {
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<AchievementId> = 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::<Vec<AchievementId>>(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());
}
}
+21
View File
@@ -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<UsageSta
let bridge = bridge.lock();
Ok(bridge.get_stats())
}
#[tauri::command]
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, 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)
}
+3
View File
@@ -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,
+35 -1
View File
@@ -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<Instant>,
// 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<crate::achievements::AchievementId> {
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
+93 -2
View File
@@ -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<RwLock<UsageStats>
// 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<RwLock<UsageStats>
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);
}
}
@@ -0,0 +1,154 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { listen } from '@tauri-apps/api/event';
import type { AchievementUnlockedEvent } from '$lib/types/achievements';
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(async () => {
const unlisten = await listen<AchievementUnlockedEvent>('achievement:unlocked', (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
return unlisten;
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case 'legendary': return 'from-yellow-400 to-orange-500';
case 'epic': return 'from-purple-400 to-pink-500';
case 'rare': return 'from-blue-400 to-indigo-500';
default: return 'from-green-400 to-emerald-500';
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
if (id === 'TokenMaster') return 'legendary';
if (['CodeMachine', 'Unstoppable'].includes(id)) return 'epic';
if (['BlossomingCoder', 'CodeWizard', 'MasterBuilder', 'EnduranceChamp', 'DeepDive', 'CreativeCoder'].includes(id)) return 'rare';
return 'common';
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div class="absolute inset-0 bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} animate-pulse"></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === 'Enter' && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"></div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"></div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} text-white capitalize">
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _, i}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(getAchievementRarity(currentAchievement.achievement.id))} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() * 2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import {
achievementsStore,
achievementProgress,
achievementCategories,
} from '$lib/stores/achievements';
import type { Achievement } from '$lib/types/achievements';
interface Props {
isOpen: boolean;
onClose?: () => void;
}
const { isOpen = $bindable(false), onClose } = $props<Props>();
let selectedCategory = $state<string | null>(null);
const achievementsState = $derived($achievementsStore);
const progress = $derived($achievementProgress);
function getRarityColor(rarity: string): string {
switch (rarity) {
case 'legendary': return 'text-yellow-500 dark:text-yellow-400';
case 'epic': return 'text-purple-500 dark:text-purple-400';
case 'rare': return 'text-blue-500 dark:text-blue-400';
default: return 'text-green-500 dark:text-green-400';
}
}
function getRarityBg(rarity: string): string {
switch (rarity) {
case 'legendary': return 'bg-yellow-500/10';
case 'epic': return 'bg-purple-500/10';
case 'rare': return 'bg-blue-500/10';
default: return 'bg-green-500/10';
}
}
function formatDate(date: Date | undefined): string {
if (!date) return '';
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
return categoryIds.map(id => achievementsState.achievements[id as keyof typeof achievementsState.achievements]).filter(Boolean);
}
</script>
<!-- Achievements panel -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 z-40"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="button"
tabindex="-1"
aria-label="Close achievements panel"
transition:slide={{ duration: 300, easing: quintOut }}
></div>
<div
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
shadow-2xl z-50 flex flex-col"
transition:slide={{ duration: 300, easing: quintOut, axis: 'x' }}
>
<!-- Header -->
<div class="p-6 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
<button
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Overall progress -->
<div class="mt-4">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>{progress.unlocked} / {progress.total} Unlocked</span>
<span>{progress.percentage}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
style="width: {progress.percentage}%"
></div>
</div>
</div>
</div>
<!-- Categories -->
<div class="flex-1 overflow-y-auto">
{#each achievementCategories as category}
{@const achievements = getAchievementsForCategory(category.ids)}
{@const unlockedCount = achievements.filter(a => a.unlocked).length}
<div class="border-b border-[var(--border-color)]">
<button
onclick={() => selectedCategory = selectedCategory === category.name ? null : category.name}
onkeydown={(e) => e.key === 'Enter' && (selectedCategory = selectedCategory === category.name ? null : category.name)}
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{unlockedCount} / {achievements.length}
</span>
<svg
class="w-5 h-5 transition-transform {selectedCategory === category.name ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
</button>
{#if selectedCategory === category.name}
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
{#each achievements as achievement}
<div
class="p-3 rounded-lg border {achievement.unlocked ? 'border-[var(--border-color)] bg-[var(--bg-secondary)]' : 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
{achievement.icon}
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-[var(--text-primary)]">
{achievement.name}
</h4>
<span class="text-xs px-2 py-0.5 rounded-full {getRarityBg(achievement.rarity)} {getRarityColor(achievement.rarity)} capitalize">
{achievement.rarity}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{achievement.description}
</p>
{#if achievement.unlocked && achievement.unlockedAt}
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
Unlocked {formatDate(achievement.unlockedAt)}
</p>
{:else if achievement.maxProgress && achievement.progress !== undefined}
<!-- Progress bar for locked achievements -->
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Progress</span>
<span>{achievement.progress} / {achievement.maxProgress}</span>
</div>
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
<div
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.min((achievement.progress / achievement.maxProgress) * 100, 100)}%"
></div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer with last unlocked -->
{#if achievementsState.lastUnlocked}
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
<div class="flex items-center gap-2">
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
<div>
<p class="font-semibold text-[var(--text-primary)]">{achievementsState.lastUnlocked.name}</p>
<p class="text-xs text-gray-500">{formatDate(achievementsState.lastUnlocked.unlockedAt)}</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
/* Custom scrollbar for achievement list */
:global(.overflow-y-auto::-webkit-scrollbar) {
width: 8px;
}
:global(.overflow-y-auto::-webkit-scrollbar-track) {
background: var(--bg-secondary);
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: var(--border-color);
border-radius: 4px;
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: var(--accent-primary);
}
</style>
+24
View File
@@ -1,4 +1,10 @@
<script lang="ts">
interface Props {
onToggleAchievements?: () => void;
}
const { onToggleAchievements = () => {} } = $props<Props>();
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog";
@@ -8,6 +14,7 @@
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -18,6 +25,7 @@
let grantedToolsList: string[] = $state([]);
let appVersion = $state("");
let showStats = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
api_key: null,
@@ -128,6 +136,10 @@
return "Disconnected";
}
}
function toggleAchievements() {
onToggleAchievements();
}
</script>
<div
@@ -169,6 +181,18 @@
</div>
<div class="flex items-center gap-3">
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
{#if progress.unlocked > 0}
<span class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]">
{progress.unlocked}
</span>
{/if}
</button>
<button
onclick={() => showStats = !showStats}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats ? 'text-[var(--accent-primary)]' : ''}"
+445
View File
@@ -0,0 +1,445 @@
import { writable, derived } from 'svelte/store';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import type { Achievement, AchievementUnlockedEvent, AchievementId } from '$lib/types/achievements';
interface AchievementState {
achievements: Record<AchievementId, Achievement>;
totalUnlocked: number;
lastUnlocked: Achievement | null;
}
// Initial achievement definitions
const achievementDefinitions: Record<AchievementId, Omit<Achievement, 'unlocked' | 'unlockedAt'>> = {
// Token milestones
FirstSteps: {
id: 'FirstSteps',
name: 'First Steps',
description: 'Generated your first 1,000 tokens',
icon: '👶',
rarity: 'common',
maxProgress: 1000,
},
GrowingStrong: {
id: 'GrowingStrong',
name: 'Growing Strong',
description: 'Reached 10,000 tokens total',
icon: '🌱',
rarity: 'common',
maxProgress: 10000,
},
BlossomingCoder: {
id: 'BlossomingCoder',
name: 'Blossoming Coder',
description: 'Generated 100,000 tokens - you\'re really growing!',
icon: '🌸',
rarity: 'rare',
maxProgress: 100000,
},
TokenMaster: {
id: 'TokenMaster',
name: 'Token Master',
description: 'One million tokens! You\'re unstoppable!',
icon: '👑',
rarity: 'legendary',
maxProgress: 1000000,
},
// Code generation
HelloWorld: {
id: 'HelloWorld',
name: 'Hello, World!',
description: 'Generated your first code block',
icon: '👋',
rarity: 'common',
maxProgress: 1,
},
CodeWizard: {
id: 'CodeWizard',
name: 'Code Wizard',
description: '100 code blocks generated',
icon: '🧙‍♀️',
rarity: 'rare',
maxProgress: 100,
},
ThousandBlocks: {
id: 'ThousandBlocks',
name: 'Thousand Blocks',
description: '1,000 code blocks! You\'re a code machine!',
icon: '🏗️',
rarity: 'epic',
maxProgress: 1000,
},
// File operations
FileManipulator: {
id: 'FileManipulator',
name: 'File Manipulator',
description: 'Edited 10 files',
icon: '📝',
rarity: 'common',
maxProgress: 10,
},
FileArchitect: {
id: 'FileArchitect',
name: 'File Architect',
description: 'Created or edited 100 files',
icon: '🏛️',
rarity: 'rare',
maxProgress: 100,
},
// Conversation milestones
ConversationStarter: {
id: 'ConversationStarter',
name: 'Conversation Starter',
description: 'Exchanged 10 messages',
icon: '💬',
rarity: 'common',
maxProgress: 10,
},
ChattyKathy: {
id: 'ChattyKathy',
name: 'Chatty Kathy',
description: '100 messages exchanged',
icon: '🗣️',
rarity: 'common',
maxProgress: 100,
},
Conversationalist: {
id: 'Conversationalist',
name: 'Master Conversationalist',
description: '1,000 messages! We\'re really connecting!',
icon: '💖',
rarity: 'rare',
maxProgress: 1000,
},
// Tool usage
Toolsmith: {
id: 'Toolsmith',
name: 'Toolsmith',
description: 'Used 5 different tools',
icon: '🔨',
rarity: 'common',
maxProgress: 5,
},
ToolMaster: {
id: 'ToolMaster',
name: 'Tool Master',
description: 'Used 10 different tools efficiently',
icon: '🛠️',
rarity: 'rare',
maxProgress: 10,
},
// Time-based achievements
EarlyBird: {
id: 'EarlyBird',
name: 'Early Bird',
description: 'Started a session between 5 AM and 7 AM',
icon: '🌅',
rarity: 'common',
},
NightOwl: {
id: 'NightOwl',
name: 'Night Owl',
description: 'Coding after midnight',
icon: '🦉',
rarity: 'common',
},
AllNighter: {
id: 'AllNighter',
name: 'All Nighter',
description: 'Worked through the night (2 AM - 5 AM)',
icon: '🌙',
rarity: 'rare',
},
WeekendWarrior: {
id: 'WeekendWarrior',
name: 'Weekend Warrior',
description: 'Coding on a weekend',
icon: '⚔️',
rarity: 'common',
},
DedicatedDeveloper: {
id: 'DedicatedDeveloper',
name: 'Dedicated Developer',
description: 'Coded for 30 days in a row',
icon: '🏆',
rarity: 'legendary',
},
// Search and exploration
Explorer: {
id: 'Explorer',
name: 'Explorer',
description: 'Used search tools 50 times',
icon: '🔍',
rarity: 'common',
maxProgress: 50,
},
MasterSearcher: {
id: 'MasterSearcher',
name: 'Master Searcher',
description: 'Searched 500 times across files',
icon: '🕵️‍♀️',
rarity: 'rare',
maxProgress: 500,
},
// Session achievements
QuickSession: {
id: 'QuickSession',
name: 'Quick Session',
description: 'Completed a productive session in under 5 minutes',
icon: '⚡',
rarity: 'common',
},
FocusedWork: {
id: 'FocusedWork',
name: 'Focused Work',
description: 'Worked for 30 minutes straight',
icon: '🎯',
rarity: 'common',
},
DeepDive: {
id: 'DeepDive',
name: 'Deep Dive',
description: 'Worked for 2 hours continuously',
icon: '🏊‍♀️',
rarity: 'rare',
},
MarathonSession: {
id: 'MarathonSession',
name: 'Marathon Session',
description: '5+ hour coding session!',
icon: '🏃‍♀️',
rarity: 'epic',
},
// Special achievements
FirstMessage: {
id: 'FirstMessage',
name: 'First Message',
description: 'Sent your first message to Hikari',
icon: '✨',
rarity: 'common',
maxProgress: 1,
},
FirstTool: {
id: 'FirstTool',
name: 'First Tool',
description: 'Used your first tool',
icon: '🔧',
rarity: 'common',
maxProgress: 1,
},
FirstCodeBlock: {
id: 'FirstCodeBlock',
name: 'First Code',
description: 'Generated your first code block',
icon: '📦',
rarity: 'common',
maxProgress: 1,
},
FirstFileEdit: {
id: 'FirstFileEdit',
name: 'First Edit',
description: 'Made your first file edit',
icon: '✏️',
rarity: 'common',
maxProgress: 1,
},
Polyglot: {
id: 'Polyglot',
name: 'Polyglot',
description: 'Generated code in 5+ languages in one session',
icon: '🌍',
rarity: 'rare',
maxProgress: 5,
},
SpeedCoder: {
id: 'SpeedCoder',
name: 'Speed Coder',
description: 'Generated 10 code blocks in 10 minutes',
icon: '🚀',
rarity: 'rare',
},
ClaudeConnoisseur: {
id: 'ClaudeConnoisseur',
name: 'Claude Connoisseur',
description: 'Used all available Claude models',
icon: '🎨',
rarity: 'epic',
maxProgress: 5, // Adjust based on available models
},
MarathonCoder: {
id: 'MarathonCoder',
name: 'Marathon Coder',
description: '10,000 tokens in a single session',
icon: '🏃‍♂️',
rarity: 'epic',
maxProgress: 10000,
},
};
// Initialize all achievements as locked
const initialAchievements: Record<AchievementId, Achievement> = {} as Record<AchievementId, Achievement>;
for (const [id, def] of Object.entries(achievementDefinitions)) {
initialAchievements[id as AchievementId] = {
...def,
unlocked: false,
progress: 0,
};
}
// Create the main store
function createAchievementsStore() {
const { subscribe, update } = writable<AchievementState>({
achievements: initialAchievements,
totalUnlocked: 0,
lastUnlocked: null,
});
return {
subscribe,
unlockAchievement: (event: AchievementUnlockedEvent) => {
update(state => {
const achievement = state.achievements[event.achievement.id];
if (achievement && !achievement.unlocked) {
achievement.unlocked = true;
achievement.unlockedAt = event.achievement.unlocked_at ? new Date(event.achievement.unlocked_at) : new Date();
state.totalUnlocked++;
state.lastUnlocked = achievement;
}
return state;
});
},
updateProgress: (id: AchievementId, progress: number) => {
update(state => {
const achievement = state.achievements[id];
if (achievement) {
achievement.progress = progress;
}
return state;
});
},
reset: () => {
update(() => ({
achievements: initialAchievements,
totalUnlocked: 0,
lastUnlocked: null,
}));
}
};
}
export const achievementsStore = createAchievementsStore();
// Derived stores for different views
export const unlockedAchievements = derived(
achievementsStore,
$store => Object.values($store.achievements).filter(a => a.unlocked)
);
export const lockedAchievements = derived(
achievementsStore,
$store => Object.values($store.achievements).filter(a => !a.unlocked)
);
export const achievementsByRarity = derived(
achievementsStore,
$store => {
const byRarity: Record<string, Achievement[]> = {
common: [],
rare: [],
epic: [],
legendary: [],
};
for (const achievement of Object.values($store.achievements)) {
byRarity[achievement.rarity].push(achievement);
}
return byRarity;
}
);
export const achievementProgress = derived(
achievementsStore,
$store => ({
unlocked: $store.totalUnlocked,
total: Object.keys($store.achievements).length,
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
})
);
// Initialize achievement listener
export async function initAchievementsListener() {
// Listen for achievement unlocked events
await listen<AchievementUnlockedEvent>('achievement:unlocked', (event) => {
achievementsStore.unlockAchievement(event.payload);
});
// Load saved achievements from persistent storage
try {
const savedAchievements = await invoke<AchievementUnlockedEvent[]>('load_saved_achievements');
// Update the store with saved achievements
for (const event of savedAchievements) {
achievementsStore.unlockAchievement(event);
}
} catch (error) {
console.error('Failed to load saved achievements:', error);
}
}
// Export achievement categories for the display panel
export const achievementCategories = [
{
name: 'Token Milestones',
description: 'Track your token generation progress',
ids: ['FirstSteps', 'GrowingStrong', 'BlossomingCoder', 'TokenMaster'] as AchievementId[],
},
{
name: 'Code Generation',
description: 'Achievements for generating code',
ids: ['HelloWorld', 'CodeWizard', 'ThousandBlocks'] as AchievementId[],
},
{
name: 'File Operations',
description: 'Working with files and projects',
ids: ['FileManipulator', 'FileArchitect'] as AchievementId[],
},
{
name: 'Conversations',
description: 'Building our relationship through chat',
ids: ['ConversationStarter', 'ChattyKathy', 'Conversationalist'] as AchievementId[],
},
{
name: 'Tools & Skills',
description: 'Mastering different tools',
ids: ['Toolsmith', 'ToolMaster'] as AchievementId[],
},
{
name: 'Time-Based',
description: 'When you code matters too!',
ids: ['EarlyBird', 'NightOwl', 'AllNighter', 'WeekendWarrior', 'DedicatedDeveloper'] as AchievementId[],
},
{
name: 'Search & Explore',
description: 'Finding what you need',
ids: ['Explorer', 'MasterSearcher'] as AchievementId[],
},
{
name: 'Session Records',
description: 'Your coding session achievements',
ids: ['QuickSession', 'FocusedWork', 'DeepDive', 'MarathonSession', 'MarathonCoder'] as AchievementId[],
},
{
name: 'Special',
description: 'Unique accomplishments',
ids: ['FirstMessage', 'FirstTool', 'FirstCodeBlock', 'FirstFileEdit', 'Polyglot', 'SpeedCoder', 'ClaudeConnoisseur'] as AchievementId[],
},
];
+4
View File
@@ -4,6 +4,7 @@ import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config";
import { initStatsListener, resetSessionStats } from "$lib/stores/stats";
import { initAchievementsListener } from "$lib/stores/achievements";
import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import {
@@ -80,6 +81,9 @@ export async function initializeTauriListeners() {
// Initialize stats listener
await initStatsListener();
// Initialize achievements listener
await initAchievementsListener();
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
const status = event.payload as ConnectionStatus;
claudeStore.setConnectionStatus(status);
+71
View File
@@ -0,0 +1,71 @@
export interface AchievementUnlockedEvent {
achievement: {
id: AchievementId;
name: string;
description: string;
icon: string;
unlocked_at: string | null;
};
}
export type 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';
export interface Achievement {
id: AchievementId;
name: string;
description: string;
icon: string;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
unlocked: boolean;
unlockedAt?: Date;
progress?: number;
maxProgress?: number;
}
export interface AchievementCategory {
name: string;
description: string;
achievements: Achievement[];
}
+6 -1
View File
@@ -9,8 +9,11 @@
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import PermissionModal from "$lib/components/PermissionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
import AchievementNotification from "$lib/components/AchievementNotification.svelte";
import AchievementsPanel from "$lib/components/AchievementsPanel.svelte";
let initialized = false;
let achievementPanelOpen = $state(false);
onMount(async () => {
if (!initialized) {
@@ -33,7 +36,7 @@
</script>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar />
<StatusBar onToggleAchievements={() => achievementPanelOpen = !achievementPanelOpen} />
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
@@ -52,6 +55,8 @@
<PermissionModal />
<ConfigSidebar />
<AchievementNotification />
<AchievementsPanel bind:isOpen={achievementPanelOpen} onClose={() => achievementPanelOpen = false} />
</div>
<style>