Files
core-cooldown/src-tauri/src/timer.rs
2026-02-07 15:11:44 +02:00

812 lines
27 KiB
Rust

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,
// F1: Microbreaks
pub microbreak_enabled: bool,
pub microbreak_active: bool,
pub microbreak_time_remaining: u64,
pub microbreak_total_duration: u64,
pub microbreak_countdown: u64, // seconds until next microbreak
pub microbreak_frequency: u32,
// F3: Pomodoro
pub pomodoro_enabled: bool,
pub pomodoro_cycle_position: u32,
pub pomodoro_total_in_cycle: u32,
pub pomodoro_is_long_break: bool,
pub pomodoro_next_is_long: bool,
// F5: Screen dimming
pub screen_dim_active: bool,
pub screen_dim_progress: f64,
// F2: Presentation mode
pub presentation_mode_active: bool,
pub deferred_break_pending: bool,
// F10: Gamification
pub is_long_break: bool,
}
/// 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 is_long_break: 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,
// F1: Microbreaks
pub microbreak_time_remaining: u64,
pub microbreak_active: bool,
pub microbreak_time_until_end: u64,
pub microbreak_total_duration: u64,
// F3: Pomodoro
pub pomodoro_cycle_position: u32,
pub pomodoro_is_long_break: bool,
// F2: Presentation mode
pub presentation_mode_active: bool,
pub deferred_break_pending: 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;
let microbreak_freq = config.microbreak_frequency as u64 * 60;
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,
// F1: Microbreaks
microbreak_time_remaining: microbreak_freq,
microbreak_active: false,
microbreak_time_until_end: 0,
microbreak_total_duration: 0,
// F3: Pomodoro
pomodoro_cycle_position: 0,
pomodoro_is_long_break: false,
// F2: Presentation mode
presentation_mode_active: false,
deferred_break_pending: 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
}
}
/// F2: Check if the foreground window is fullscreen (presentation mode)
pub fn check_presentation_mode(&mut self) -> bool {
if !self.config.presentation_mode_enabled {
self.presentation_mode_active = false;
return false;
}
let fs = is_foreground_fullscreen();
self.presentation_mode_active = fs;
fs
}
/// 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 {
// F2: Check presentation mode before starting break
if self.check_presentation_mode() {
self.deferred_break_pending = true;
// Keep time at 0 so it triggers immediately when cleared
self.time_until_next_break = 0;
return TickResult::BreakDeferred;
}
// Clear any deferred state
self.deferred_break_pending = false;
self.start_break();
TickResult::BreakStarted(self.make_break_payload())
}
}
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.advance_pomodoro_cycle();
self.reset_timer();
TickResult::BreakEnded
}
}
TimerState::Paused => {
// F2: Check if deferred break can now proceed
if self.deferred_break_pending && !self.idle_paused {
if !self.check_presentation_mode() {
self.deferred_break_pending = false;
self.state = TimerState::Running;
self.start_break();
return TickResult::BreakStarted(self.make_break_payload());
}
}
TickResult::None
}
}
}
/// F1: Called every second for microbreak logic. Returns microbreak events.
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
if !self.config.microbreak_enabled {
return MicrobreakTickResult::None;
}
// Don't tick microbreaks during main breaks
if self.config.microbreak_pause_during_break && self.state == TimerState::BreakActive {
return MicrobreakTickResult::None;
}
// Don't tick when manually paused (but not during idle-pause which auto-resumes)
if self.state == TimerState::Paused && !self.idle_paused {
return MicrobreakTickResult::None;
}
// F2: Defer microbreaks during presentation mode
if self.config.presentation_mode_defer_microbreaks && self.presentation_mode_active {
return MicrobreakTickResult::None;
}
if self.microbreak_active {
// Counting down microbreak
if self.microbreak_time_until_end > 0 {
self.microbreak_time_until_end -= 1;
MicrobreakTickResult::None
} else {
// Microbreak ended
self.microbreak_active = false;
self.reset_microbreak_timer();
MicrobreakTickResult::MicrobreakEnded
}
} else {
// Counting down to next microbreak
if self.microbreak_time_remaining > 0 {
self.microbreak_time_remaining -= 1;
MicrobreakTickResult::None
} else {
// Start microbreak
self.microbreak_active = true;
self.microbreak_total_duration = self.config.microbreak_duration as u64;
self.microbreak_time_until_end = self.microbreak_total_duration;
MicrobreakTickResult::MicrobreakStarted
}
}
}
fn reset_microbreak_timer(&mut self) {
self.microbreak_time_remaining = self.config.microbreak_frequency as u64 * 60;
}
fn make_break_payload(&self) -> BreakStartedPayload {
BreakStartedPayload {
title: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_title.clone()
} else {
self.config.break_title.clone()
},
message: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_message.clone()
} else {
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,
is_long_break: self.pomodoro_is_long_break,
}
}
pub fn start_break(&mut self) {
// F3: Determine if this should be a long break (Pomodoro)
if self.config.pomodoro_enabled {
// cycle_position counts from 0. Position == short_breaks means it's the long break.
self.pomodoro_is_long_break = self.pomodoro_cycle_position >= self.config.pomodoro_short_breaks;
} else {
self.pomodoro_is_long_break = false;
}
self.state = TimerState::BreakActive;
self.current_view = AppView::BreakScreen;
if self.pomodoro_is_long_break {
self.break_total_duration = self.config.pomodoro_long_break_duration as u64 * 60;
} else {
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;
}
/// F3: Advance the Pomodoro cycle position after a break completes
fn advance_pomodoro_cycle(&mut self) {
if !self.config.pomodoro_enabled {
return;
}
if self.pomodoro_is_long_break {
// After long break, reset cycle
self.pomodoro_cycle_position = 0;
} else {
self.pomodoro_cycle_position += 1;
}
}
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;
self.pomodoro_is_long_break = 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(self.make_break_payload())
} 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.advance_pomodoro_cycle();
self.reset_timer();
true
} else if !past_half {
// "Cancel break" - doesn't count
// F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0;
}
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
};
// F5: Screen dim active when running and close to break
let dim_secs = self.config.screen_dim_seconds as u64;
let screen_dim_active = self.config.screen_dim_enabled
&& self.state == TimerState::Running
&& self.time_until_next_break <= dim_secs
&& self.time_until_next_break > 0
&& !self.deferred_break_pending;
let screen_dim_progress = if screen_dim_active && dim_secs > 0 {
1.0 - (self.time_until_next_break as f64 / dim_secs as f64)
} else {
0.0
};
// F3: Pomodoro info
let pomo_total = self.config.pomodoro_short_breaks + 1;
let pomo_next_is_long = self.config.pomodoro_enabled
&& self.pomodoro_cycle_position + 1 >= pomo_total
&& self.state != TimerState::BreakActive;
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: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_title.clone()
} else {
display_config.break_title.clone()
},
break_message: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_message.clone()
} else {
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,
// F1: Microbreaks
microbreak_enabled: self.config.microbreak_enabled,
microbreak_active: self.microbreak_active,
microbreak_time_remaining: self.microbreak_time_until_end,
microbreak_total_duration: self.microbreak_total_duration,
microbreak_countdown: self.microbreak_time_remaining,
microbreak_frequency: self.config.microbreak_frequency,
// F3: Pomodoro
pomodoro_enabled: self.config.pomodoro_enabled,
pomodoro_cycle_position: self.pomodoro_cycle_position,
pomodoro_total_in_cycle: pomo_total,
pomodoro_is_long_break: self.pomodoro_is_long_break,
pomodoro_next_is_long: pomo_next_is_long,
// F5: Screen dimming
screen_dim_active,
screen_dim_progress,
// F2: Presentation mode
presentation_mode_active: self.presentation_mode_active,
deferred_break_pending: self.deferred_break_pending,
// F10
is_long_break: self.pomodoro_is_long_break,
}
}
}
pub enum TickResult {
None,
BreakStarted(BreakStartedPayload),
BreakEnded,
PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 },
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
pub enum IdleCheckResult {
None,
JustPaused,
JustResumed,
NaturalBreakDetected(u64), // duration in seconds
}
/// 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
}
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
use winapi::shared::windef::{HWND, RECT};
use winapi::um::winuser::{
GetForegroundWindow, GetWindowRect, MonitorFromWindow, GetMonitorInfoW,
MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
unsafe {
let hwnd: HWND = GetForegroundWindow();
if hwnd.is_null() {
return false;
}
// Get the monitor this window is on
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if monitor.is_null() {
return false;
}
let mut mi: MONITORINFO = mem::zeroed();
mi.cbSize = mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) == 0 {
return false;
}
let mut wr: RECT = mem::zeroed();
if GetWindowRect(hwnd, &mut wr) == 0 {
return false;
}
// Check if window rect covers the monitor rect
let mr = mi.rcMonitor;
wr.left <= mr.left && wr.top <= mr.top && wr.right >= mr.right && wr.bottom >= mr.bottom
}
}
#[cfg(not(windows))]
pub fn is_foreground_fullscreen() -> bool {
false
}
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
use winapi::um::winuser::{EnumDisplayMonitors, GetMonitorInfoW, MONITORINFO};
unsafe extern "system" fn callback(
monitor: HMONITOR,
_hdc: HDC,
_rect: LPRECT,
data: isize,
) -> i32 {
let monitors = &mut *(data as *mut Vec<MonitorInfo>);
let mut mi: MONITORINFO = std::mem::zeroed();
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) != 0 {
let r = mi.rcMonitor;
monitors.push(MonitorInfo {
x: r.left,
y: r.top,
width: (r.right - r.left) as u32,
height: (r.bottom - r.top) as u32,
is_primary: (mi.dwFlags & 1) != 0, // MONITORINFOF_PRIMARY = 1
});
}
1 // continue enumeration
}
let mut monitors: Vec<MonitorInfo> = Vec::new();
unsafe {
EnumDisplayMonitors(
std::ptr::null_mut(),
std::ptr::null(),
Some(callback),
&mut monitors as *mut Vec<MonitorInfo> as isize,
);
}
monitors
}
#[cfg(not(windows))]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
Vec::new()
}
#[derive(Debug, Clone)]
pub struct MonitorInfo {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub is_primary: bool,
}