Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager

Major feature release (v0.1.3) adding 15 new features to the break timer:

Backend (Rust):
- Pomodoro cycle tracking with configurable short/long break pattern
- Microbreak scheduling (20-20-20 rule) with independent timer
- Screen dimming events with gradual opacity progression
- Presentation mode detection (fullscreen app deferral)
- Smart break detection (natural idle breaks counting toward goals)
- Daily goal tracking and streak milestone events
- Multi-monitor break overlay support
- Working hours enforcement with per-day schedules
- Weekly summary and natural break stats queries
- Config expanded to 71 validated fields

Frontend (Svelte):
- 6 new components: BreathingGuide, ActivityManager, BreakOverlay,
  MicrobreakOverlay, DimOverlay, Celebration
- Breathing guide with 5 patterns and animated pulsing halo
- Activity manager with favorites, custom activities, momentum scroll
- Confetti celebrations on milestones and goal completion
- Dashboard indicators (pomodoro/microbreak/goal) moved inside ring
- Settings reorganized into 18 logical cards
- Breathing pattern selector redesigned with timing descriptions
- Break activities expanded from 40 to 71 curated exercises
- Sound presets expanded from 4 to 8
- Stats view with weekly summary and natural break tracking

Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
This commit is contained in:
Your Name
2026-02-07 15:11:44 +02:00
parent 460bf2c613
commit a339dd1bb3
28 changed files with 3792 additions and 448 deletions

2
src-tauri/Cargo.lock generated
View File

