Initial commit - Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
This commit is contained in:
421
src-tauri/src/config.rs
Normal file
421
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A single time range (e.g., 09:00 to 17:00)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimeRange {
|
||||
pub start: String, // Format: "HH:MM"
|
||||
pub end: String, // Format: "HH:MM"
|
||||
}
|
||||
|
||||
impl Default for TimeRange {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start: "09:00".to_string(),
|
||||
end: "18:00".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule for a single day
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DaySchedule {
|
||||
pub enabled: bool,
|
||||
pub ranges: Vec<TimeRange>,
|
||||
}
|
||||
|
||||
impl Default for DaySchedule {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
ranges: vec![TimeRange::default()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaySchedule {
|
||||
/// Create a default schedule for weekend days (disabled by default)
|
||||
fn weekend_default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
ranges: vec![TimeRange::default()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
// Timer settings
|
||||
pub break_duration: u32, // Duration of each break in minutes (1-60)
|
||||
pub break_frequency: u32, // How often breaks occur in minutes (5-120)
|
||||
pub auto_start: bool, // Start timer automatically on app launch
|
||||
|
||||
// Break settings
|
||||
pub break_title: String, // Title shown on break screen (e.g., "Rest your eyes")
|
||||
pub break_message: String, // Custom message shown during breaks
|
||||
pub fullscreen_mode: bool, // Use fullscreen break window vs notification
|
||||
pub strict_mode: bool, // Prevent user from skipping breaks
|
||||
pub allow_end_early: bool, // Allow ending break after 50% completion
|
||||
pub immediately_start_breaks: bool, // Skip pre-break notification, go straight to break
|
||||
|
||||
// Working hours (per-day schedule with multiple ranges)
|
||||
pub working_hours_enabled: bool,
|
||||
pub working_hours_schedule: Vec<DaySchedule>, // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
|
||||
|
||||
// Appearance
|
||||
pub dark_mode: bool,
|
||||
pub color_scheme: String,
|
||||
pub backdrop_opacity: f32, // Break screen backdrop opacity (0.5-1.0)
|
||||
|
||||
// Notifications
|
||||
pub notification_enabled: bool,
|
||||
pub notification_before_break: u32, // Notify X seconds before break (0 = no notification)
|
||||
|
||||
// Advanced
|
||||
pub snooze_duration: u32, // Duration of snooze in minutes (1-30)
|
||||
pub snooze_limit: u32, // Max snoozes per break cycle (0 = unlimited, 1-5)
|
||||
pub skip_cooldown: u32, // Minimum time between skip operations in seconds
|
||||
|
||||
// Sound
|
||||
pub sound_enabled: bool, // Play sounds on break events
|
||||
pub sound_volume: u32, // Volume 0-100
|
||||
pub sound_preset: String, // Sound preset name: "bell", "chime", "soft", "digital"
|
||||
|
||||
// Idle detection
|
||||
pub idle_detection_enabled: bool,
|
||||
pub idle_timeout: u32, // Seconds of inactivity before auto-pause (30-600)
|
||||
|
||||
// Smart breaks - natural break detection
|
||||
pub smart_breaks_enabled: bool,
|
||||
pub smart_break_threshold: u32, // Seconds of idle to count as natural break (120-900)
|
||||
pub smart_break_count_stats: bool, // Include natural breaks in statistics
|
||||
|
||||
// Break activities
|
||||
pub show_break_activities: bool,
|
||||
|
||||
// UI
|
||||
pub ui_zoom: u32, // UI zoom percentage (50-200, default 100)
|
||||
pub accent_color: String, // Main accent color hex (e.g., "#ff4d00")
|
||||
pub break_color: String, // Break screen ring color hex (e.g., "#7c6aef")
|
||||
pub countdown_font: String, // Google Font family for countdown display (empty = system default)
|
||||
pub background_blobs_enabled: bool, // Animated gradient blobs background
|
||||
|
||||
// Mini mode
|
||||
pub mini_click_through: bool, // Mini mode is click-through until hovered
|
||||
pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0)
|
||||
|
||||
// Window positions (persisted between launches)
|
||||
pub main_window_x: Option<i32>,
|
||||
pub main_window_y: Option<i32>,
|
||||
pub main_window_width: Option<u32>,
|
||||
pub main_window_height: Option<u32>,
|
||||
pub mini_window_x: Option<i32>,
|
||||
pub mini_window_y: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Timer settings
|
||||
break_duration: 5,
|
||||
break_frequency: 25,
|
||||
auto_start: true,
|
||||
|
||||
// Break settings
|
||||
break_title: "Rest your eyes".to_string(),
|
||||
break_message: "Look away from the screen. Stretch and relax.".to_string(),
|
||||
fullscreen_mode: true,
|
||||
strict_mode: false,
|
||||
allow_end_early: true,
|
||||
immediately_start_breaks: false,
|
||||
|
||||
// Working hours
|
||||
working_hours_enabled: false,
|
||||
working_hours_schedule: vec![
|
||||
DaySchedule::default(), // Monday
|
||||
DaySchedule::default(), // Tuesday
|
||||
DaySchedule::default(), // Wednesday
|
||||
DaySchedule::default(), // Thursday
|
||||
DaySchedule::default(), // Friday
|
||||
DaySchedule::weekend_default(), // Saturday
|
||||
DaySchedule::weekend_default(), // Sunday
|
||||
],
|
||||
|
||||
// Appearance
|
||||
dark_mode: true,
|
||||
color_scheme: "Ocean".to_string(),
|
||||
backdrop_opacity: 0.92,
|
||||
|
||||
// Notifications
|
||||
notification_enabled: true,
|
||||
notification_before_break: 30,
|
||||
|
||||
// Advanced
|
||||
snooze_duration: 5,
|
||||
snooze_limit: 3,
|
||||
skip_cooldown: 60,
|
||||
|
||||
// Sound
|
||||
sound_enabled: true,
|
||||
sound_volume: 70,
|
||||
sound_preset: "bell".to_string(),
|
||||
|
||||
// Idle detection
|
||||
idle_detection_enabled: true,
|
||||
idle_timeout: 120,
|
||||
|
||||
// Smart breaks
|
||||
smart_breaks_enabled: true,
|
||||
smart_break_threshold: 300, // 5 minutes
|
||||
smart_break_count_stats: false,
|
||||
|
||||
// Break activities
|
||||
show_break_activities: true,
|
||||
|
||||
// UI
|
||||
ui_zoom: 100,
|
||||
accent_color: "#ff4d00".to_string(),
|
||||
break_color: "#7c6aef".to_string(),
|
||||
countdown_font: String::new(),
|
||||
background_blobs_enabled: false,
|
||||
|
||||
// Mini mode
|
||||
mini_click_through: true,
|
||||
mini_hover_threshold: 3.0,
|
||||
|
||||
// Window positions
|
||||
main_window_x: None,
|
||||
main_window_y: None,
|
||||
main_window_width: None,
|
||||
main_window_height: None,
|
||||
mini_window_x: None,
|
||||
mini_window_y: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Get the path to the config file (portable: next to the exe)
|
||||
fn config_path() -> Result<PathBuf> {
|
||||
let exe_path = std::env::current_exe().context("Failed to determine exe path")?;
|
||||
let exe_dir = exe_path.parent().context("Failed to determine exe directory")?;
|
||||
Ok(exe_dir.join("config.json"))
|
||||
}
|
||||
|
||||
/// Load configuration from file, or return default if it doesn't exist
|
||||
pub fn load_or_default() -> Self {
|
||||
match Self::load() {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load config, using defaults: {}", e);
|
||||
let default = Self::default();
|
||||
// Try to save the default config
|
||||
if let Err(save_err) = default.save() {
|
||||
eprintln!("Failed to save default config: {}", save_err);
|
||||
}
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from file
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::config_path()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(anyhow::anyhow!("Config file does not exist"));
|
||||
}
|
||||
|
||||
let config_content =
|
||||
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||||
|
||||
let config: Config =
|
||||
serde_json::from_str(&config_content).context("Failed to parse config file")?;
|
||||
|
||||
// Validate and sanitize the loaded config
|
||||
Ok(config.validate())
|
||||
}
|
||||
|
||||
/// Save configuration to file
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let config_path = Self::config_path()?;
|
||||
|
||||
let config_content =
|
||||
serde_json::to_string_pretty(self).context("Failed to serialize config")?;
|
||||
|
||||
fs::write(&config_path, config_content).context("Failed to write config file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate and sanitize configuration values
|
||||
pub fn validate(mut self) -> Self {
|
||||
// Break duration: 1-60 minutes
|
||||
self.break_duration = self.break_duration.clamp(1, 60);
|
||||
|
||||
// Break frequency: 5-120 minutes
|
||||
self.break_frequency = self.break_frequency.clamp(5, 120);
|
||||
|
||||
// Notification before break: 0-300 seconds (0 = disabled)
|
||||
self.notification_before_break = self.notification_before_break.clamp(0, 300);
|
||||
|
||||
// Snooze duration: 1-30 minutes
|
||||
self.snooze_duration = self.snooze_duration.clamp(1, 30);
|
||||
|
||||
// Snooze limit: 0-5 (0 = unlimited)
|
||||
self.snooze_limit = self.snooze_limit.clamp(0, 5);
|
||||
|
||||
// Skip cooldown: 0-600 seconds (0 = disabled)
|
||||
self.skip_cooldown = self.skip_cooldown.clamp(0, 600);
|
||||
|
||||
// Backdrop opacity: 0.5-1.0
|
||||
self.backdrop_opacity = self.backdrop_opacity.clamp(0.5, 1.0);
|
||||
|
||||
// Validate break title
|
||||
if self.break_title.is_empty() {
|
||||
self.break_title = "Rest your eyes".to_string();
|
||||
} else if self.break_title.len() > 100 {
|
||||
self.break_title.truncate(100);
|
||||
}
|
||||
|
||||
// Validate working hours schedule
|
||||
if self.working_hours_schedule.len() != 7 {
|
||||
// Reset to default if invalid
|
||||
self.working_hours_schedule = vec![
|
||||
DaySchedule::default(), // Monday
|
||||
DaySchedule::default(), // Tuesday
|
||||
DaySchedule::default(), // Wednesday
|
||||
DaySchedule::default(), // Thursday
|
||||
DaySchedule::default(), // Friday
|
||||
DaySchedule::weekend_default(), // Saturday
|
||||
DaySchedule::weekend_default(), // Sunday
|
||||
];
|
||||
}
|
||||
|
||||
// Validate each day's ranges
|
||||
for day in &mut self.working_hours_schedule {
|
||||
// Ensure at least one range
|
||||
if day.ranges.is_empty() {
|
||||
day.ranges.push(TimeRange::default());
|
||||
}
|
||||
|
||||
// Validate each range
|
||||
for range in &mut day.ranges {
|
||||
if !Self::is_valid_time_format(&range.start) {
|
||||
range.start = "09:00".to_string();
|
||||
}
|
||||
if !Self::is_valid_time_format(&range.end) {
|
||||
range.end = "18:00".to_string();
|
||||
}
|
||||
|
||||
// Ensure start < end
|
||||
let start_mins = Self::time_to_minutes(&range.start);
|
||||
let end_mins = Self::time_to_minutes(&range.end);
|
||||
if start_mins >= end_mins {
|
||||
range.end = "18:00".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color scheme
|
||||
let valid_schemes = vec!["Ocean", "Forest", "Sunset", "Midnight", "Dawn"];
|
||||
if !valid_schemes.contains(&self.color_scheme.as_str()) {
|
||||
self.color_scheme = "Ocean".to_string();
|
||||
}
|
||||
|
||||
// Sound volume: 0-100
|
||||
self.sound_volume = self.sound_volume.clamp(0, 100);
|
||||
|
||||
// Validate sound preset
|
||||
let valid_presets = vec![
|
||||
"bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle",
|
||||
];
|
||||
if !valid_presets.contains(&self.sound_preset.as_str()) {
|
||||
self.sound_preset = "bell".to_string();
|
||||
}
|
||||
|
||||
// Idle timeout: 30-600 seconds
|
||||
self.idle_timeout = self.idle_timeout.clamp(30, 600);
|
||||
|
||||
// Smart break threshold: 120-900 seconds (2-15 minutes)
|
||||
self.smart_break_threshold = self.smart_break_threshold.clamp(120, 900);
|
||||
|
||||
// Mini hover threshold: 1.0-10.0 seconds
|
||||
self.mini_hover_threshold = self.mini_hover_threshold.clamp(1.0, 10.0);
|
||||
|
||||
// UI zoom: 50-200%
|
||||
self.ui_zoom = self.ui_zoom.clamp(50, 200);
|
||||
|
||||
// Validate color hex strings
|
||||
if !Self::is_valid_hex_color(&self.accent_color) {
|
||||
self.accent_color = "#ff4d00".to_string();
|
||||
}
|
||||
if !Self::is_valid_hex_color(&self.break_color) {
|
||||
self.break_color = "#7c6aef".to_string();
|
||||
}
|
||||
|
||||
// Validate break message length
|
||||
if self.break_message.is_empty() {
|
||||
self.break_message = "Time for a break! Stretch and relax your eyes.".to_string();
|
||||
} else if self.break_message.len() > 500 {
|
||||
self.break_message.truncate(500);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a time string is in valid HH:MM format
|
||||
fn is_valid_time_format(time: &str) -> bool {
|
||||
let parts: Vec<&str> = time.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let (Ok(hours), Ok(minutes)) = (parts[0].parse::<u8>(), parts[1].parse::<u8>()) {
|
||||
hours < 24 && minutes < 60
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert HH:MM time string to minutes since midnight
|
||||
pub fn time_to_minutes(time: &str) -> u32 {
|
||||
let parts: Vec<&str> = time.split(':').collect();
|
||||
if parts.len() != 2 {
|
||||
return 0;
|
||||
}
|
||||
let hours = parts[0].parse::<u32>().unwrap_or(0);
|
||||
let minutes = parts[1].parse::<u32>().unwrap_or(0);
|
||||
hours * 60 + minutes
|
||||
}
|
||||
|
||||
/// Check if a string is a valid hex color (#RRGGBB)
|
||||
fn is_valid_hex_color(color: &str) -> bool {
|
||||
color.len() == 7
|
||||
&& color.starts_with('#')
|
||||
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Get break duration in seconds
|
||||
pub fn break_duration_seconds(&self) -> u64 {
|
||||
self.break_duration as u64 * 60
|
||||
}
|
||||
|
||||
/// Get break frequency in seconds
|
||||
pub fn break_frequency_seconds(&self) -> u64 {
|
||||
self.break_frequency as u64 * 60
|
||||
}
|
||||
|
||||
/// Get snooze duration in seconds
|
||||
pub fn snooze_duration_seconds(&self) -> u64 {
|
||||
self.snooze_duration as u64 * 60
|
||||
}
|
||||
|
||||
/// Reset to default values
|
||||
pub fn reset_to_default(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
688
src-tauri/src/lib.rs
Normal file
688
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,688 @@
|
||||
mod config;
|
||||
mod stats;
|
||||
mod timer;
|
||||
|
||||
use config::Config;
|
||||
use stats::Stats;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::{TrayIcon, TrayIconBuilder},
|
||||
AppHandle, Emitter, Manager, State,
|
||||
};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
|
||||
|
||||
pub struct AppState {
|
||||
pub timer: Arc<Mutex<TimerManager>>,
|
||||
pub stats: Arc<Mutex<Stats>>,
|
||||
}
|
||||
|
||||
// ── Tauri Commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config(state: State<AppState>) -> Config {
|
||||
let timer = state.timer.lock().unwrap();
|
||||
timer.pending_config.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config(state: State<AppState>, config: Config) -> Result<(), String> {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.update_config(config);
|
||||
timer.save_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_pending_config(app: AppHandle, state: State<AppState>, config: Config) {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.update_config(config);
|
||||
// Notify all windows (break, mini) so they can pick up live config changes
|
||||
let _ = app.emit("config-changed", &());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn reset_config(state: State<AppState>) -> Config {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.reset_config();
|
||||
timer.pending_config.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn toggle_timer(state: State<AppState>) -> TimerSnapshot {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.toggle_timer();
|
||||
timer.snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_break_now(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
|
||||
let (snapshot, fullscreen_mode) = {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
let payload = timer.start_break_now();
|
||||
let fm = payload.as_ref().map(|p| p.fullscreen_mode);
|
||||
(timer.snapshot(), fm)
|
||||
};
|
||||
// Window creation must happen after dropping the mutex
|
||||
if let Some(fm) = fullscreen_mode {
|
||||
handle_break_start(&app, fm);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel_break(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
|
||||
let (snapshot, should_end) = {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
let was_break = timer.state == timer::TimerState::BreakActive;
|
||||
timer.cancel_break();
|
||||
let ended = was_break && timer.state != timer::TimerState::BreakActive;
|
||||
if ended {
|
||||
let mut s = state.stats.lock().unwrap();
|
||||
s.record_break_skipped();
|
||||
}
|
||||
(timer.snapshot(), ended)
|
||||
};
|
||||
if should_end {
|
||||
handle_break_end(&app);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn snooze(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
|
||||
let (snapshot, should_end) = {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
let was_break = timer.state == timer::TimerState::BreakActive;
|
||||
timer.snooze();
|
||||
let ended = was_break && timer.state != timer::TimerState::BreakActive;
|
||||
if ended {
|
||||
let mut s = state.stats.lock().unwrap();
|
||||
s.record_break_snoozed();
|
||||
}
|
||||
(timer.snapshot(), ended)
|
||||
};
|
||||
if should_end {
|
||||
handle_break_end(&app);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_timer_state(state: State<AppState>) -> TimerSnapshot {
|
||||
let timer = state.timer.lock().unwrap();
|
||||
timer.snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_view(state: State<AppState>, view: AppView) {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.set_view(view);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
|
||||
let s = state.stats.lock().unwrap();
|
||||
s.snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord> {
|
||||
let s = state.stats.lock().unwrap();
|
||||
s.recent_days(days)
|
||||
}
|
||||
|
||||
// ── Cursor / Window Position Commands ────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cursor_position() -> (i32, i32) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use winapi::shared::windef::POINT;
|
||||
use winapi::um::winuser::GetCursorPos;
|
||||
let mut point = POINT { x: 0, y: 0 };
|
||||
unsafe {
|
||||
GetCursorPos(&mut point);
|
||||
}
|
||||
(point.x, point.y)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_window_position(
|
||||
state: State<AppState>,
|
||||
label: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) {
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
match label.as_str() {
|
||||
"main" => {
|
||||
timer.pending_config.main_window_x = Some(x);
|
||||
timer.pending_config.main_window_y = Some(y);
|
||||
timer.pending_config.main_window_width = Some(width);
|
||||
timer.pending_config.main_window_height = Some(height);
|
||||
}
|
||||
"mini" => {
|
||||
timer.pending_config.mini_window_x = Some(x);
|
||||
timer.pending_config.mini_window_y = Some(y);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = timer.save_config();
|
||||
}
|
||||
|
||||
// ── Dynamic Tray Icon Rendering ────────────────────────────────────────────
|
||||
|
||||
/// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input.
|
||||
fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
|
||||
if hex.len() == 7 && hex.starts_with('#') {
|
||||
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
|
||||
let g = u8::from_str_radix(&hex[3..5], 16).unwrap_or(fallback.1);
|
||||
let b = u8::from_str_radix(&hex[5..7], 16).unwrap_or(fallback.2);
|
||||
(r, g, b)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
|
||||
fn render_tray_icon(
|
||||
progress: f64,
|
||||
is_break: bool,
|
||||
is_paused: bool,
|
||||
accent: (u8, u8, u8),
|
||||
break_color: (u8, u8, u8),
|
||||
) -> Vec<u8> {
|
||||
let size: usize = 32;
|
||||
let mut rgba = vec![0u8; size * size * 4];
|
||||
let center = size as f64 / 2.0;
|
||||
let outer_r = center - 1.0;
|
||||
let inner_r = outer_r - 4.0;
|
||||
|
||||
let arc_color = if is_break { break_color } else { accent };
|
||||
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = x as f64 - center;
|
||||
let dy = y as f64 - center;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
let idx = (y * size + x) * 4;
|
||||
|
||||
if dist >= inner_r && dist <= outer_r {
|
||||
// Determine angle (0 at top, clockwise)
|
||||
let angle = (dx.atan2(-dy) + std::f64::consts::PI) / (2.0 * std::f64::consts::PI);
|
||||
|
||||
let in_arc = angle <= progress;
|
||||
|
||||
if in_arc {
|
||||
rgba[idx] = arc_color.0;
|
||||
rgba[idx + 1] = arc_color.1;
|
||||
rgba[idx + 2] = arc_color.2;
|
||||
rgba[idx + 3] = 255;
|
||||
} else {
|
||||
// Background ring
|
||||
rgba[idx] = 60;
|
||||
rgba[idx + 1] = 60;
|
||||
rgba[idx + 2] = 60;
|
||||
rgba[idx + 3] = if is_paused { 100 } else { 180 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rgba
|
||||
}
|
||||
|
||||
fn update_tray(
|
||||
tray: &TrayIcon,
|
||||
snapshot: &TimerSnapshot,
|
||||
accent: (u8, u8, u8),
|
||||
break_color: (u8, u8, u8),
|
||||
) {
|
||||
// Update tooltip
|
||||
let tooltip = match snapshot.state {
|
||||
timer::TimerState::Running => {
|
||||
let m = snapshot.time_remaining / 60;
|
||||
let s = snapshot.time_remaining % 60;
|
||||
format!("Core Cooldown — {:02}:{:02} until break", m, s)
|
||||
}
|
||||
timer::TimerState::Paused => {
|
||||
if snapshot.idle_paused {
|
||||
"Core Cooldown — Paused (idle)".to_string()
|
||||
} else {
|
||||
"Core Cooldown — Paused".to_string()
|
||||
}
|
||||
}
|
||||
timer::TimerState::BreakActive => {
|
||||
let m = snapshot.break_time_remaining / 60;
|
||||
let s = snapshot.break_time_remaining % 60;
|
||||
format!("Core Cooldown — Break {:02}:{:02}", m, s)
|
||||
}
|
||||
};
|
||||
let _ = tray.set_tooltip(Some(&tooltip));
|
||||
|
||||
// Update icon
|
||||
let (progress, is_break, is_paused) = match snapshot.state {
|
||||
timer::TimerState::Running => (snapshot.progress, false, false),
|
||||
timer::TimerState::Paused => (snapshot.progress, false, true),
|
||||
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
|
||||
};
|
||||
|
||||
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color);
|
||||
let icon = Image::new_owned(icon_data, 32, 32);
|
||||
let _ = tray.set_icon(Some(icon));
|
||||
}
|
||||
|
||||
// ── App Builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.setup(|app| {
|
||||
// Portable data directory for WebView2 data (next to the exe)
|
||||
let data_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("data")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("data"));
|
||||
|
||||
// Create main window (was previously in tauri.conf.json)
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"main",
|
||||
tauri::WebviewUrl::App("index.html".into()),
|
||||
)
|
||||
.title("Core Cooldown")
|
||||
.inner_size(480.0, 700.0)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(true)
|
||||
.data_directory(data_dir.clone())
|
||||
.build()?;
|
||||
|
||||
// Create break window (hidden, was previously in tauri.conf.json)
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"break",
|
||||
tauri::WebviewUrl::App("index.html?break=1".into()),
|
||||
)
|
||||
.title("Core Cooldown \u{2014} Break")
|
||||
.inner_size(900.0, 540.0)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.visible(false)
|
||||
.center()
|
||||
.data_directory(data_dir.clone())
|
||||
.build()?;
|
||||
|
||||
let timer_manager = TimerManager::new();
|
||||
let loaded_stats = Stats::load_or_default();
|
||||
let state = AppState {
|
||||
timer: Arc::new(Mutex::new(timer_manager)),
|
||||
stats: Arc::new(Mutex::new(loaded_stats)),
|
||||
};
|
||||
app.manage(state);
|
||||
|
||||
// Restore saved window position
|
||||
{
|
||||
let st = app.state::<AppState>();
|
||||
let timer = st.timer.lock().unwrap();
|
||||
let cfg = &timer.pending_config;
|
||||
if let (Some(x), Some(y)) = (cfg.main_window_x, cfg.main_window_y) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.set_position(tauri::Position::Physical(
|
||||
tauri::PhysicalPosition::new(x, y),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let (Some(w), Some(h)) = (cfg.main_window_width, cfg.main_window_height) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up system tray
|
||||
let tray = setup_tray(app.handle())?;
|
||||
|
||||
// Set up global shortcuts
|
||||
setup_shortcuts(app.handle());
|
||||
|
||||
// Start the timer tick thread
|
||||
let handle = app.handle().clone();
|
||||
let timer_ref = app.state::<AppState>().timer.clone();
|
||||
let stats_ref = app.state::<AppState>().stats.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
let (tick_result, snapshot, accent_hex, break_hex) = {
|
||||
let mut timer = timer_ref.lock().unwrap();
|
||||
let result = timer.tick();
|
||||
let snap = timer.snapshot();
|
||||
let ac = timer.config.accent_color.clone();
|
||||
let bc = timer.config.break_color.clone();
|
||||
(result, snap, ac, bc)
|
||||
};
|
||||
|
||||
// Update tray icon and tooltip with configured colors
|
||||
let accent = parse_hex_color(&accent_hex, (255, 77, 0));
|
||||
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
|
||||
update_tray(&tray, &snapshot, accent, break_c);
|
||||
|
||||
// Emit tick event with full snapshot
|
||||
let _ = handle.emit("timer-tick", &snapshot);
|
||||
|
||||
// Emit specific events for state transitions
|
||||
match tick_result {
|
||||
TickResult::BreakStarted(payload) => {
|
||||
handle_break_start(&handle, payload.fullscreen_mode);
|
||||
let _ = handle.emit("break-started", &payload);
|
||||
}
|
||||
TickResult::BreakEnded => {
|
||||
// Restore normal window state and close break window
|
||||
handle_break_end(&handle);
|
||||
// Record completed break in stats
|
||||
{
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
let mut s = stats_ref.lock().unwrap();
|
||||
s.record_break_completed(timer.break_total_duration);
|
||||
}
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Break complete")
|
||||
.body("Great job! Back to work.")
|
||||
.show();
|
||||
let _ = handle.emit("break-ended", &());
|
||||
}
|
||||
TickResult::PreBreakWarning {
|
||||
seconds_until_break,
|
||||
} => {
|
||||
let secs = seconds_until_break;
|
||||
let msg = if secs >= 60 {
|
||||
format!(
|
||||
"Break in {} minute{}",
|
||||
secs / 60,
|
||||
if secs / 60 == 1 { "" } else { "s" }
|
||||
)
|
||||
} else {
|
||||
format!("Break in {} seconds", secs)
|
||||
};
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Core Cooldown")
|
||||
.body(&msg)
|
||||
.show();
|
||||
let _ = handle.emit("prebreak-warning", &secs);
|
||||
}
|
||||
TickResult::NaturalBreakDetected { duration_seconds } => {
|
||||
// Record natural break in stats if enabled
|
||||
{
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
if timer.config.smart_break_count_stats {
|
||||
let mut s = stats_ref.lock().unwrap();
|
||||
s.record_natural_break(duration_seconds);
|
||||
}
|
||||
}
|
||||
let mins = duration_seconds / 60;
|
||||
let msg = if mins >= 1 {
|
||||
format!(
|
||||
"You've been away for {} minute{}. Break timer has been reset.",
|
||||
mins,
|
||||
if mins == 1 { "" } else { "s" }
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"You've been away for {} seconds. Break timer has been reset.",
|
||||
duration_seconds
|
||||
)
|
||||
};
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Natural break detected")
|
||||
.body(&msg)
|
||||
.show();
|
||||
let _ = handle.emit("natural-break-detected", &duration_seconds);
|
||||
}
|
||||
TickResult::None => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// Handle window close events - only exit when the main window is closed
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if window.label() == "main" {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
// Mini and break windows just close normally without killing the app
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_config,
|
||||
save_config,
|
||||
update_pending_config,
|
||||
reset_config,
|
||||
toggle_timer,
|
||||
start_break_now,
|
||||
cancel_break,
|
||||
snooze,
|
||||
get_timer_state,
|
||||
set_view,
|
||||
get_stats,
|
||||
get_daily_history,
|
||||
get_cursor_position,
|
||||
save_window_position,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn setup_tray(app: &AppHandle) -> Result<TrayIcon, Box<dyn std::error::Error>> {
|
||||
let show_i = MenuItemBuilder::with_id("show", "Show").build(app)?;
|
||||
let pause_i = MenuItemBuilder::with_id("pause", "Pause/Resume").build(app)?;
|
||||
let mini_i = MenuItemBuilder::with_id("mini", "Mini Mode").build(app)?;
|
||||
let quit_i = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show_i)
|
||||
.item(&pause_i)
|
||||
.item(&mini_i)
|
||||
.separator()
|
||||
.item(&quit_i)
|
||||
.build()?;
|
||||
|
||||
let tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("Core Cooldown")
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"pause" => {
|
||||
let state: State<AppState> = app.state();
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.toggle_timer();
|
||||
}
|
||||
"mini" => {
|
||||
toggle_mini_window(app);
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(tray)
|
||||
}
|
||||
|
||||
// ── Global Shortcuts ───────────────────────────────────────────────────────
|
||||
|
||||
fn setup_shortcuts(app: &AppHandle) {
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
|
||||
let _ = app
|
||||
.global_shortcut()
|
||||
.on_shortcut("Ctrl+Shift+P", move |_app, _shortcut, event| {
|
||||
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
|
||||
let state: State<AppState> = _app.state();
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
timer.toggle_timer();
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app
|
||||
.global_shortcut()
|
||||
.on_shortcut("Ctrl+Shift+B", move |_app, _shortcut, event| {
|
||||
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
|
||||
let state: State<AppState> = _app.state();
|
||||
let mut timer = state.timer.lock().unwrap();
|
||||
let payload = timer.start_break_now();
|
||||
if let Some(ref p) = payload {
|
||||
handle_break_start(_app, p.fullscreen_mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app
|
||||
.global_shortcut()
|
||||
.on_shortcut("Ctrl+Shift+S", move |_app, _shortcut, event| {
|
||||
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
|
||||
if let Some(window) = _app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Break Window ────────────────────────────────────────────────────────────
|
||||
|
||||
fn open_break_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("break") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn close_break_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("break") {
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
|
||||
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
|
||||
if fullscreen_mode {
|
||||
// Fullscreen: show break inside the main window
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_always_on_top(true);
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.set_fullscreen(true);
|
||||
}
|
||||
} else {
|
||||
// Modal: open a separate centered break window
|
||||
open_break_window(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle break end: restore main window state and close break window if open.
|
||||
fn handle_break_end(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_always_on_top(false);
|
||||
let _ = window.set_fullscreen(false);
|
||||
}
|
||||
close_break_window(app);
|
||||
}
|
||||
|
||||
// ── Mini Mode ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn toggle_mini_window(app: &AppHandle) {
|
||||
if let Some(mini) = app.get_webview_window("mini") {
|
||||
// Close existing mini window
|
||||
let _ = mini.close();
|
||||
} else {
|
||||
// Read saved position from config
|
||||
let (mx, my) = {
|
||||
let st: State<AppState> = app.state();
|
||||
let timer = st.timer.lock().unwrap();
|
||||
(
|
||||
timer.pending_config.mini_window_x,
|
||||
timer.pending_config.mini_window_y,
|
||||
)
|
||||
};
|
||||
|
||||
// Portable data directory for WebView2
|
||||
let data_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("data")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("data"));
|
||||
|
||||
// Create mini window
|
||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"mini",
|
||||
tauri::WebviewUrl::App("index.html?mini=1".into()),
|
||||
)
|
||||
.title("Core Cooldown")
|
||||
.inner_size(184.0, 92.0)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.data_directory(data_dir);
|
||||
|
||||
if let (Some(x), Some(y)) = (mx, my) {
|
||||
builder = builder.position(x as f64, y as f64);
|
||||
}
|
||||
|
||||
let _ = builder.build();
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
core_cooldown_lib::run()
|
||||
}
|
||||
199
src-tauri/src/stats.rs
Normal file
199
src-tauri/src/stats.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A single day's break statistics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayRecord {
|
||||
pub date: String, // YYYY-MM-DD
|
||||
pub breaks_completed: u32,
|
||||
pub breaks_skipped: u32,
|
||||
pub breaks_snoozed: u32,
|
||||
pub total_break_time_secs: u64,
|
||||
pub natural_breaks: u32,
|
||||
pub natural_break_time_secs: u64,
|
||||
}
|
||||
|
||||
/// Persistent stats stored as JSON.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct StatsData {
|
||||
pub days: HashMap<String, DayRecord>,
|
||||
pub current_streak: u32,
|
||||
pub best_streak: u32,
|
||||
}
|
||||
|
||||
/// Runtime stats manager.
|
||||
pub struct Stats {
|
||||
pub data: StatsData,
|
||||
}
|
||||
|
||||
/// Snapshot sent to the frontend.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatsSnapshot {
|
||||
pub today_completed: u32,
|
||||
pub today_skipped: u32,
|
||||
pub today_snoozed: u32,
|
||||
pub today_break_time_secs: u64,
|
||||
pub today_natural_breaks: u32,
|
||||
pub today_natural_break_time_secs: u64,
|
||||
pub compliance_rate: f64,
|
||||
pub current_streak: u32,
|
||||
pub best_streak: u32,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
/// Portable: stats file lives next to the exe
|
||||
fn stats_path() -> Option<PathBuf> {
|
||||
let exe_path = std::env::current_exe().ok()?;
|
||||
let exe_dir = exe_path.parent()?;
|
||||
Some(exe_dir.join("stats.json"))
|
||||
}
|
||||
|
||||
pub fn load_or_default() -> Self {
|
||||
let data = if let Some(path) = Self::stats_path() {
|
||||
if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
StatsData::default()
|
||||
}
|
||||
} else {
|
||||
StatsData::default()
|
||||
};
|
||||
Stats { data }
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
if let Some(path) = Self::stats_path() {
|
||||
if let Ok(json) = serde_json::to_string_pretty(&self.data) {
|
||||
let _ = fs::write(path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn today_key() -> String {
|
||||
chrono::Local::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
fn today_mut(&mut self) -> &mut DayRecord {
|
||||
let key = Self::today_key();
|
||||
self.data
|
||||
.days
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| DayRecord {
|
||||
date: key,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record_break_completed(&mut self, duration_secs: u64) {
|
||||
let day = self.today_mut();
|
||||
day.breaks_completed += 1;
|
||||
day.total_break_time_secs += duration_secs;
|
||||
self.update_streak();
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn record_break_skipped(&mut self) {
|
||||
let day = self.today_mut();
|
||||
day.breaks_skipped += 1;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn record_break_snoozed(&mut self) {
|
||||
let day = self.today_mut();
|
||||
day.breaks_snoozed += 1;
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn record_natural_break(&mut self, duration_secs: u64) {
|
||||
let day = self.today_mut();
|
||||
day.natural_breaks += 1;
|
||||
day.natural_break_time_secs += duration_secs;
|
||||
self.save();
|
||||
}
|
||||
|
||||
fn update_streak(&mut self) {
|
||||
// Calculate streak: consecutive days with at least 1 break completed
|
||||
let mut streak = 0u32;
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
for i in 0.. {
|
||||
let day = today - chrono::Duration::days(i);
|
||||
let key = day.format("%Y-%m-%d").to_string();
|
||||
if let Some(record) = self.data.days.get(&key) {
|
||||
if record.breaks_completed > 0 {
|
||||
streak += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if i == 0 {
|
||||
// Today hasn't been recorded yet (but we just did), continue
|
||||
streak += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.data.current_streak = streak;
|
||||
if streak > self.data.best_streak {
|
||||
self.data.best_streak = streak;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> StatsSnapshot {
|
||||
let key = Self::today_key();
|
||||
let today = self.data.days.get(&key);
|
||||
|
||||
let completed = today.map(|d| d.breaks_completed).unwrap_or(0);
|
||||
let skipped = today.map(|d| d.breaks_skipped).unwrap_or(0);
|
||||
let snoozed = today.map(|d| d.breaks_snoozed).unwrap_or(0);
|
||||
let break_time = today.map(|d| d.total_break_time_secs).unwrap_or(0);
|
||||
let natural_breaks = today.map(|d| d.natural_breaks).unwrap_or(0);
|
||||
let natural_break_time = today.map(|d| d.natural_break_time_secs).unwrap_or(0);
|
||||
|
||||
let total = completed + skipped;
|
||||
let compliance = if total > 0 {
|
||||
completed as f64 / total as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
StatsSnapshot {
|
||||
today_completed: completed,
|
||||
today_skipped: skipped,
|
||||
today_snoozed: snoozed,
|
||||
today_break_time_secs: break_time,
|
||||
today_natural_breaks: natural_breaks,
|
||||
today_natural_break_time_secs: natural_break_time,
|
||||
compliance_rate: compliance,
|
||||
current_streak: self.data.current_streak,
|
||||
best_streak: self.data.best_streak,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recent N days of history, sorted chronologically.
|
||||
pub fn recent_days(&self, n: u32) -> Vec<DayRecord> {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let mut records = Vec::new();
|
||||
|
||||
for i in (0..n).rev() {
|
||||
let day = today - chrono::Duration::days(i as i64);
|
||||
let key = day.format("%Y-%m-%d").to_string();
|
||||
let record = self.data.days.get(&key).cloned().unwrap_or(DayRecord {
|
||||
date: key,
|
||||
..Default::default()
|
||||
});
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
}
|
||||
496
src-tauri/src/timer.rs
Normal file
496
src-tauri/src/timer.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
use crate::config::Config;
|
||||
use chrono::{Datelike, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TimerState {
|
||||
Running,
|
||||
Paused,
|
||||
BreakActive,
|
||||
}
|
||||
|
||||
impl Default for TimerState {
|
||||
fn default() -> Self {
|
||||
Self::Paused
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AppView {
|
||||
Dashboard,
|
||||
BreakScreen,
|
||||
Settings,
|
||||
Stats,
|
||||
}
|
||||
|
||||
/// Snapshot of the full timer state, sent to the frontend on every tick
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimerSnapshot {
|
||||
pub state: TimerState,
|
||||
pub current_view: AppView,
|
||||
pub time_remaining: u64,
|
||||
pub total_duration: u64,
|
||||
pub progress: f64,
|
||||
pub has_had_break: bool,
|
||||
pub seconds_since_last_break: u64,
|
||||
pub prebreak_warning: bool,
|
||||
pub snoozes_used: u32,
|
||||
pub can_snooze: bool,
|
||||
pub break_title: String,
|
||||
pub break_message: String,
|
||||
pub break_progress: f64,
|
||||
pub break_time_remaining: u64,
|
||||
pub break_total_duration: u64,
|
||||
pub break_past_half: bool,
|
||||
pub settings_modified: bool,
|
||||
pub idle_paused: bool,
|
||||
pub natural_break_occurred: bool,
|
||||
pub smart_breaks_enabled: bool,
|
||||
pub smart_break_threshold: u32,
|
||||
}
|
||||
|
||||
/// Events emitted by the timer to the frontend
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BreakStartedPayload {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub duration: u64,
|
||||
pub strict_mode: bool,
|
||||
pub snooze_duration: u32,
|
||||
pub fullscreen_mode: bool,
|
||||
}
|
||||
|
||||
pub struct TimerManager {
|
||||
pub state: TimerState,
|
||||
pub current_view: AppView,
|
||||
pub config: Config,
|
||||
pub time_until_next_break: u64,
|
||||
pub time_until_break_end: u64,
|
||||
pub break_total_duration: u64,
|
||||
pub snoozes_used: u32,
|
||||
pub seconds_since_last_break: u64,
|
||||
pub has_had_break: bool,
|
||||
pub prebreak_notification_active: bool,
|
||||
pub settings_modified: bool,
|
||||
// Pending config: edited in settings but not yet saved
|
||||
pub pending_config: Config,
|
||||
// Idle detection: true when auto-paused due to inactivity
|
||||
pub idle_paused: bool,
|
||||
// Smart breaks: track when idle started for natural break detection
|
||||
pub idle_start_time: Option<Instant>,
|
||||
pub natural_break_occurred: bool,
|
||||
}
|
||||
|
||||
impl TimerManager {
|
||||
pub fn new() -> Self {
|
||||
let config = Config::load_or_default();
|
||||
let freq = config.break_frequency_seconds();
|
||||
let pending = config.clone();
|
||||
let auto_start = config.auto_start;
|
||||
|
||||
Self {
|
||||
state: if auto_start {
|
||||
TimerState::Running
|
||||
} else {
|
||||
TimerState::Paused
|
||||
},
|
||||
current_view: AppView::Dashboard,
|
||||
config,
|
||||
time_until_next_break: freq,
|
||||
time_until_break_end: 0,
|
||||
break_total_duration: 0,
|
||||
snoozes_used: 0,
|
||||
seconds_since_last_break: 0,
|
||||
has_had_break: false,
|
||||
prebreak_notification_active: false,
|
||||
settings_modified: false,
|
||||
pending_config: pending,
|
||||
idle_paused: false,
|
||||
idle_start_time: None,
|
||||
natural_break_occurred: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check idle state and auto-pause/resume accordingly.
|
||||
/// Returns IdleCheckResult indicating what happened.
|
||||
pub fn check_idle(&mut self) -> IdleCheckResult {
|
||||
if !self.config.idle_detection_enabled {
|
||||
// If idle detection disabled but we were idle-paused, resume
|
||||
if self.idle_paused {
|
||||
self.idle_paused = false;
|
||||
self.state = TimerState::Running;
|
||||
}
|
||||
return IdleCheckResult::None;
|
||||
}
|
||||
|
||||
let idle_secs = get_idle_seconds();
|
||||
let threshold = self.config.idle_timeout as u64;
|
||||
|
||||
if idle_secs >= threshold && self.state == TimerState::Running {
|
||||
// Just became idle - record start time for smart breaks
|
||||
if self.idle_start_time.is_none() {
|
||||
self.idle_start_time = Some(Instant::now());
|
||||
}
|
||||
self.idle_paused = true;
|
||||
self.state = TimerState::Paused;
|
||||
IdleCheckResult::JustPaused
|
||||
} else if idle_secs < threshold && self.idle_paused {
|
||||
// Just returned from idle
|
||||
self.idle_paused = false;
|
||||
self.state = TimerState::Running;
|
||||
|
||||
// Check if this was a natural break
|
||||
if let Some(start_time) = self.idle_start_time {
|
||||
let idle_duration = start_time.elapsed().as_secs();
|
||||
self.idle_start_time = None;
|
||||
|
||||
if self.config.smart_breaks_enabled
|
||||
&& self.state != TimerState::BreakActive
|
||||
&& idle_duration >= self.config.smart_break_threshold as u64
|
||||
{
|
||||
// Natural break detected!
|
||||
return IdleCheckResult::NaturalBreakDetected(idle_duration);
|
||||
}
|
||||
}
|
||||
|
||||
IdleCheckResult::JustResumed
|
||||
} else {
|
||||
// Still idle - update idle_start_time if needed
|
||||
if self.state == TimerState::Paused
|
||||
&& self.idle_start_time.is_none()
|
||||
&& idle_secs >= threshold
|
||||
{
|
||||
// We were already paused when idle detection kicked in
|
||||
// Start tracking from now
|
||||
self.idle_start_time = Some(Instant::now());
|
||||
}
|
||||
IdleCheckResult::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every second. Returns what events should be emitted.
|
||||
pub fn tick(&mut self) -> TickResult {
|
||||
// Idle detection and natural break detection
|
||||
let idle_result = self.check_idle();
|
||||
|
||||
// Handle natural break detection
|
||||
if let IdleCheckResult::NaturalBreakDetected(duration) = idle_result {
|
||||
// Reset the timer completely since they took a natural break
|
||||
self.reset_timer();
|
||||
self.has_had_break = true;
|
||||
self.seconds_since_last_break = 0;
|
||||
self.natural_break_occurred = true;
|
||||
return TickResult::NaturalBreakDetected {
|
||||
duration_seconds: duration,
|
||||
};
|
||||
}
|
||||
|
||||
// Track time since last break (always, even when paused)
|
||||
if self.has_had_break && self.state != TimerState::BreakActive {
|
||||
self.seconds_since_last_break += 1;
|
||||
}
|
||||
|
||||
match self.state {
|
||||
TimerState::Running => {
|
||||
if !self.is_within_working_hours() {
|
||||
return TickResult::None;
|
||||
}
|
||||
|
||||
// Pre-break notification
|
||||
let threshold = self.config.notification_before_break as u64;
|
||||
let fire_prebreak = self.config.notification_enabled
|
||||
&& !self.config.immediately_start_breaks
|
||||
&& threshold > 0
|
||||
&& self.time_until_next_break <= threshold
|
||||
&& !self.prebreak_notification_active;
|
||||
|
||||
if fire_prebreak {
|
||||
self.prebreak_notification_active = true;
|
||||
}
|
||||
|
||||
if self.time_until_next_break > 0 {
|
||||
self.time_until_next_break -= 1;
|
||||
if fire_prebreak {
|
||||
TickResult::PreBreakWarning {
|
||||
seconds_until_break: self.time_until_next_break,
|
||||
}
|
||||
} else {
|
||||
TickResult::None
|
||||
}
|
||||
} else {
|
||||
self.start_break();
|
||||
TickResult::BreakStarted(BreakStartedPayload {
|
||||
title: self.config.break_title.clone(),
|
||||
message: self.config.break_message.clone(),
|
||||
duration: self.break_total_duration,
|
||||
strict_mode: self.config.strict_mode,
|
||||
snooze_duration: self.config.snooze_duration,
|
||||
fullscreen_mode: self.config.fullscreen_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
TimerState::BreakActive => {
|
||||
if self.time_until_break_end > 0 {
|
||||
self.time_until_break_end -= 1;
|
||||
TickResult::None
|
||||
} else {
|
||||
// Break completed naturally
|
||||
self.has_had_break = true;
|
||||
self.seconds_since_last_break = 0;
|
||||
self.reset_timer();
|
||||
TickResult::BreakEnded
|
||||
}
|
||||
}
|
||||
TimerState::Paused => TickResult::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_break(&mut self) {
|
||||
self.state = TimerState::BreakActive;
|
||||
self.current_view = AppView::BreakScreen;
|
||||
self.break_total_duration = self.config.break_duration_seconds();
|
||||
self.time_until_break_end = self.break_total_duration;
|
||||
self.prebreak_notification_active = false;
|
||||
self.snoozes_used = 0;
|
||||
}
|
||||
|
||||
pub fn reset_timer(&mut self) {
|
||||
self.state = TimerState::Running;
|
||||
self.current_view = AppView::Dashboard;
|
||||
self.time_until_next_break = self.config.break_frequency_seconds();
|
||||
self.prebreak_notification_active = false;
|
||||
}
|
||||
|
||||
pub fn toggle_timer(&mut self) {
|
||||
match self.state {
|
||||
TimerState::Running => {
|
||||
self.state = TimerState::Paused;
|
||||
}
|
||||
TimerState::Paused => {
|
||||
self.state = TimerState::Running;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
|
||||
if self.state == TimerState::Running || self.state == TimerState::Paused {
|
||||
self.start_break();
|
||||
Some(BreakStartedPayload {
|
||||
title: self.config.break_title.clone(),
|
||||
message: self.config.break_message.clone(),
|
||||
duration: self.break_total_duration,
|
||||
strict_mode: self.config.strict_mode,
|
||||
snooze_duration: self.config.snooze_duration,
|
||||
fullscreen_mode: self.config.fullscreen_mode,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_break(&mut self) -> bool {
|
||||
if self.state != TimerState::BreakActive {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.config.strict_mode {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = self.break_total_duration;
|
||||
let elapsed = total.saturating_sub(self.time_until_break_end);
|
||||
let past_half = total > 0 && elapsed * 2 >= total;
|
||||
|
||||
if past_half && self.config.allow_end_early {
|
||||
// "End break" — counts as completed
|
||||
self.has_had_break = true;
|
||||
self.seconds_since_last_break = 0;
|
||||
self.reset_timer();
|
||||
true
|
||||
} else if !past_half {
|
||||
// "Cancel break" — doesn't count
|
||||
self.reset_timer();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snooze(&mut self) -> bool {
|
||||
if self.config.strict_mode || !self.can_snooze() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.snoozes_used += 1;
|
||||
self.time_until_next_break = self.config.snooze_duration_seconds();
|
||||
self.state = TimerState::Running;
|
||||
self.current_view = AppView::Dashboard;
|
||||
self.prebreak_notification_active = false;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn can_snooze(&self) -> bool {
|
||||
if self.config.snooze_limit == 0 {
|
||||
return true; // unlimited
|
||||
}
|
||||
self.snoozes_used < self.config.snooze_limit
|
||||
}
|
||||
|
||||
pub fn is_within_working_hours(&self) -> bool {
|
||||
if !self.config.working_hours_enabled {
|
||||
return true;
|
||||
}
|
||||
|
||||
let now = chrono::Local::now();
|
||||
let day_of_week = now.weekday().num_days_from_monday() as usize; // 0 = Monday, 6 = Sunday
|
||||
let current_minutes = now.hour() * 60 + now.minute();
|
||||
|
||||
// Get the schedule for today
|
||||
if let Some(day_schedule) = self.config.working_hours_schedule.get(day_of_week) {
|
||||
if !day_schedule.enabled {
|
||||
return false; // Day is disabled, timer doesn't run
|
||||
}
|
||||
|
||||
// Check if current time falls within any of the time ranges
|
||||
for range in &day_schedule.ranges {
|
||||
let start_mins = Config::time_to_minutes(&range.start);
|
||||
let end_mins = Config::time_to_minutes(&range.end);
|
||||
|
||||
if current_minutes >= start_mins && current_minutes < end_mins {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false // Not within any time range
|
||||
}
|
||||
|
||||
pub fn set_view(&mut self, view: AppView) {
|
||||
self.current_view = view;
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config: Config) {
|
||||
self.pending_config = config;
|
||||
self.settings_modified = true;
|
||||
}
|
||||
|
||||
pub fn save_config(&mut self) -> Result<(), String> {
|
||||
let validated = self.pending_config.clone().validate();
|
||||
validated.save().map_err(|e| e.to_string())?;
|
||||
self.config = validated.clone();
|
||||
self.pending_config = validated;
|
||||
self.settings_modified = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reset_config(&mut self) {
|
||||
self.pending_config = Config::default();
|
||||
self.settings_modified = true;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> TimerSnapshot {
|
||||
let total_freq = self.config.break_frequency_seconds();
|
||||
let timer_progress = if total_freq > 0 {
|
||||
self.time_until_next_break as f64 / total_freq as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let break_elapsed = self
|
||||
.break_total_duration
|
||||
.saturating_sub(self.time_until_break_end);
|
||||
let break_progress = if self.break_total_duration > 0 {
|
||||
break_elapsed as f64 / self.break_total_duration as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let break_past_half =
|
||||
self.break_total_duration > 0 && break_elapsed * 2 >= self.break_total_duration;
|
||||
|
||||
// Use pending_config for settings display, active config for timer
|
||||
let display_config = if self.current_view == AppView::Settings {
|
||||
&self.pending_config
|
||||
} else {
|
||||
&self.config
|
||||
};
|
||||
|
||||
TimerSnapshot {
|
||||
state: self.state,
|
||||
current_view: self.current_view,
|
||||
time_remaining: self.time_until_next_break,
|
||||
total_duration: total_freq,
|
||||
progress: timer_progress,
|
||||
has_had_break: self.has_had_break,
|
||||
seconds_since_last_break: self.seconds_since_last_break,
|
||||
prebreak_warning: self.prebreak_notification_active,
|
||||
snoozes_used: self.snoozes_used,
|
||||
can_snooze: self.can_snooze(),
|
||||
break_title: display_config.break_title.clone(),
|
||||
break_message: display_config.break_message.clone(),
|
||||
break_progress,
|
||||
break_time_remaining: self.time_until_break_end,
|
||||
break_total_duration: self.break_total_duration,
|
||||
break_past_half,
|
||||
settings_modified: self.settings_modified,
|
||||
idle_paused: self.idle_paused,
|
||||
natural_break_occurred: self.natural_break_occurred,
|
||||
smart_breaks_enabled: display_config.smart_breaks_enabled,
|
||||
smart_break_threshold: display_config.smart_break_threshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TickResult {
|
||||
None,
|
||||
BreakStarted(BreakStartedPayload),
|
||||
BreakEnded,
|
||||
PreBreakWarning { seconds_until_break: u64 },
|
||||
NaturalBreakDetected { duration_seconds: u64 },
|
||||
}
|
||||
|
||||
/// Result of checking idle state
|
||||
pub enum IdleCheckResult {
|
||||
None,
|
||||
JustPaused,
|
||||
JustResumed,
|
||||
NaturalBreakDetected(u64), // duration in seconds
|
||||
}
|
||||
|
||||
fn parse_hour(time_str: &str) -> u32 {
|
||||
time_str
|
||||
.split(':')
|
||||
.next()
|
||||
.and_then(|h| h.parse().ok())
|
||||
.unwrap_or(9)
|
||||
}
|
||||
|
||||
/// Returns the number of seconds since last user input (mouse/keyboard).
|
||||
#[cfg(windows)]
|
||||
pub fn get_idle_seconds() -> u64 {
|
||||
use std::mem;
|
||||
use winapi::um::sysinfoapi::GetTickCount;
|
||||
use winapi::um::winuser::{GetLastInputInfo, LASTINPUTINFO};
|
||||
|
||||
unsafe {
|
||||
let mut lii: LASTINPUTINFO = mem::zeroed();
|
||||
lii.cbSize = mem::size_of::<LASTINPUTINFO>() as u32;
|
||||
if GetLastInputInfo(&mut lii) != 0 {
|
||||
let tick = GetTickCount();
|
||||
let idle_ms = tick.wrapping_sub(lii.dwTime);
|
||||
(idle_ms / 1000) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn get_idle_seconds() -> u64 {
|
||||
0
|
||||
}
|
||||
Reference in New Issue
Block a user