generated from nhcarrigan/template
feat: add multiple productivity features and UI enhancements (#68)
## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *✨ This PR was created with help from Hikari~ 🌸* Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #68 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use crate::achievements::{check_achievements, AchievementProgress};
|
||||
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 {
|
||||
@@ -28,6 +30,14 @@ pub struct UsageStats {
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<Instant>,
|
||||
|
||||
// Extended tracking for achievements
|
||||
pub sessions_started: u64,
|
||||
pub consecutive_days: u64,
|
||||
pub total_days_used: u64,
|
||||
pub morning_sessions: u64, // Sessions started before 9 AM
|
||||
pub night_sessions: u64, // Sessions started after 10 PM
|
||||
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||
|
||||
// Achievement tracking
|
||||
#[serde(skip)]
|
||||
pub achievements: AchievementProgress,
|
||||
@@ -65,6 +75,47 @@ impl UsageStats {
|
||||
self.session_duration_seconds = 0;
|
||||
self.session_start = Some(Instant::now());
|
||||
self.achievements.start_session();
|
||||
|
||||
// Track session start for achievements
|
||||
self.track_session_start();
|
||||
}
|
||||
|
||||
pub fn track_session_start(&mut self) {
|
||||
let now = Local::now();
|
||||
let today = now.format("%Y-%m-%d").to_string();
|
||||
let hour = now.hour();
|
||||
|
||||
// Increment session count
|
||||
self.sessions_started += 1;
|
||||
|
||||
// Track morning/night sessions
|
||||
if hour < 9 {
|
||||
self.morning_sessions += 1;
|
||||
}
|
||||
if hour >= 22 {
|
||||
self.night_sessions += 1;
|
||||
}
|
||||
|
||||
// Track consecutive days and total days
|
||||
if let Some(last_date) = &self.last_session_date {
|
||||
if last_date != &today {
|
||||
// Check if it's the next day (consecutive)
|
||||
if is_consecutive_day(last_date, &today) {
|
||||
self.consecutive_days += 1;
|
||||
} else {
|
||||
// Streak broken
|
||||
self.consecutive_days = 1;
|
||||
}
|
||||
self.total_days_used += 1;
|
||||
self.last_session_date = Some(today);
|
||||
}
|
||||
// Same day - don't increment anything
|
||||
} else {
|
||||
// First session ever
|
||||
self.consecutive_days = 1;
|
||||
self.total_days_used = 1;
|
||||
self.last_session_date = Some(today);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_messages(&mut self) {
|
||||
@@ -127,12 +178,34 @@ impl UsageStats {
|
||||
session_tools_usage: self.session_tools_usage.clone(),
|
||||
session_duration_seconds: self.session_duration_seconds,
|
||||
session_start: self.session_start,
|
||||
sessions_started: self.sessions_started,
|
||||
consecutive_days: self.consecutive_days,
|
||||
total_days_used: self.total_days_used,
|
||||
morning_sessions: self.morning_sessions,
|
||||
night_sessions: self.night_sessions,
|
||||
last_session_date: self.last_session_date.clone(),
|
||||
achievements: AchievementProgress::new(), // Dummy for copy
|
||||
};
|
||||
check_achievements(&stats_copy, &mut self.achievements)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if two dates are consecutive
|
||||
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
|
||||
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
|
||||
|
||||
match (prev, current) {
|
||||
(Some(p), Some(c)) => {
|
||||
let diff = c.signed_duration_since(p).num_days();
|
||||
diff == 1
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing as of January 2025
|
||||
// https://www.anthropic.com/pricing
|
||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||
@@ -169,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::*;
|
||||
|
||||
Reference in New Issue
Block a user