@@ -480,7 +480,7 @@ dependencies = [
[[package]]
name = "core-cooldown"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"anyhow",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "core-cooldown"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
[lib]
@@ -21,4 +21,4 @@ chrono = "0.4"
anyhow = "1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi", "winreg"] }

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "mini", "break"],
"windows": ["main", "mini", "break", "microbreak", "dim", "break-overlay-0", "break-overlay-1", "break-overlay-2", "break-overlay-3", "break-overlay-4", "break-overlay-5"],
"permissions": [
"core:window:allow-start-dragging",
"core:window:allow-minimize",

View File

@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// A custom break activity defined by the user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity {
pub id: String,
pub category: String,
pub text: String,
pub is_favorite: bool,
pub enabled: bool,
}
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
@@ -107,6 +117,54 @@ pub struct Config {
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)
// F8: Auto-start on Windows login
pub auto_start_on_login: bool,
// F6: Custom break activities
pub custom_activities: Vec<CustomActivity>,
pub disabled_builtin_activities: Vec<String>,
pub favorite_builtin_activities: Vec<String>,
pub favorite_weight: u32, // Multiplier for favorites in random pool (2-10)
// F4: Guided breathing animation
pub breathing_guide_enabled: bool,
pub breathing_pattern: String, // "box", "relaxing", "energizing", "calm", "deep"
// F10: Break streaks & gamification
pub daily_goal_enabled: bool,
pub daily_goal_breaks: u32, // 1-30
pub milestone_celebrations: bool,
pub streak_notifications: bool,
// F1: Microbreaks & 20-20-20
pub microbreak_enabled: bool,
pub microbreak_frequency: u32, // 5-60 min
pub microbreak_duration: u32, // 10-60 sec
pub microbreak_sound_enabled: bool,
pub microbreak_show_activity: bool,
pub microbreak_pause_during_break: bool,
// F3: Pomodoro Mode
pub pomodoro_enabled: bool,
pub pomodoro_short_breaks: u32, // 1-10 (short breaks before long)
pub pomodoro_long_break_duration: u32, // 5-60 min
pub pomodoro_long_break_title: String, // max 100 chars
pub pomodoro_long_break_message: String, // max 500 chars
pub pomodoro_reset_on_skip: bool,
// F5: Screen dimming pre-break nudge
pub screen_dim_enabled: bool,
pub screen_dim_seconds: u32, // 3-60 sec before break
pub screen_dim_max_opacity: f32, // 0.1-0.7
// F2: Presentation mode / fullscreen detection
pub presentation_mode_enabled: bool,
pub presentation_mode_defer_microbreaks: bool,
pub presentation_mode_notification: bool,
// F9: Multi-monitor break enforcement
pub multi_monitor_break: bool,
// Window positions (persisted between launches)
pub main_window_x: Option<i32>,
pub main_window_y: Option<i32>,
@@ -186,6 +244,54 @@ impl Default for Config {
mini_click_through: true,
mini_hover_threshold: 3.0,
// F8: Auto-start
auto_start_on_login: false,
// F6: Custom activities
custom_activities: Vec::new(),
disabled_builtin_activities: Vec::new(),
favorite_builtin_activities: Vec::new(),
favorite_weight: 3,
// F4: Breathing guide
breathing_guide_enabled: true,
breathing_pattern: "box".to_string(),
// F10: Gamification
daily_goal_enabled: true,
daily_goal_breaks: 8,
milestone_celebrations: true,
streak_notifications: true,
// F1: Microbreaks
microbreak_enabled: false,
microbreak_frequency: 20,
microbreak_duration: 20,
microbreak_sound_enabled: true,
microbreak_show_activity: true,
microbreak_pause_during_break: true,
// F3: Pomodoro
pomodoro_enabled: false,
pomodoro_short_breaks: 3,
pomodoro_long_break_duration: 15,
pomodoro_long_break_title: "Long break".to_string(),
pomodoro_long_break_message: "Great work! Take a longer rest.".to_string(),
pomodoro_reset_on_skip: false,
// F5: Screen dimming
screen_dim_enabled: false,
screen_dim_seconds: 10,
screen_dim_max_opacity: 0.3,
// F2: Presentation mode
presentation_mode_enabled: true,
presentation_mode_defer_microbreaks: true,
presentation_mode_notification: true,
// F9: Multi-monitor
multi_monitor_break: true,
// Window positions
main_window_x: None,
main_window_y: None,
@@ -349,6 +455,44 @@ impl Config {
// UI zoom: 50-200%
self.ui_zoom = self.ui_zoom.clamp(50, 200);
// F6: Custom activities
if self.custom_activities.len() > 100 {
self.custom_activities.truncate(100);
}
for act in &mut self.custom_activities {
if act.text.len() > 500 {
act.text.truncate(500);
}
}
self.favorite_weight = self.favorite_weight.clamp(2, 10);
// F4: Breathing pattern
let valid_patterns = vec!["box", "relaxing", "energizing", "calm", "deep"];
if !valid_patterns.contains(&self.breathing_pattern.as_str()) {
self.breathing_pattern = "box".to_string();
}
// F10: Daily goal
self.daily_goal_breaks = self.daily_goal_breaks.clamp(1, 30);
// F1: Microbreaks
self.microbreak_frequency = self.microbreak_frequency.clamp(5, 60);
self.microbreak_duration = self.microbreak_duration.clamp(10, 60);
// F3: Pomodoro
self.pomodoro_short_breaks = self.pomodoro_short_breaks.clamp(1, 10);
self.pomodoro_long_break_duration = self.pomodoro_long_break_duration.clamp(5, 60);
if self.pomodoro_long_break_title.len() > 100 {
self.pomodoro_long_break_title.truncate(100);
}
if self.pomodoro_long_break_message.len() > 500 {
self.pomodoro_long_break_message.truncate(500);
}
// F5: Screen dimming
self.screen_dim_seconds = self.screen_dim_seconds.clamp(3, 60);
self.screen_dim_max_opacity = self.screen_dim_max_opacity.clamp(0.1, 0.7);
// Validate color hex strings
if !Self::is_valid_hex_color(&self.accent_color) {
self.accent_color = "#ff4d00".to_string();

View File

@@ -15,7 +15,7 @@ use tauri::{
AppHandle, Emitter, Manager, State,
};
use tauri_plugin_notification::NotificationExt;
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
use timer::{AppView, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
pub struct AppState {
pub timer: Arc<Mutex<TimerManager>>,
@@ -127,7 +127,13 @@ fn set_view(state: State<AppState>, view: AppView) {
#[tauri::command]
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
let s = state.stats.lock().unwrap();
s.snapshot()
let timer = state.timer.lock().unwrap();
let daily_goal = if timer.config.daily_goal_enabled {
timer.config.daily_goal_breaks
} else {
0
};
s.snapshot(daily_goal)
}
#[tauri::command]
@@ -136,6 +142,126 @@ fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord>
s.recent_days(days)
}
// F7: Weekly summary command
#[tauri::command]
fn get_weekly_summary(state: State<AppState>, weeks: u32) -> Vec<stats::WeekSummary> {
let s = state.stats.lock().unwrap();
s.weekly_summary(weeks)
}
// F8: Auto-start on Windows login
#[tauri::command]
fn set_auto_start(enabled: bool) -> Result<(), String> {
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winreg::{RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, HKEY_CURRENT_USER};
use winapi::um::winnt::{KEY_SET_VALUE, REG_SZ};
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = std::ptr::null_mut();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
0,
KEY_SET_VALUE,
&mut hkey,
);
if result != 0 {
return Err("Failed to open registry key".to_string());
}
if enabled {
let exe_path = std::env::current_exe()
.map_err(|e| e.to_string())?;
let path_str = exe_path.to_string_lossy();
let value_data: Vec<u16> = OsStr::new(&*path_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let res = RegSetValueExW(
hkey,
value_name.as_ptr(),
0,
REG_SZ,
value_data.as_ptr() as *const u8,
(value_data.len() * 2) as u32,
);
winapi::um::winreg::RegCloseKey(hkey);
if res != 0 {
return Err("Failed to set registry value".to_string());
}
} else {
let _res = RegDeleteValueW(hkey, value_name.as_ptr());
winapi::um::winreg::RegCloseKey(hkey);
}
}
Ok(())
}
#[cfg(not(windows))]
{
Err("Auto-start is only supported on Windows".to_string())
}
}
#[tauri::command]
fn get_auto_start_status() -> bool {
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
use winapi::um::winnt::KEY_READ;
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = std::ptr::null_mut();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
0,
KEY_READ,
&mut hkey,
);
if result != 0 {
return false;
}
let res = RegQueryValueExW(
hkey,
value_name.as_ptr(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
);
winapi::um::winreg::RegCloseKey(hkey);
res == 0
}
}
#[cfg(not(windows))]
{
false
}
}
// ── Cursor / Window Position Commands ────────────────────────────────────
#[tauri::command]
@@ -197,12 +323,14 @@ fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
}
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
/// F10: Optionally renders a green checkmark when daily goal is met.
fn render_tray_icon(
progress: f64,
is_break: bool,
is_paused: bool,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
goal_met: bool,
) -> Vec<u8> {
let size: usize = 32;
let mut rgba = vec![0u8; size * size * 4];
@@ -241,6 +369,28 @@ fn render_tray_icon(
}
}
}
// F10: Draw a small green dot in the bottom-right corner when goal is met
if goal_met {
let dot_cx = 25.0_f64;
let dot_cy = 25.0_f64;
let dot_r = 4.0_f64;
for y in 20..32 {
for x in 20..32 {
let dx = x as f64 - dot_cx;
let dy = y as f64 - dot_cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= dot_r {
let idx = (y * size + x) * 4;
rgba[idx] = 63; // green
rgba[idx + 1] = 185;
rgba[idx + 2] = 80;
rgba[idx + 3] = 255;
}
}
}
}
rgba
}
@@ -249,13 +399,18 @@ fn update_tray(
snapshot: &TimerSnapshot,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
goal_met: bool,
) {
// 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)
if snapshot.deferred_break_pending {
"Core Cooldown — Break deferred (fullscreen)".to_string()
} else {
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 {
@@ -279,7 +434,7 @@ fn update_tray(
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
};
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color);
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color, goal_met);
let icon = Image::new_owned(icon_data, 32, 32);
let _ = tray.set_icon(Some(icon));
}
@@ -369,41 +524,133 @@ pub fn run() {
let handle = app.handle().clone();
let timer_ref = app.state::<AppState>().timer.clone();
let stats_ref = app.state::<AppState>().stats.clone();
let data_dir_clone = data_dir.clone();
std::thread::spawn(move || {
let mut dim_window_open = false;
let mut microbreak_window_open = false;
let mut break_deferred_notified = false;
loop {
std::thread::sleep(Duration::from_secs(1));
let (tick_result, snapshot, accent_hex, break_hex) = {
let (tick_result, mb_result, snapshot, accent_hex, break_hex, daily_goal, daily_goal_enabled, goal_met) = {
let mut timer = timer_ref.lock().unwrap();
let result = timer.tick();
let mb = timer.tick_microbreak();
let snap = timer.snapshot();
let ac = timer.config.accent_color.clone();
let bc = timer.config.break_color.clone();
(result, snap, ac, bc)
let dg = timer.config.daily_goal_breaks;
let dge = timer.config.daily_goal_enabled;
// Check goal status
let s = stats_ref.lock().unwrap();
let goal_target = if dge { dg } else { 0 };
let ss = s.snapshot(goal_target);
(result, mb, snap, ac, bc, dg, dge, ss.daily_goal_met)
};
// 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);
update_tray(&tray, &snapshot, accent, break_c, goal_met);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot);
// F5: Screen dim window management
if snapshot.screen_dim_active && !dim_window_open {
open_dim_overlay(&handle, &data_dir_clone);
dim_window_open = true;
} else if !snapshot.screen_dim_active && dim_window_open {
close_dim_overlay(&handle);
dim_window_open = false;
}
if snapshot.screen_dim_active {
let max_opacity = {
let t = timer_ref.lock().unwrap();
t.config.screen_dim_max_opacity
};
let _ = handle.emit("screen-dim-update", &serde_json::json!({
"progress": snapshot.screen_dim_progress,
"maxOpacity": max_opacity
}));
}
// F1: Microbreak window management
match mb_result {
MicrobreakTickResult::MicrobreakStarted => {
open_microbreak_window(&handle, &data_dir_clone);
microbreak_window_open = true;
let _ = handle.emit("microbreak-started", &());
}
MicrobreakTickResult::MicrobreakEnded => {
close_microbreak_window(&handle);
microbreak_window_open = false;
let _ = handle.emit("microbreak-ended", &());
}
MicrobreakTickResult::None => {}
}
// Emit specific events for state transitions
match tick_result {
TickResult::BreakStarted(payload) => {
// Close dim overlay if it was open
if dim_window_open {
close_dim_overlay(&handle);
dim_window_open = false;
}
// Close microbreak if active
if microbreak_window_open {
close_microbreak_window(&handle);
microbreak_window_open = false;
}
handle_break_start(&handle, payload.fullscreen_mode);
// F9: Multi-monitor overlays
{
let timer = timer_ref.lock().unwrap();
if timer.config.fullscreen_mode && timer.config.multi_monitor_break {
open_multi_monitor_overlays(&handle, &data_dir_clone);
}
}
let _ = handle.emit("break-started", &payload);
break_deferred_notified = false;
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle);
// Record completed break in stats
{
let break_result = {
let timer = timer_ref.lock().unwrap();
let goal = if daily_goal_enabled { daily_goal } else { 0 };
let mut s = stats_ref.lock().unwrap();
s.record_break_completed(timer.break_total_duration);
s.record_break_completed(timer.break_total_duration, goal)
};
// F10: Emit milestone/goal events
if let Some(streak) = break_result.milestone_reached {
let _ = handle.emit("milestone-reached", &streak);
let timer = timer_ref.lock().unwrap();
if timer.config.streak_notifications {
let _ = handle
.notification()
.builder()
.title("Streak milestone!")
.body(&format!("{}-day streak! Keep it up!", streak))
.show();
}
}
if break_result.daily_goal_just_met {
let _ = handle.emit("daily-goal-met", &());
let timer = timer_ref.lock().unwrap();
if timer.config.streak_notifications {
let _ = handle
.notification()
.builder()
.title("Daily goal reached!")
.body("You've hit your break goal for today.")
.show();
}
}
let _ = handle
.notification()
@@ -464,6 +711,22 @@ pub fn run() {
.show();
let _ = handle.emit("natural-break-detected", &duration_seconds);
}
TickResult::BreakDeferred => {
// F2: Notify once when break gets deferred
if !break_deferred_notified {
break_deferred_notified = true;
let timer = timer_ref.lock().unwrap();
if timer.config.presentation_mode_notification {
let _ = handle
.notification()
.builder()
.title("Break deferred")
.body("Fullscreen app detected — break will start when you exit.")
.show();
}
let _ = handle.emit("break-deferred", &());
}
}
TickResult::None => {}
}
}
@@ -493,6 +756,9 @@ pub fn run() {
set_view,
get_stats,
get_daily_history,
get_weekly_summary,
set_auto_start,
get_auto_start_status,
get_cursor_position,
save_window_position,
])
@@ -688,3 +954,114 @@ fn toggle_mini_window(app: &AppHandle) {
let _ = builder.build();
}
}
// ── F1: Microbreak Window ──────────────────────────────────────────────────
fn open_microbreak_window(app: &AppHandle, data_dir: &std::path::Path) {
if app.get_webview_window("microbreak").is_some() {
return; // already open
}
let _ = tauri::WebviewWindowBuilder::new(
app,
"microbreak",
tauri::WebviewUrl::App("index.html?microbreak=1".into()),
)
.title("Eye Break")
.inner_size(400.0, 180.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.center()
.data_directory(data_dir.to_path_buf())
.build();
}
fn close_microbreak_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("microbreak") {
let _ = win.close();
}
}
// ── F5: Dim Overlay Window ──────────────────────────────────────────────────
fn open_dim_overlay(app: &AppHandle, data_dir: &std::path::Path) {
if app.get_webview_window("dim").is_some() {
return;
}
let builder = tauri::WebviewWindowBuilder::new(
app,
"dim",
tauri::WebviewUrl::App("index.html?dim=1".into()),
)
.title("")
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.maximized(true)
.data_directory(data_dir.to_path_buf());
if let Ok(win) = builder.build() {
let _ = win.set_ignore_cursor_events(true);
}
}
fn close_dim_overlay(app: &AppHandle) {
if let Some(win) = app.get_webview_window("dim") {
let _ = win.close();
}
}
// ── F9: Multi-Monitor Break Overlays ────────────────────────────────────────
fn open_multi_monitor_overlays(app: &AppHandle, data_dir: &std::path::Path) {
let monitors = timer::get_all_monitors();
for (i, mon) in monitors.iter().enumerate() {
if mon.is_primary {
continue; // Primary is handled by the main break window
}
if i > 5 {
break; // Cap at 6 monitors
}
let label = format!("break-overlay-{}", i);
if app.get_webview_window(&label).is_some() {
continue;
}
let _ = tauri::WebviewWindowBuilder::new(
app,
&label,
tauri::WebviewUrl::App("index.html?breakoverlay=1".into()),
)
.title("")
.position(mon.x as f64, mon.y as f64)
.inner_size(mon.width as f64, mon.height as f64)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.data_directory(data_dir.to_path_buf())
.build();
}
}
fn close_multi_monitor_overlays(app: &AppHandle) {
// Close any window with label starting with "break-overlay-"
for i in 0..6 {
let label = format!("break-overlay-{}", i);
if let Some(win) = app.get_webview_window(&label) {
let _ = win.close();
}
}
}

View File

@@ -42,8 +42,31 @@ pub struct StatsSnapshot {
pub compliance_rate: f64,
pub current_streak: u32,
pub best_streak: u32,
// F10: Daily goal
pub daily_goal_progress: u32,
pub daily_goal_met: bool,
}
/// F10: Result of recording a completed break
pub struct BreakCompletedResult {
pub milestone_reached: Option<u32>,
pub daily_goal_just_met: bool,
}
/// F7: Weekly summary for reports
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WeekSummary {
pub week_start: String,
pub total_completed: u32,
pub total_skipped: u32,
pub total_break_time_secs: u64,
pub compliance_rate: f64,
pub avg_daily_completed: f64,
}
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
impl Stats {
/// Portable: stats file lives next to the exe
fn stats_path() -> Option<PathBuf> {
@@ -91,12 +114,23 @@ impl Stats {
})
}
pub fn record_break_completed(&mut self, duration_secs: u64) {
/// Record a completed break. Returns milestone/goal info for gamification.
pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult {
let day = self.today_mut();
let was_below_goal = day.breaks_completed < daily_goal;
day.breaks_completed += 1;
day.total_break_time_secs += duration_secs;
let now_at_goal = day.breaks_completed >= daily_goal;
self.update_streak();
self.save();
let milestone = self.check_milestone();
let daily_goal_just_met = was_below_goal && now_at_goal && daily_goal > 0;
BreakCompletedResult {
milestone_reached: milestone,
daily_goal_just_met,
}
}
pub fn record_break_skipped(&mut self) {
@@ -148,7 +182,17 @@ impl Stats {
}
}
pub fn snapshot(&self) -> StatsSnapshot {
/// F10: Check if current streak exactly matches a milestone
fn check_milestone(&self) -> Option<u32> {
let streak = self.data.current_streak;
if MILESTONES.contains(&streak) {
Some(streak)
} else {
None
}
}
pub fn snapshot(&self, daily_goal: u32) -> StatsSnapshot {
let key = Self::today_key();
let today = self.data.days.get(&key);
@@ -176,6 +220,8 @@ impl Stats {
compliance_rate: compliance,
current_streak: self.data.current_streak,
best_streak: self.data.best_streak,
daily_goal_progress: completed,
daily_goal_met: daily_goal > 0 && completed >= daily_goal,
}
}
@@ -196,4 +242,47 @@ impl Stats {
records
}
/// F7: Get weekly summaries for the past N weeks
pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> {
let today = chrono::Local::now().date_naive();
let mut summaries = Vec::new();
for w in 0..weeks {
let week_end = today - chrono::Duration::days((w * 7) as i64);
let week_start = week_end - chrono::Duration::days(6);
let mut total_completed = 0u32;
let mut total_skipped = 0u32;
let mut total_break_time = 0u64;
for d in 0..7 {
let day = week_start + chrono::Duration::days(d);
let key = day.format("%Y-%m-%d").to_string();
if let Some(record) = self.data.days.get(&key) {
total_completed += record.breaks_completed;
total_skipped += record.breaks_skipped;
total_break_time += record.total_break_time_secs;
}
}
let total = total_completed + total_skipped;
let compliance = if total > 0 {
total_completed as f64 / total as f64
} else {
1.0
};
summaries.push(WeekSummary {
week_start: week_start.format("%Y-%m-%d").to_string(),
total_completed,
total_skipped,
total_break_time_secs: total_break_time,
compliance_rate: compliance,
avg_daily_completed: total_completed as f64 / 7.0,
});
}
summaries
}
}

View File

@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
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
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
pub strict_mode: bool,
pub snooze_duration: u32,
pub fullscreen_mode: bool,
pub is_long_break: bool,
}
pub struct TimerManager {
@@ -84,6 +106,17 @@ pub struct TimerManager {
// 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 {
@@ -92,6 +125,7 @@ impl TimerManager {
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 {
@@ -113,6 +147,17 @@ impl TimerManager {
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,
}
}
@@ -173,6 +218,18 @@ impl TimerManager {
}
}
/// 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
@@ -223,15 +280,18 @@ impl TimerManager {
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(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,
})
TickResult::BreakStarted(self.make_break_payload())
}
}
TimerState::BreakActive => {
@@ -242,28 +302,139 @@ impl TimerManager {
// 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 => TickResult::None,
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;
self.break_total_duration = self.config.break_duration_seconds();
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) {
@@ -281,14 +452,7 @@ impl TimerManager {
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,
})
Some(self.make_break_payload())
} else {
None
}
@@ -311,10 +475,15 @@ impl TimerManager {
// "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 {
@@ -420,6 +589,25 @@ impl TimerManager {
&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,
@@ -431,8 +619,16 @@ impl TimerManager {
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_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,
@@ -442,6 +638,27 @@ impl TimerManager {
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,
}
}
}
@@ -452,6 +669,14 @@ pub enum TickResult {
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
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
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,
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Core Cooldown",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "com.corecooldown.app",
"build": {
"frontendDist": "../dist",