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:
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