diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c59f10d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +## v0.1.3 + +### New Features + +- **Pomodoro Mode** — Alternates short breaks with a longer recovery break after configurable focus sessions. Cycle indicator on dashboard, custom long break titles/messages, reset-on-skip option. +- **Microbreaks (20-20-20 Rule)** — Independent short eye breaks between main breaks. Subtle non-blocking overlay with activity suggestion and optional sound. Configurable frequency (5-60 min) and duration (10-60 sec). +- **Breathing Guide** — Visual breathing exercise during breaks with 5 patterns (Box, Relaxing, Energizing, Calm, Deep). Animated pulsing halo behind countdown ring with color interpolation between accent and break colors. Phase labels (Inhale/Hold/Exhale) with countdown. +- **Screen Dimming** — Gradual pre-break screen dimming. Configurable timing (3-60 sec before break) and maximum intensity (10-70%). +- **Presentation Mode** — Detects fullscreen applications and defers breaks until exit. Optional microbreak deferral and toast notification when breaks are deferred. +- **Goals & Streaks** — Daily break target with progress indicator inside dashboard ring. Confetti celebrations on milestones and goal completion. Streak tracking with toast notifications. +- **Multi-Monitor Breaks** — Fullscreen break overlay spans all connected monitors. +- **Working Hours Schedule** — Per-day schedule with multiple time ranges. Timer automatically pauses outside configured hours. +- **Activity Manager** — Browse, search, favorite, and disable built-in activities. Add custom activities with category assignment. Favorites appear 3x more often. Momentum drag scroll with elastic overscroll. +- **Celebration Animations** — Confetti particle effects on streak milestones and daily goal completion. +- **Smart Breaks** — Recognizes natural away-from-desk breaks (idle periods exceeding configurable threshold) and optionally counts them toward daily goal. +- **Break Window (Standalone)** — When fullscreen mode is disabled, breaks open in a separate centered transparent modal window instead of taking over the main window. +- **Break Overlay** — Multi-monitor break enforcement overlay that covers all screens during breaks. +- **Microbreak Overlay** — Subtle non-blocking overlay for 20-20-20 eye breaks with activity suggestion. +- **Dim Overlay** — Smooth pre-break screen dimming with gradual opacity transition. + +### UI Polish + +- **Settings Reordered** — 18 logically grouped cards: Timer, Pomodoro, Microbreaks, Break Screen, Break Activities (conditional), Breathing Guide, Behavior, Alerts (merged notifications + screen dimming), Sound, Idle & Smart Breaks (merged), Presentation Mode, Goals & Streaks, Appearance, Working Hours, Mini Mode, General, Keyboard Shortcuts, Reset. +- **Breathing Pattern Selector Redesigned** — Replaced cramped 5-column grid with vertical radio-button list showing timing descriptions (e.g., "4s in - 4s hold - 4s out - 4s hold"). +- **Dashboard Indicators Inside Ring** — Pomodoro cycle dots, microbreak countdown, and daily goal progress bar moved inside the timer ring for cleaner layout. +- **Daily Goal Label** — Progress bar now includes a target icon and "Goal" label. +- **Break Screen Breathing Halo** — Breathing guide renders as a pulsing circle behind the countdown ring with dynamic color gradient, in both fullscreen and standalone modes. + +### Improvements + +- Expanded break activity library from 40 to 71 curated activities +- Added 4 new sound presets: Harp, Bowl, Rain, Whistle (now 8 total) +- Stats view now includes weekly summary, natural break tracking, and daily goal progress +- Timer store now handles microbreak, screen dim, celebration, and presentation mode events +- Config store expanded from ~30 to 71 keys with validation for all new features +- Timer state machine now tracks pomodoro cycles, microbreak scheduling, presentation mode deferral, and smart break detection +- Stats backend tracks natural breaks, daily goals, and weekly summaries + +### Documentation + +- README comprehensively updated with all new features, accurate component/activity/config counts +- CLAUDE.md updated with current architecture (17 commands, 12 events, 20 components, 71 config keys) + +--- + +## v0.1.2 + +- WCAG 2.1 Level AA accessibility across all components +- Focus indicators, reduced motion support, forced colors, screen reader support +- Keyboard navigation, focus trapping, aria-live regions + +## v0.1.1 + +- Tighten TimeSpinner spacing and fix build warnings +- Enable custom-protocol for embedded frontend assets +- Fix WebView2 detection using loader API instead of registry +- Statically link WebView2Loader for single portable exe +- Fix fullscreen break screen centering and sizing + +## v0.1.0 + +- Initial release +- Dashboard with timer ring and status pill +- Break screen with activity suggestions +- Settings panel with sound, idle detection, and activity configuration +- Statistics view with 7-day bar chart +- System tray with dynamic icon, tooltip, and context menu +- Config and stats persistence (portable JSON files) +- Global keyboard shortcuts (Ctrl+Shift+P/B/S) +- Mini mode floating timer +- Toast notifications +- Always-on-top break enforcement +- Animated view transitions +- Idle detection via Windows API +- Custom titlebar with frosted glass effects +- Background gradient blobs with film grain diff --git a/README.md b/README.md index 2f4cc47..69e7ac4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Core Cooldown is a single portable `.exe`. No installer, no account, no telemetr --- -## 💡 Philosophy +## Philosophy Core Cooldown exists because rest is not a luxury. It is a fundamental need that no productivity framework, no employer, and no software platform should gatekeep behind a paywall or a subscription. @@ -59,11 +59,11 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s --- -## 🖼️ Screenshots +## Screenshots

Dashboard - Main Timer
- Dashboard - Focus timer with countdown ring, status pill, and quick controls + Dashboard - Focus timer with countdown ring, status pill, pomodoro dots, and quick controls


@@ -77,14 +77,14 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s

Settings
- Settings - Grouped configuration cards with live preview + Settings - 18 grouped configuration cards with live preview


Break Screen
- Break Screen - Always-on-top break overlay with activity suggestions + Break Screen - Always-on-top break overlay with breathing guide and activity suggestions


@@ -100,23 +100,62 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s ## Features -### ⏱️ Timer & Breaks +### Timer & Breaks | | Feature | Description | |:--|:--------|:------------| | 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min | | 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break | | 🛡️ | **Break enforcement** | Always-on-top break window with optional fullscreen mode | +| 🖥️ | **Multi-monitor** | Fullscreen break overlay spans all connected monitors | | 🔒 | **Strict mode** | Removes skip and cancel buttons entirely | | ⏩ | **Early end** | Optionally allow ending a break after 50% completion | | 😴 | **Snooze** | Delay breaks by a configurable duration (with limits) | -| ⏳ | **Skip cooldown** | Prevents rapid-fire skipping with a cooldown timer | | ⚡ | **Immediate breaks** | Skip pre-break notification, go straight into break | | 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu |
-### 🧠 Idle Detection & Smart Breaks +### Pomodoro Mode + +A full Pomodoro timer built in. Alternates short breaks with a longer recovery break after a configurable number of focus sessions. + +- **Short breaks before long** - 1-10 short breaks then one long break (default: 3 + 1) +- **Long break duration** - independently configurable (5-60 min, default: 15 min) +- **Custom titles and messages** - personalize the long break screen +- **Cycle indicator** - dashboard shows dot progress through the current cycle +- **Reset on skip** - optionally restart the cycle when skipping a break + +
+ +### Microbreaks (20-20-20 Rule) + +Quick eye rest reminders between full breaks. The 20-20-20 rule: every 20 minutes, look at something 20 feet away for 20 seconds. + +- **Independent timer** - runs alongside the main break timer +- **Configurable frequency** - 5-60 minutes (default: 20) +- **Configurable duration** - 10-60 seconds (default: 20) +- **Subtle overlay** - non-blocking overlay with activity suggestion +- **Sound notification** - optional audio cue +- **Pauses during breaks** - no microbreak interruptions during main breaks + +
+ +### Breathing Guide + +A visual breathing exercise during breaks. The breathing text pulses with the rhythm - scaling up on inhale, holding on hold, scaling down on exhale - with a color gradient that interpolates between your accent and break colors. + +| Pattern | Timing | +|:--------|:-------| +| **Box** | 4s in · 4s hold · 4s out · 4s hold | +| **Relaxing** | 4s in · 7s hold · 8s out | +| **Energizing** | 6s in · 2s hold · 6s out · 2s hold | +| **Calm** | 4s in · 4s hold · 6s out | +| **Deep** | 5s in · 5s out | + +
+ +### Idle Detection & Smart Breaks | | Feature | Description | |:--|:--------|:------------| @@ -127,9 +166,20 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
-### 🧘 Break Activities +### Presentation Mode -Each break shows a randomized suggestion from a curated library of **72 activities** across four categories: +Detects fullscreen applications (presentations, video calls, games) and defers breaks until you exit. + +- **Auto-detection** - monitors for fullscreen windows +- **Microbreak deferral** - optionally defer microbreaks too +- **Toast notification** - alerts you when a break is deferred +- **Queued breaks** - deferred breaks trigger when the fullscreen app closes + +
+ +### Break Activities + +Each break shows a randomized suggestion from a curated library of **71 activities** across four categories: | Category | Examples | |:---------|:---------| @@ -140,9 +190,36 @@ Each break shows a randomized suggestion from a curated library of **72 activiti Activities cycle every 30 seconds and never repeat consecutively. +**Activity Manager** - customize your break experience from settings: +- **Custom activities** - add your own with category assignment +- **Favorites** - star activities to increase their appearance frequency (3x weight) +- **Enable/disable** - toggle any built-in or custom activity +- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in the activity list +
-### 📅 Working Hours Schedule +### Goals & Streaks + +| | Feature | Description | +|:--|:--------|:------------| +| 🎯 | **Daily goal** | Set a target number of breaks per day (1-30, shown on dashboard) | +| 🎉 | **Celebrations** | Confetti animation on milestones and goal completion | +| 🔥 | **Streak tracking** | Current and best consecutive-day streaks | +| 🔔 | **Streak notifications** | Toast notification on streak milestones | + +
+ +### Screen Dimming + +A gentle pre-break nudge that gradually dims your screen before the break starts. + +- **Configurable timing** - start dimming 3-60 seconds before break +- **Adjustable intensity** - maximum opacity from 10% to 70% +- **Smooth transition** - gradual linear fade, not a sudden jump + +
+ +### Working Hours Schedule A per-day schedule with multiple time ranges per day. The timer only runs during your configured hours - outside those hours, it pauses automatically. @@ -152,7 +229,7 @@ A per-day schedule with multiple time ranges per day. The timer only runs during
-### 📊 Statistics & History +### Statistics & History | | Metric | Description | |:--|:-------|:------------| @@ -166,7 +243,7 @@ All statistics stored locally in a plain JSON file next to the executable.
-### 🔊 Sound Effects +### Sound Effects Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests. @@ -176,7 +253,7 @@ Sounds play on break start, pre-break warning, and break completion. Volume conf
-### ⌨️ Global Keyboard Shortcuts +### Global Keyboard Shortcuts | Shortcut | Action | |:---------|:-------| @@ -188,87 +265,89 @@ Works system-wide, even when Core Cooldown is not focused.
-### 🔲 System Tray +### System Tray -- 🎨 **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused) -- 💬 **Countdown tooltip** - hover over tray icon to see time remaining -- 📋 **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit +- **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused) +- **Countdown tooltip** - hover over tray icon to see time remaining +- **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
-### 📌 Mini Mode +### Mini Mode A compact floating timer (200x50px) that sits on top of your other windows. -- 👻 **Click-through** - completely transparent to mouse events, never blocks what's underneath -- ✋ **Hover to grab** - hover for a configurable number of seconds and it becomes draggable -- 🖱️ **Double-click** - opens the main window -- 🔀 **Togglable** - enable/disable from the tray menu +- **Click-through** - completely transparent to mouse events, never blocks what's underneath +- **Hover to grab** - hover for a configurable number of seconds and it becomes draggable +- **Double-click** - opens the main window +- **Togglable** - enable/disable from the tray menu
-### 🎨 Appearance & Customization +### Appearance & Customization | Setting | Range | |:--------|:------| -| 🔍 **UI zoom** | 50-200% with live preview | -| 🎯 **Accent color** | Hex color picker for the main UI accent | -| 💜 **Break color** | Separate hex for the break screen ring | -| 🌈 **Color schemes** | Ocean, Forest, Sunset, Midnight, Dawn | -| 🔤 **Countdown font** | Google Fonts selector for timer display | -| 🫧 **Background blobs** | Animated gradient blobs with film grain overlay | -| 🌑 **Backdrop opacity** | 50-100% for the break screen overlay | -| 💬 **Break title & message** | Fully customizable text shown during breaks | -| 🌙 **Dark mode** | Always on (the only civilized option) | +| **UI zoom** | 50-200% with live preview | +| **Accent color** | Full color picker (SL pad + hue bar) for the main UI accent | +| **Break color** | Separate color for the break screen ring and breathing guide | +| **Countdown font** | Google Fonts selector for timer display | +| **Background blobs** | Animated gradient blobs with film grain overlay | +| **Break title & message** | Fully customizable text shown during breaks |
-### 🔔 Notifications +### Notifications Native Windows toast notifications for: -- ⏰ Pre-break warnings (configurable seconds before break) -- ✅ Break completion +- Pre-break warnings (configurable seconds before break) +- Break completion +- Streak milestones +- Daily goal achievement +- Break deferral (presentation mode)
-### 🪟 Window Behavior +### Window Behavior - **Frameless window** with custom titlebar and drag region -- **Transparent background** with frosted glass effects +- **Transparent background** with frosted glass effects (backdrop-blur) - **Window position persistence** - main and mini windows remember position between launches - **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing) +- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in settings
-### ♿ Accessibility +### Accessibility Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preventing repetitive strain injury should be usable by everyone - including those who already live with disabilities. | | Feature | Description | |:--|:--------|:------------| -| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. | +| ⌨️ | **Full keyboard navigation** | Every control reachable and operable via keyboard alone. Arrow keys navigate dropdowns, adjust color pickers, steppers, and time spinners. Tab/Shift+Tab cycles through all interactive elements. | | 🔍 | **Visible focus indicators** | Global `:focus-visible` outlines on all interactive elements - no hidden or suppressed focus rings | -| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state changes, break activities, and status updates. Progress rings use `role="progressbar"` with value text. Stats chart has a screen-reader-accessible data table. | -| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. | -| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds (WCAG AA) | +| 🗣️ | **Screen reader support** | `aria-live` regions announce timer state, breathing phase, break activities, and status changes. Progress rings use `role="progressbar"` with value text. Accordion panels have `aria-controls` and `aria-expanded`. Custom dropdowns support `role="listbox"` with arrow key navigation. | +| 🎯 | **Focus management** | View transitions move focus to the new view's heading. Break screen traps focus to prevent interaction with obscured content. Dropdown focus returns to trigger on close. | +| 🎨 | **Color contrast** | All text meets 4.5:1 minimum contrast ratio against dark backgrounds. Dynamic breathing text color interpolation validated against threshold. | | 🖥️ | **Windows High Contrast** | `forced-colors: active` media query maps all theme tokens to system colors | -| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions *and* all JavaScript-driven Web Animations API effects. No functionality lost - just calmer. | -| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, and form controls have descriptive accessible names instead of generic labels | +| 🐢 | **Reduced motion** | `prefers-reduced-motion` disables all CSS animations/transitions, all JavaScript-driven Web Animations API effects, and momentum scroll physics. No functionality lost - just calmer. | +| 🏷️ | **Descriptive labels** | All toggle switches, steppers, buttons, dropdowns, and form controls have descriptive accessible names | +| 👆 | **Touch targets** | Interactive elements meet minimum 32x32px hit areas for comfortable interaction |
--- -## 📦 Portability +## Portability Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself: ``` -📁 anywhere-you-want/ -├── core-cooldown.exe ← the application -├── config.json ← your settings (auto-created on first run) -├── stats.json ← your break history (auto-created on first run) -└── data/ ← WebView2 runtime data (auto-created on first run) +anywhere-you-want/ +├── core-cooldown.exe <- the application +├── config.json <- your settings (auto-created on first run) +├── stats.json <- your break history (auto-created on first run) +└── data/ <- WebView2 runtime data (auto-created on first run) ``` - No installer @@ -281,7 +360,7 @@ Core Cooldown is **fully portable**. The executable carries everything it needs --- -## 🚀 Installation +## Installation ``` 1. Download core-cooldown.exe from the Releases page @@ -291,13 +370,13 @@ Core Cooldown is **fully portable**. The executable carries everything it needs That's it. No elevated permissions. No runtime dependencies. The first launch may take a moment while Windows initializes the WebView2 runtime. -**[Download latest release →](https://git.lashman.live/lashman/core-cooldown/releases)** +**[Download latest release](https://git.lashman.live/lashman/core-cooldown/releases)**
--- -## 🔨 Building from Source +## Building from Source
Prerequisites @@ -356,7 +435,7 @@ Output: `src-tauri/target/x86_64-pc-windows-gnu/release/core-cooldown.exe` --- -## 🏗️ Architecture +## Architecture A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView. @@ -366,13 +445,13 @@ A split-architecture desktop app: Rust backend for system integration and timer │ (dynamic icon · tooltip · menu) │ ├──────────────────────────────────────────────────────────────┤ │ │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Main Window │ │ Break Window│ │ Mini Window │ │ -│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Main Window │ │ Break Window│ │ Mini Window │ │ +│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └─────────┬───────┴──────────────────┘ │ -│ │ Tauri IPC │ +│ │ Tauri IPC │ │ ┌─────────┴─────────┐ │ │ │ Rust Backend │ │ │ │ │ │ @@ -380,7 +459,7 @@ A split-architecture desktop app: Rust backend for system integration and timer │ │ Config │ JSON persistence (portable) │ │ │ Stats │ break history tracking │ │ │ IdleDetector │ GetLastInputInfo polling │ -│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │ +│ │ GlobalShortcuts │ Ctrl+Shift+P/B/S │ │ │ TrayIcon │ RGBA ring rendering │ │ │ Notifications │ Windows toast │ │ └───────────────────┘ │ @@ -393,10 +472,10 @@ A split-architecture desktop app: Rust backend for system integration and timer | Module | Responsibility | |:-------|:---------------| -| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts | -| `config.rs` | Config struct with serde serialization, validation (clamping all values to safe ranges), portable file I/O | -| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement | -| `stats.rs` | Daily break statistics, streak calculation, history queries | +| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts, screen dim events, microbreak events, presentation mode detection | +| `config.rs` | Config struct (75 fields) with serde serialization, validation (clamping all values to safe ranges), portable file I/O | +| `timer.rs` | Timer state machine (`Running` / `Paused` / `BreakActive`), idle detection via Windows API, working hours enforcement, pomodoro cycle tracking, microbreak scheduling, presentation mode deferral | +| `stats.rs` | Daily break statistics, streak calculation, daily goal tracking, history queries, weekly summaries | | `main.rs` | Entry point, WebView2 Runtime detection | | `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW | @@ -409,20 +488,21 @@ A split-architecture desktop app: Rust backend for system integration and timer |:------|:------| | **Views** | `Dashboard`, `BreakScreen`, `Settings`, `StatsView` | | **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) | -| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs` | +| **Overlays** | `BreakOverlay` (break enforcement), `MicrobreakOverlay` (eye break), `DimOverlay` (screen dimming), `Celebration` (confetti) | +| **Components** | `TimerRing`, `Titlebar`, `ToggleSwitch`, `Stepper`, `ColorPicker`, `FontSelector`, `TimeSpinner`, `BackgroundBlobs`, `BreathingGuide`, `ActivityManager` | | **Stores** | `timer.ts` (reactive timer state from IPC events), `config.ts` (config with debounced auto-save) | -| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (72 break activities), `animate.ts` (motion library) | +| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (71 break activities), `animate.ts` (motion library: fadeIn, scaleIn, inView, pressable, glowHover, dragScroll) |
IPC contract -**Commands** (frontend → backend): -`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` +**Commands** (frontend -> backend): +`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_weekly_summary` · `set_auto_start` · `get_auto_start_status` · `get_cursor_position` · `save_window_position` -**Events** (backend → frontend): -`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected` +**Events** (backend -> frontend): +`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected` · `screen-dim-update` · `microbreak-started` · `microbreak-ended` · `milestone-reached` · `daily-goal-met` · `break-deferred`
@@ -430,52 +510,166 @@ A split-architecture desktop app: Rust backend for system integration and timer --- -## ⚙️ Configuration Reference +## Configuration Reference All settings stored in `config.json` next to the executable. The settings panel exposes every option with live validation, but the file is plain JSON and can be edited by hand.
-Full configuration schema (35 keys) +Full configuration schema (71 keys)
-| Key | Type | Default | Range | Description | -|:----|:-----|:--------|:------|:------------| -| `break_duration` | `u32` | `5` | 1-60 min | Duration of each break | -| `break_frequency` | `u32` | `25` | 5-120 min | Interval between breaks | -| `auto_start` | `bool` | `true` | - | Start timer on launch | -| `break_title` | `string` | `"Rest your eyes"` | max 100 chars | Title shown on break screen | -| `break_message` | `string` | `"Look away..."` | max 500 chars | Message shown during breaks | -| `fullscreen_mode` | `bool` | `true` | - | Use fullscreen break window | -| `strict_mode` | `bool` | `false` | - | Remove skip/cancel buttons | -| `allow_end_early` | `bool` | `true` | - | Allow ending break after 50% | -| `immediately_start_breaks` | `bool` | `false` | - | Skip pre-break notification | -| `working_hours_enabled` | `bool` | `false` | - | Restrict timer to schedule | -| `working_hours_schedule` | `array` | Mon-Fri 09-18 | 7 days | Per-day time ranges | -| `dark_mode` | `bool` | `true` | - | Dark theme | -| `color_scheme` | `string` | `"Ocean"` | 5 presets | Color scheme name | -| `backdrop_opacity` | `f32` | `0.92` | 0.5-1.0 | Break screen opacity | -| `notification_enabled` | `bool` | `true` | - | Enable toast notifications | -| `notification_before_break` | `u32` | `30` | 0-300 sec | Pre-break warning time | -| `snooze_duration` | `u32` | `5` | 1-30 min | Snooze delay | -| `snooze_limit` | `u32` | `3` | 0-5 | Max snoozes per cycle | -| `skip_cooldown` | `u32` | `60` | 0-600 sec | Cooldown between skips | -| `sound_enabled` | `bool` | `true` | - | Play notification sounds | -| `sound_volume` | `u32` | `70` | 0-100 | Volume percentage | -| `sound_preset` | `string` | `"bell"` | 8 presets | Sound preset name | -| `idle_detection_enabled` | `bool` | `true` | - | Enable idle auto-pause | -| `idle_timeout` | `u32` | `120` | 30-600 sec | Idle threshold | -| `smart_breaks_enabled` | `bool` | `true` | - | Detect natural breaks | -| `smart_break_threshold` | `u32` | `300` | 120-900 sec | Natural break threshold | -| `smart_break_count_stats` | `bool` | `false` | - | Count natural breaks in stats | -| `show_break_activities` | `bool` | `true` | - | Show activity suggestions | -| `ui_zoom` | `u32` | `100` | 50-200% | Interface zoom level | -| `accent_color` | `string` | `"#ff4d00"` | hex | Main accent color | -| `break_color` | `string` | `"#7c6aef"` | hex | Break screen ring color | -| `countdown_font` | `string` | `""` | font family | Google Font for countdown | -| `background_blobs_enabled` | `bool` | `false` | - | Animated background blobs | -| `mini_click_through` | `bool` | `true` | - | Mini mode click-through | -| `mini_hover_threshold` | `f32` | `3.0` | 1.0-10.0 sec | Hover delay before drag | +**Timer** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `break_duration` | `u32` | `5` | Duration of each break (1-60 min) | +| `break_frequency` | `u32` | `25` | Interval between breaks (5-120 min) | +| `auto_start` | `bool` | `true` | Start timer on launch | + +**Pomodoro** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `pomodoro_enabled` | `bool` | `false` | Enable Pomodoro mode | +| `pomodoro_short_breaks` | `u32` | `3` | Short breaks before long (1-10) | +| `pomodoro_long_break_duration` | `u32` | `15` | Long break duration (5-60 min) | +| `pomodoro_long_break_title` | `string` | `"Long break"` | Long break title | +| `pomodoro_long_break_message` | `string` | `"Great work!..."` | Long break message | +| `pomodoro_reset_on_skip` | `bool` | `false` | Reset cycle when skipping | + +**Microbreaks** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `microbreak_enabled` | `bool` | `false` | Enable 20-20-20 eye breaks | +| `microbreak_frequency` | `u32` | `20` | Microbreak interval (5-60 min) | +| `microbreak_duration` | `u32` | `20` | Microbreak duration (10-60 sec) | +| `microbreak_sound_enabled` | `bool` | `true` | Play sound on microbreak | +| `microbreak_show_activity` | `bool` | `true` | Show activity during microbreak | +| `microbreak_pause_during_break` | `bool` | `true` | No microbreaks during main breaks | + +**Break Screen** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `break_title` | `string` | `"Rest your eyes"` | Title shown on break screen | +| `break_message` | `string` | `"Look away..."` | Message shown during breaks | +| `fullscreen_mode` | `bool` | `true` | Use fullscreen break window | +| `multi_monitor_break` | `bool` | `true` | Show overlay on all monitors | +| `show_break_activities` | `bool` | `true` | Show activity suggestions | + +**Activities** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `custom_activities` | `array` | `[]` | User-created activities | +| `disabled_builtin_activities` | `array` | `[]` | Disabled built-in activities | +| `favorite_builtin_activities` | `array` | `[]` | Favorited built-in activities | +| `favorite_weight` | `u32` | `3` | How much more often favorites appear | + +**Breathing Guide** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `breathing_guide_enabled` | `bool` | `true` | Show breathing guide during breaks | +| `breathing_pattern` | `string` | `"box"` | Breathing pattern (box/relaxing/energizing/calm/deep) | + +**Behavior** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `strict_mode` | `bool` | `false` | Remove skip/cancel buttons | +| `allow_end_early` | `bool` | `true` | Allow ending break after 50% | +| `immediately_start_breaks` | `bool` | `false` | Skip pre-break notification | +| `snooze_duration` | `u32` | `5` | Snooze delay (1-30 min) | +| `snooze_limit` | `u32` | `3` | Max snoozes per cycle (0-5) | + +**Alerts** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `notification_enabled` | `bool` | `true` | Enable toast notifications | +| `notification_before_break` | `u32` | `30` | Pre-break warning (0-300 sec) | +| `screen_dim_enabled` | `bool` | `false` | Gradually dim screen before breaks | +| `screen_dim_seconds` | `u32` | `10` | Start dimming N seconds before break | +| `screen_dim_max_opacity` | `f32` | `0.3` | Maximum dim intensity (10-70%) | + +**Sound** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `sound_enabled` | `bool` | `true` | Play notification sounds | +| `sound_volume` | `u32` | `70` | Volume (0-100%) | +| `sound_preset` | `string` | `"bell"` | Sound preset | + +**Idle & Smart Breaks** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `idle_detection_enabled` | `bool` | `true` | Enable idle auto-pause | +| `idle_timeout` | `u32` | `120` | Idle threshold (30-600 sec) | +| `smart_breaks_enabled` | `bool` | `true` | Detect natural breaks | +| `smart_break_threshold` | `u32` | `300` | Natural break threshold (120-900 sec) | +| `smart_break_count_stats` | `bool` | `false` | Count natural breaks in stats | + +**Presentation Mode** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `presentation_mode_enabled` | `bool` | `true` | Defer breaks during fullscreen apps | +| `presentation_mode_defer_microbreaks` | `bool` | `true` | Also defer microbreaks | +| `presentation_mode_notification` | `bool` | `true` | Show toast when break deferred | + +**Goals & Streaks** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `daily_goal_enabled` | `bool` | `true` | Track daily break target | +| `daily_goal_breaks` | `u32` | `8` | Target breaks per day (1-30) | +| `milestone_celebrations` | `bool` | `true` | Confetti on milestones | +| `streak_notifications` | `bool` | `true` | Toast on streak milestones | + +**Appearance** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `ui_zoom` | `u32` | `100` | Interface zoom (50-200%) | +| `accent_color` | `string` | `"#ff4d00"` | Main accent color | +| `break_color` | `string` | `"#7c6aef"` | Break screen ring color | +| `countdown_font` | `string` | `""` | Google Font for countdown | +| `background_blobs_enabled` | `bool` | `false` | Animated background blobs | + +**Working Hours** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `working_hours_enabled` | `bool` | `false` | Restrict timer to schedule | +| `working_hours_schedule` | `array` | Mon-Fri 09-18 | Per-day time ranges | + +**Mini Mode** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `mini_click_through` | `bool` | `true` | Mini mode click-through | +| `mini_hover_threshold` | `f32` | `3.0` | Hover delay before drag (1-10 sec) | + +**General** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `auto_start_on_login` | `bool` | `false` | Launch on Windows startup | + +**Window Position (internal)** + +| Key | Type | Default | Description | +|:----|:-----|:--------|:------------| +| `main_window_x` | `i32?` | `null` | Main window X position | +| `main_window_y` | `i32?` | `null` | Main window Y position | +| `main_window_width` | `u32?` | `null` | Main window width | +| `main_window_height` | `u32?` | `null` | Main window height | +| `mini_window_x` | `i32?` | `null` | Mini window X position | +| `mini_window_y` | `i32?` | `null` | Mini window Y position |
@@ -483,7 +677,7 @@ All settings stored in `config.json` next to the executable. The settings panel --- -## 📚 Dependencies +## Dependencies
Rust crates @@ -519,7 +713,7 @@ All settings stored in `config.json` next to the executable. The settings panel --- -## 🤝 Contributing +## Contributing This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome. @@ -527,12 +721,12 @@ No contribution agreements to sign. No corporate CLAs. No licensing traps. Every **Some ways to help:** -- 🐛 Report bugs or rough edges -- 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge) -- ♿ Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further) -- 🐧 Port idle detection to macOS/Linux -- 🌍 Translate the interface -- 💌 Share it with someone who needs it +- Report bugs or rough edges +- Suggest new break activities (especially with physiotherapy or ergonomics knowledge) +- Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further) +- Port idle detection to macOS/Linux +- Translate the interface +- Share it with someone who needs it The best software is built through mutual aid - people helping people because it's the right thing to do, not because there's a profit motive attached. @@ -540,7 +734,7 @@ The best software is built through mutual aid - people helping people because it --- -## 📄 License +## License

@@ -564,7 +758,7 @@ See [`LICENSE`](LICENSE) for the full legal text.

- Built with care. Shared without conditions. 🧊
+ Built with care. Shared without conditions.
Rest well.

diff --git a/package.json b/package.json index cf2a6df..925d3a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "core-cooldown", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 27efbd5..8e95e49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -480,7 +480,7 @@ dependencies = [ [[package]] name = "core-cooldown" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff92cfb..f79d509 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c7422fb..f04a796 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index c45375d..4942caa 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -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, + pub disabled_builtin_activities: Vec, + pub favorite_builtin_activities: Vec, + 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, pub main_window_y: Option, @@ -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(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5bd9d3e..fa04f74 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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>, @@ -127,7 +127,13 @@ fn set_view(state: State, view: AppView) { #[tauri::command] fn get_stats(state: State) -> 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, days: u32) -> Vec s.recent_days(days) } +// F7: Weekly summary command +#[tauri::command] +fn get_weekly_summary(state: State, weeks: u32) -> Vec { + 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 = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let value_name: Vec = 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 = 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 = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let value_name: Vec = 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 { 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::().timer.clone(); let stats_ref = app.state::().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(); + } + } +} diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index b173475..3d4ffa2 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -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, + 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 { @@ -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 { + 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 { + 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 + } } diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs index ee2d7ef..d9cd5cc 100644 --- a/src-tauri/src/timer.rs +++ b/src-tauri/src/timer.rs @@ -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, 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 { 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::() 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 { + 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); + let mut mi: MONITORINFO = std::mem::zeroed(); + mi.cbSize = std::mem::size_of::() 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 = Vec::new(); + unsafe { + EnumDisplayMonitors( + std::ptr::null_mut(), + std::ptr::null(), + Some(callback), + &mut monitors as *mut Vec as isize, + ); + } + monitors +} + +#[cfg(not(windows))] +pub fn get_all_monitors() -> Vec { + Vec::new() +} + +#[derive(Debug, Clone)] +pub struct MonitorInfo { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub is_primary: bool, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e5a347c..1651e0b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.svelte b/src/App.svelte index 2c656f5..07c364b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,7 @@ import Settings from "./lib/components/Settings.svelte"; import StatsView from "./lib/components/StatsView.svelte"; import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte"; + import Celebration from "./lib/components/Celebration.svelte"; const appWindow = getCurrentWebviewWindow(); @@ -133,4 +134,5 @@ {/if} + diff --git a/src/lib/components/ActivityManager.svelte b/src/lib/components/ActivityManager.svelte new file mode 100644 index 0000000..7d4e2f0 --- /dev/null +++ b/src/lib/components/ActivityManager.svelte @@ -0,0 +1,581 @@ + + +
+ +
+
+ { if (e.key === "Enter") addCustomActivity(); }} + /> +
+ + + + +
+ + +
+ {#each categories as cat} + {@const total = builtinCount(cat) + customCount(cat)} + {@const disabled = disabledCount(cat)} + {@const isExpanded = expandedCategory === cat} + + {/each} +
+ + + {#each categories as cat} + {@const isExpanded = expandedCategory === cat} + {@const catBuiltins = breakActivities.filter((a) => a.category === cat)} + {@const catCustoms = $config.custom_activities.filter((a) => a.category === cat)} +
+
+
+ {#if catCustoms.length > 0} +
+
Custom
+
+ {/if} + +
+ +
+
+ {#each catCustoms as activity (activity.id)} +
+ + + {activity.text} + + + toggleCustomEnabled(activity.id)} + /> +
+ {/each} + + {#if catCustoms.length > 0 && catBuiltins.length > 0} +
+
Built-in
+
+ {/if} + + {#each catBuiltins as activity (activity.text)} +
+ + + {activity.text} + + toggleBuiltinEnabled(activity.text)} + /> +
+ {/each} +
+
+
+
+
+
+ {/each} +
+ + diff --git a/src/lib/components/BreakOverlay.svelte b/src/lib/components/BreakOverlay.svelte new file mode 100644 index 0000000..976c3bd --- /dev/null +++ b/src/lib/components/BreakOverlay.svelte @@ -0,0 +1,56 @@ + + +
+

Break in progress

+ + + {formatTime(breakTimeRemaining)} + + + +
+
+
+
diff --git a/src/lib/components/BreakScreen.svelte b/src/lib/components/BreakScreen.svelte index a751f7b..ccff681 100644 --- a/src/lib/components/BreakScreen.svelte +++ b/src/lib/components/BreakScreen.svelte @@ -7,6 +7,7 @@ import { onMount } from "svelte"; import { scaleIn, fadeIn, pressable } from "../utils/animate"; import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities"; + import BreathingGuide from "./BreathingGuide.svelte"; interface Props { standalone?: boolean; @@ -16,14 +17,14 @@ const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null); - let currentActivity = $state(pickRandomActivity()); + let currentActivity = $state(pickRandomActivity(undefined, $config)); let activityCycleTimer: ReturnType | null = null; // Cycle activity every 30 seconds during break $effect(() => { if ($config.show_break_activities && $timer.state === "breakActive") { activityCycleTimer = setInterval(() => { - currentActivity = pickRandomActivity(currentActivity); + currentActivity = pickRandomActivity(currentActivity, $config); }, 30_000); } return () => { @@ -34,6 +35,9 @@ }; }); + // F3: Long break indicator + const isLongBreak = $derived($timer.isLongBreak); + async function cancelBreak() { const snap = await invoke("cancel_break"); timer.set(snap); @@ -65,6 +69,32 @@ const showButtons = $derived(!$config.strict_mode); + // Breathing guide bindable state + let breathPhase = $state("Inhale"); + let breathCountdown = $state(4); + let breathScale = $state(0.6); + + // Map raw 0.6–1.0 scale to 0.9–1.6 range for visible breathing text + const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4)); + + // Interpolate color between break_color (inhale) and accent_color (exhale) + // breathScale: 0.6 = exhale (accent), 1.0 = inhale (break) + function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + } + function lerpColor(c1: string, c2: string, t: number): string { + const [r1, g1, b1] = hexToRgb(c1); + const [r2, g2, b2] = hexToRgb(c2); + const r = Math.round(r1 + (r2 - r1) * t); + const g = Math.round(g1 + (g2 - g1) * t); + const b = Math.round(b1 + (b2 - b1) * t); + return `rgb(${r},${g},${b})`; + } + // t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0) + const breathT = $derived((breathScale - 0.6) / 0.4); + const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT)); + // Bottom progress bar uses a gradient from break color to accent const barGradient = $derived( `linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`, @@ -105,7 +135,21 @@
-
+ + {#if $config.breathing_guide_enabled} + + {/if} +
-
+
{formatTime($timer.breakTimeRemaining)} + {#if $config.breathing_guide_enabled} + + {breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""} + + {/if}
@@ -140,7 +194,7 @@
{getCategoryLabel(currentActivity.category)}
-

+

{currentActivity.text}

@@ -209,7 +263,22 @@
-
+ + {#if $config.breathing_guide_enabled} + + {/if} + +
-
+
{formatTime($timer.breakTimeRemaining)} + {#if $config.breathing_guide_enabled} + + {breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""} + + {/if}
+ + {#if isLongBreak} +
+ Long break +
+ {/if} +

{$timer.breakTitle}

@@ -253,7 +344,7 @@
{getCategoryLabel(currentActivity.category)}
-

+

{currentActivity.text}

@@ -411,23 +502,6 @@ background: rgba(255, 255, 255, 0.05); } - /* ── Breathing pulse on the ring ── */ - .break-breathe { - animation: breathe 4s ease-in-out infinite; - } - @keyframes breathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.04); } - } - - .break-breathe-counter { - animation: breathe-counter 4s ease-in-out infinite; - } - @keyframes breathe-counter { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(0.962); } - } - /* ── Ripple circles ── */ .break-ripple { position: absolute; diff --git a/src/lib/components/BreathingGuide.svelte b/src/lib/components/BreathingGuide.svelte new file mode 100644 index 0000000..698a9e6 --- /dev/null +++ b/src/lib/components/BreathingGuide.svelte @@ -0,0 +1,183 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + {#if showLabel} +
+ + {phaseLabel} + + {#if countdown > 0} + + {countdown} + + {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/Celebration.svelte b/src/lib/components/Celebration.svelte new file mode 100644 index 0000000..cc8b458 --- /dev/null +++ b/src/lib/components/Celebration.svelte @@ -0,0 +1,166 @@ + + +{#if showMilestone} + +{/if} + +{#if showGoal && !showMilestone} + +{/if} + + diff --git a/src/lib/components/Dashboard.svelte b/src/lib/components/Dashboard.svelte index 7def8a2..d87026f 100644 --- a/src/lib/components/Dashboard.svelte +++ b/src/lib/components/Dashboard.svelte @@ -10,6 +10,7 @@ import { config } from "../stores/config"; import TimerRing from "./TimerRing.svelte"; import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate"; + import { listen } from "@tauri-apps/api/event"; async function toggleTimer() { const snap = await invoke("toggle_timer"); @@ -28,15 +29,45 @@ } const statusText = $derived( - $timer.idlePaused - ? "IDLE" - : $timer.prebreakWarning - ? "BREAK SOON" - : $timer.state === "running" - ? "FOCUS" - : "PAUSED", + $timer.deferredBreakPending + ? "DEFERRED" + : $timer.idlePaused + ? "IDLE" + : $timer.prebreakWarning + ? "BREAK SOON" + : $timer.state === "running" + ? "FOCUS" + : "PAUSED", ); + // F1: Microbreak countdown + const microbreakCountdown = $derived(() => { + if (!$timer.microbreakEnabled || $timer.microbreakActive) return ""; + const secs = $timer.microbreakCountdown; + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + }); + + // F10: Daily goal from stats + let dailyGoalProgress = $state(0); + let dailyGoalMet = $state(false); + + // Load stats for daily goal display + async function loadGoalProgress() { + try { + const stats = await invoke<{ dailyGoalProgress: number; dailyGoalMet: boolean }>("get_stats"); + dailyGoalProgress = stats.dailyGoalProgress; + dailyGoalMet = stats.dailyGoalMet; + } catch {} + } + + $effect(() => { + // Reload goal progress on each tick (approximately) + const _state = $timer.state; + loadGoalProgress(); + }); + // Track status changes for aria-live region (announce only on change, not every tick) let lastAnnouncedStatus = $state(""); let statusAnnouncement = $state(""); @@ -146,11 +177,74 @@ {statusText} + + +
+ + {#if $timer.pomodoroEnabled} +
+
+ {#each Array($timer.pomodoroTotalInCycle) as _, i} + {@const isLong = i === $timer.pomodoroTotalInCycle - 1} + {@const isFilled = i < $timer.pomodoroCyclePosition} + {@const isCurrent = i === $timer.pomodoroCyclePosition} +
+ {/each} +
+ + {$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle} + +
+ {/if} + + + {#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"} +
+ + {microbreakCountdown()} +
+ {/if} + + + {#if $config.daily_goal_enabled} +
+ {#if dailyGoalMet} + + Goal met + {:else} + Goal +
+
+
+ + {dailyGoalProgress}/{$config.daily_goal_breaks} + + {/if} +
+ {/if} +
diff --git a/src/lib/components/DimOverlay.svelte b/src/lib/components/DimOverlay.svelte new file mode 100644 index 0000000..298fd72 --- /dev/null +++ b/src/lib/components/DimOverlay.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/MicrobreakOverlay.svelte b/src/lib/components/MicrobreakOverlay.svelte new file mode 100644 index 0000000..e724600 --- /dev/null +++ b/src/lib/components/MicrobreakOverlay.svelte @@ -0,0 +1,80 @@ + + +
+
+ + Look away — 20 feet for {timeRemaining}s +
+ + {#if activity && $config.microbreak_show_activity} +

+ {activity.text} +

+ {/if} + + +
+
+
+
+ + diff --git a/src/lib/components/MiniTimer.svelte b/src/lib/components/MiniTimer.svelte index a66675b..9b82542 100644 --- a/src/lib/components/MiniTimer.svelte +++ b/src/lib/components/MiniTimer.svelte @@ -16,6 +16,9 @@ let breakColor = $state("#7c6aef"); let countdownFont = $state(""); let draggable = $state(false); + let pomodoroEnabled = $state(false); + let pomodoroCyclePosition = $state(0); + let pomodoroTotalInCycle = $state(4); // Use config store directly for live updates const uiZoom = $derived($config.ui_zoom); @@ -132,6 +135,9 @@ timeText = formatTime(snap.timeRemaining); progress = snap.progress; } + pomodoroEnabled = snap.pomodoroEnabled; + pomodoroCyclePosition = snap.pomodoroCyclePosition; + pomodoroTotalInCycle = snap.pomodoroTotalInCycle; } // Click opens main window @@ -333,6 +339,12 @@ const fontStyle = $derived( > {timeText} + + {#if pomodoroEnabled} + + {pomodoroCyclePosition + 1}/{pomodoroTotalInCycle} + + {/if} diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 69953a6..4b3ce49 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -7,10 +7,36 @@ import ColorPicker from "./ColorPicker.svelte"; import FontSelector from "./FontSelector.svelte"; import TimeSpinner from "./TimeSpinner.svelte"; + import ActivityManager from "./ActivityManager.svelte"; import { fadeIn, inView, pressable, dragScroll } from "../utils/animate"; import { playSound } from "../utils/sounds"; import type { TimeRange } from "../stores/config"; + const breathingPatternMeta = [ + { id: "box", label: "Box", desc: "4s in \u00b7 4s hold \u00b7 4s out \u00b7 4s hold" }, + { id: "relaxing", label: "Relaxing", desc: "4s in \u00b7 7s hold \u00b7 8s out" }, + { id: "energizing", label: "Energizing", desc: "6s in \u00b7 2s hold \u00b7 6s out \u00b7 2s hold" }, + { id: "calm", label: "Calm", desc: "4s in \u00b7 4s hold \u00b7 6s out" }, + { id: "deep", label: "Deep", desc: "5s in \u00b7 5s out" }, + ] as const; + + // F8: Auto-start on login + let autoStartEnabled = $state(false); + async function loadAutoStartStatus() { + try { + autoStartEnabled = await invoke("get_auto_start_status"); + } catch {} + } + async function toggleAutoStart() { + try { + await invoke("set_auto_start", { enabled: !autoStartEnabled }); + autoStartEnabled = !autoStartEnabled; + } catch (e) { + console.error("Failed to set auto-start:", e); + } + } + $effect(() => { loadAutoStartStatus(); }); + const soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const; const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const; @@ -131,11 +157,10 @@ use:dragScroll >
- + +
-

+

Timer

@@ -188,11 +213,137 @@
- + +
+

+ Pomodoro Mode +

+ +
+
+
Enable Pomodoro
+
Short breaks then a long break
+
+ +
+ + {#if $config.pomodoro_enabled} +
+
+
+
Short breaks before long
+
{$config.pomodoro_short_breaks} short + 1 long
+
+ +
+ +
+
+
+
Long break duration
+
{$config.pomodoro_long_break_duration} min
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
Reset on skip
+
Reset cycle when skipping a break
+
+ +
+ {/if} +
+ +
-

+

+ Microbreaks +

+ +
+
+
20-20-20 eye breaks
+
Quick eye rest reminders
+
+ +
+ + {#if $config.microbreak_enabled} +
+
+
+
Frequency
+
Every {$config.microbreak_frequency} min
+
+ +
+ +
+
+
+
Duration
+
{$config.microbreak_duration} seconds
+
+ +
+ +
+
+
+
Sound
+
Play sound on eye break
+
+ +
+ +
+
+
+
Show activity
+
Activity suggestion during eye break
+
+ +
+ +
+
+
+
Pause during breaks
+
No eye breaks during main breaks
+
+ +
+ {/if} +
+ + +
+

Break Screen

@@ -261,13 +412,90 @@ onchange={markChanged} /> + + {#if $config.fullscreen_mode} +
+
+
+
Block all monitors
+
Show overlay on all screens during breaks
+
+ +
+ {/if}
- -
-

+ + {#if $config.show_break_activities} +
+

+ Break Activities +

+ +
+ {/if} + + +
+

+ Breathing Guide +

+ +
+
+
Guided breathing
+
Visual breathing guide during breaks
+
+ +
+ + {#if $config.breathing_guide_enabled} +
+
+
Breathing pattern
+
+ {#each breathingPatternMeta as bp} + + {/each} +
+
+ {/if} +
+ + +
+

Behavior

@@ -357,114 +585,152 @@
- -
+ +
+

+ Alerts +

+
-
Working hours
-
- Only show breaks during your configured work schedule -
+
Pre-break alert
+
Warn before breaks
- {#if $config.working_hours_enabled} + {#if $config.notification_enabled}
- {#each $config.working_hours_schedule as daySchedule, dayIndex} - {@const dayName = daysOfWeek[dayIndex]} -
- -
- - {dayName} +
+
+
Alert timing
+
+ {$config.notification_before_break}s before
- - {#if daySchedule.enabled} - -
- {#each daySchedule.ranges as range, rangeIndex} -
- updateTimeRange(dayIndex, rangeIndex, "start", v)} - /> - to - updateTimeRange(dayIndex, rangeIndex, "end", v)} - /> - - - {#if rangeIndex === daySchedule.ranges.length - 1} - - {/if} - - - - - - {#if rangeIndex > 0} - - {/if} -
- {/each} -
- {/if}
- - {#if dayIndex < 6} -
- {/if} - {/each} + +
+ {/if} + +
+ +
+
+
Screen dimming
+
Gradually dim screen before breaks
+
+ +
+ + {#if $config.screen_dim_enabled} +
+
+
+
Start dimming
+
{$config.screen_dim_seconds}s before break
+
+ +
+ +
+
+
+
Max dimming
+
{Math.round($config.screen_dim_max_opacity * 100)}%
+
+ `${Math.round(v * 100)}%`} + onchange={markChanged} + /> +
{/if}
- -
-

- Idle Detection + +
+

+ Sound +

+ +
+
+
Sound effects
+
Play sounds on break events
+
+ +
+ + {#if $config.sound_enabled} +
+ +
+
+
Volume
+
{$config.sound_volume}%
+
+ `${v}%`} + onchange={markChanged} + /> +
+ +
+ +
+
Sound preset
+
+ {#each soundPresets as preset} + + {/each} +
+
+ {/if} +
+ + +
+

+ Idle & Smart Breaks

@@ -500,19 +766,12 @@ />
{/if} -
- -
-

- Smart Breaks -

+
-
Enable smart breaks
+
Smart breaks
Auto-reset timer when you step away
- -
-

- Notifications + +
+

+ Presentation Mode

-
Pre-break alert
-
Warn before breaks
+
Auto-detect fullscreen
+
Defer breaks during fullscreen apps
- +
- {#if $config.notification_enabled} -
- -
-
-
Alert timing
-
- {$config.notification_before_break}s before + {#if $config.presentation_mode_enabled} + {#if $config.microbreak_enabled} +
+
+
+
Defer microbreaks
+
Also pause eye breaks
+
- + {/if} + +
+
+
+
Notification
+
Show toast when break is deferred
+
+
{/if}
- -
-

- Sound + +
+

+ Goals & Streaks

-
Sound effects
-
Play sounds on break events
+
Daily goal
+
Track daily break target
- +
- {#if $config.sound_enabled} + {#if $config.daily_goal_enabled}
-
-
Volume
-
{$config.sound_volume}%
-
- `${v}%`} - onchange={markChanged} - /> -
- -
- -
-
Sound preset
-
- {#each soundPresets as preset} - - {/each} +
Target breaks
+
{$config.daily_goal_breaks} per day
+
{/if} + +
+
+
+
Celebrations
+
Confetti on milestones and goals
+
+ +
+ +
+
+
+
Streak notifications
+
Toast on streak milestones
+
+ +
- -
-

+ +
+

Appearance

@@ -741,11 +971,111 @@

- -
-

+ +
+
+
+
Working hours
+
+ Only show breaks during your configured work schedule +
+
+ +
+ + {#if $config.working_hours_enabled} +
+ + {#each $config.working_hours_schedule as daySchedule, dayIndex} + {@const dayName = daysOfWeek[dayIndex]} +
+ +
+ + {dayName} +
+ + {#if daySchedule.enabled} + +
+ {#each daySchedule.ranges as range, rangeIndex} +
+ updateTimeRange(dayIndex, rangeIndex, "start", v)} + /> + to + updateTimeRange(dayIndex, rangeIndex, "end", v)} + /> + + + {#if rangeIndex === daySchedule.ranges.length - 1} + + {/if} + + + + + + {#if rangeIndex > 0} + + {/if} +
+ {/each} +
+ {/if} +
+ + {#if dayIndex < 6} +
+ {/if} + {/each} + {/if} +
+ + +
+

Mini Mode

@@ -784,11 +1114,28 @@ {/if}
- -
-

+ +
+

+ General +

+ +
+
+
Start on Windows login
+
Launch automatically at startup
+
+ +
+
+ + +
+

Keyboard Shortcuts

@@ -808,8 +1155,8 @@
- -
+ +

+ +
+ {#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]} + + {/each} +
+
+ + {#if activeTab === "today"}
-

+

Today

@@ -224,11 +350,42 @@
+ + {#if $config.daily_goal_enabled} +
+

+ Daily Goal +

+
+
+ + + + +
+ {goalPercent}% +
+
+
+
+ {stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks +
+
+ {stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`} +
+
+
+
+ {/if} +
-

+

Streak

@@ -253,13 +410,25 @@ {stats?.bestStreak ?? 0} + + + {#if nextMilestone()} +
+
+
+
Next milestone
+
{nextMilestone()} day streak
+
+
+ {nextMilestone()! - (stats?.currentStreak ?? 0)} days away +
+
+ {/if}
-

+

Last 7 Days

@@ -271,7 +440,6 @@ aria-label={chartAriaLabel()} > - {#if history.length > 0} @@ -301,6 +469,142 @@ + + {:else if activeTab === "weekly"} + + {#each weeklySummaries as week, i} + {@const prevWeek = weeklySummaries[i + 1]} + {@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0} +
+

+ Week of {week.weekStart} +

+ +
+
+
{week.totalCompleted}
+
Completed
+
+
+
{week.totalSkipped}
+
Skipped
+
+
+
+ {Math.round(week.complianceRate * 100)}% +
+
Compliance
+
+
+ +
+ Avg {week.avgDailyCompleted.toFixed(1)} breaks/day + {#if prevWeek} + + {#if trend > 0} + + {:else if trend < 0} + + {:else} + + {/if} + {Math.abs(Math.round(trend * 100))}% + + {/if} +
+
+ {/each} + + {:else} + +
+

+ Last 30 Days +

+ + + + +
+
+
+ Completed +
+
+
+ Skipped +
+
+
+ + +
+

+ Activity Heatmap +

+ +
+ + +
+ +
+ Less +
+
+
+
+
+
+
+ More +
+
+ + +
+

+ Monthly Summary +

+ +
+
+
{monthTotalCompleted}
+
Total breaks
+
+
+
+ {monthAvgCompliance()}% +
+
Avg compliance
+
+
+
+ {Math.floor(monthTotalTime / 60)} min +
+
Total break time
+
+
+
+ {(monthTotalCompleted / 30).toFixed(1)} +
+
Avg daily breaks
+
+
+
+ {/if} + diff --git a/src/lib/components/Stepper.svelte b/src/lib/components/Stepper.svelte index c93c49e..c6ce1ca 100644 --- a/src/lib/components/Stepper.svelte +++ b/src/lib/components/Stepper.svelte @@ -62,7 +62,7 @@ type="button" aria-label="Decrease" class="flex h-7 w-7 items-center justify-center rounded-lg - bg-[#141414] text-[#999] transition-colors + bg-[#141414] text-[#8a8a8a] transition-colors hover:bg-[#1c1c1c] hover:text-white disabled:opacity-20" onmousedown={() => startHold(decrement)} @@ -80,7 +80,7 @@ type="button" aria-label="Increase" class="flex h-7 w-7 items-center justify-center rounded-lg - bg-[#141414] text-[#999] transition-colors + bg-[#141414] text-[#8a8a8a] transition-colors hover:bg-[#1c1c1c] hover:text-white disabled:opacity-20" onmousedown={() => startHold(increment)} diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts index aada2b6..7fdd784 100644 --- a/src/lib/stores/config.ts +++ b/src/lib/stores/config.ts @@ -11,7 +11,13 @@ export interface DaySchedule { ranges: TimeRange[]; } -export type { TimeRange, DaySchedule }; +export interface CustomActivity { + id: string; + category: string; + text: string; + is_favorite: boolean; + enabled: boolean; +} export interface Config { break_duration: number; @@ -24,7 +30,7 @@ export interface Config { allow_end_early: boolean; immediately_start_breaks: boolean; working_hours_enabled: boolean; - working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun + working_hours_schedule: DaySchedule[]; dark_mode: boolean; color_scheme: string; backdrop_opacity: number; @@ -49,6 +55,45 @@ export interface Config { background_blobs_enabled: boolean; mini_click_through: boolean; mini_hover_threshold: number; + // F8: Auto-start on login + auto_start_on_login: boolean; + // F6: Custom activities + custom_activities: CustomActivity[]; + disabled_builtin_activities: string[]; + favorite_builtin_activities: string[]; + favorite_weight: number; + // F4: Breathing guide + breathing_guide_enabled: boolean; + breathing_pattern: string; + // F10: Gamification + daily_goal_enabled: boolean; + daily_goal_breaks: number; + milestone_celebrations: boolean; + streak_notifications: boolean; + // F1: Microbreaks + microbreak_enabled: boolean; + microbreak_frequency: number; + microbreak_duration: number; + microbreak_sound_enabled: boolean; + microbreak_show_activity: boolean; + microbreak_pause_during_break: boolean; + // F3: Pomodoro + pomodoro_enabled: boolean; + pomodoro_short_breaks: number; + pomodoro_long_break_duration: number; + pomodoro_long_break_title: string; + pomodoro_long_break_message: string; + pomodoro_reset_on_skip: boolean; + // F5: Screen dimming + screen_dim_enabled: boolean; + screen_dim_seconds: number; + screen_dim_max_opacity: number; + // F2: Presentation mode + presentation_mode_enabled: boolean; + presentation_mode_defer_microbreaks: boolean; + presentation_mode_notification: boolean; + // F9: Multi-monitor + multi_monitor_break: boolean; } const defaultConfig: Config = { @@ -63,13 +108,13 @@ const defaultConfig: Config = { immediately_start_breaks: false, working_hours_enabled: false, working_hours_schedule: [ - { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday - { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday - { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday - { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday - { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday - { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday - { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday + { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, + { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, ], dark_mode: true, color_scheme: "Ocean", @@ -95,6 +140,45 @@ const defaultConfig: Config = { background_blobs_enabled: true, mini_click_through: true, mini_hover_threshold: 3.0, + // F8 + auto_start_on_login: false, + // F6 + custom_activities: [], + disabled_builtin_activities: [], + favorite_builtin_activities: [], + favorite_weight: 3, + // F4 + breathing_guide_enabled: true, + breathing_pattern: "box", + // F10 + daily_goal_enabled: true, + daily_goal_breaks: 8, + milestone_celebrations: true, + streak_notifications: true, + // F1 + microbreak_enabled: false, + microbreak_frequency: 20, + microbreak_duration: 20, + microbreak_sound_enabled: true, + microbreak_show_activity: true, + microbreak_pause_during_break: true, + // F3 + pomodoro_enabled: false, + pomodoro_short_breaks: 3, + pomodoro_long_break_duration: 15, + pomodoro_long_break_title: "Long break", + pomodoro_long_break_message: "Great work! Take a longer rest.", + pomodoro_reset_on_skip: false, + // F5 + screen_dim_enabled: false, + screen_dim_seconds: 10, + screen_dim_max_opacity: 0.3, + // F2 + presentation_mode_enabled: true, + presentation_mode_defer_microbreaks: true, + presentation_mode_notification: true, + // F9 + multi_monitor_break: true, }; export const config = writable(defaultConfig); diff --git a/src/lib/stores/timer.ts b/src/lib/stores/timer.ts index e4f9eb0..5a8be57 100644 --- a/src/lib/stores/timer.ts +++ b/src/lib/stores/timer.ts @@ -26,6 +26,27 @@ export interface TimerSnapshot { naturalBreakOccurred: boolean; smartBreaksEnabled: boolean; smartBreakThreshold: number; + // F1: Microbreaks + microbreakEnabled: boolean; + microbreakActive: boolean; + microbreakTimeRemaining: number; + microbreakTotalDuration: number; + microbreakCountdown: number; + microbreakFrequency: number; + // F3: Pomodoro + pomodoroEnabled: boolean; + pomodoroCyclePosition: number; + pomodoroTotalInCycle: number; + pomodoroIsLongBreak: boolean; + pomodoroNextIsLong: boolean; + // F5: Screen dimming + screenDimActive: boolean; + screenDimProgress: number; + // F2: Presentation mode + presentationModeActive: boolean; + deferredBreakPending: boolean; + // F10: Gamification + isLongBreak: boolean; } const defaultSnapshot: TimerSnapshot = { @@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = { naturalBreakOccurred: false, smartBreaksEnabled: true, smartBreakThreshold: 300, + microbreakEnabled: false, + microbreakActive: false, + microbreakTimeRemaining: 0, + microbreakTotalDuration: 0, + microbreakCountdown: 0, + microbreakFrequency: 1200, + pomodoroEnabled: false, + pomodoroCyclePosition: 0, + pomodoroTotalInCycle: 4, + pomodoroIsLongBreak: false, + pomodoroNextIsLong: false, + screenDimActive: false, + screenDimProgress: 0, + presentationModeActive: false, + deferredBreakPending: false, + isLongBreak: false, }; export const timer = writable(defaultSnapshot); @@ -114,8 +151,43 @@ export async function initTimerStore() { playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5); } }); + + // F1: Microbreak events + await listen("microbreak-started", () => { + const cfg = get(config); + if (cfg.microbreak_sound_enabled && cfg.sound_enabled) { + playSound(cfg.sound_preset as any, cfg.sound_volume * 0.4); + } + }); + + await listen("microbreak-ended", () => { + const cfg = get(config); + if (cfg.microbreak_sound_enabled && cfg.sound_enabled) { + playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume * 0.3); + } + }); + + // F10: Milestone and daily goal events + await listen<{ streak: number }>("milestone-reached", (event) => { + milestoneEvent.set(event.payload.streak); + setTimeout(() => milestoneEvent.set(null), 4000); + }); + + await listen("daily-goal-met", () => { + dailyGoalEvent.set(true); + setTimeout(() => dailyGoalEvent.set(false), 4000); + }); + + // F2: Break deferred + await listen("break-deferred", () => { + // Dashboard will show deferred status from snapshot + }); } +// F10: Gamification event stores +export const milestoneEvent = writable(null); +export const dailyGoalEvent = writable(false); + // Helper: format seconds as MM:SS export function formatTime(secs: number): string { const m = Math.floor(secs / 60); diff --git a/src/lib/utils/activities.ts b/src/lib/utils/activities.ts index d96c1bc..eacfabd 100644 --- a/src/lib/utils/activities.ts +++ b/src/lib/utils/activities.ts @@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string { return categoryLabels[cat]; } -/** Pick a random activity, optionally excluding a previous one */ -export function pickRandomActivity(exclude?: BreakActivity): BreakActivity { - const pool = exclude - ? breakActivities.filter((a) => a.text !== exclude.text) - : breakActivities; - return pool[Math.floor(Math.random() * pool.length)]; +/** Pick a random activity, optionally excluding a previous one. + * When config is provided, respects disabled/favorite/custom activity settings. */ +export function pickRandomActivity( + exclude?: BreakActivity, + config?: { + disabled_builtin_activities?: string[]; + favorite_builtin_activities?: string[]; + custom_activities?: Array<{ category: string; text: string; is_favorite: boolean; enabled: boolean }>; + favorite_weight?: number; + }, +): BreakActivity { + const disabled = new Set(config?.disabled_builtin_activities ?? []); + const favorites = new Set(config?.favorite_builtin_activities ?? []); + const weight = config?.favorite_weight ?? 3; + + // Build pool: enabled builtins + enabled customs + let pool: BreakActivity[] = breakActivities.filter((a) => !disabled.has(a.text)); + + // Add enabled custom activities + if (config?.custom_activities) { + for (const ca of config.custom_activities) { + if (ca.enabled) { + const cat = (["eyes", "stretch", "breathing", "movement"].includes(ca.category) + ? ca.category + : "movement") as BreakActivity["category"]; + pool.push({ category: cat, text: ca.text }); + } + } + } + + // Exclude previous + if (exclude) { + pool = pool.filter((a) => a.text !== exclude.text); + } + + if (pool.length === 0) { + return exclude ?? breakActivities[0]; + } + + // Build weighted pool: favorites appear `weight` times + const weighted: BreakActivity[] = []; + for (const a of pool) { + const isFav = favorites.has(a.text) || + (config?.custom_activities?.some((c) => c.text === a.text && c.is_favorite) ?? false); + const count = isFav ? weight : 1; + for (let i = 0; i < count; i++) { + weighted.push(a); + } + } + + return weighted[Math.floor(Math.random() * weighted.length)]; } diff --git a/src/main.ts b/src/main.ts index a4d586c..a93e697 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,19 +2,35 @@ import "./app.css"; import App from "./App.svelte"; import MiniTimer from "./lib/components/MiniTimer.svelte"; import BreakWindow from "./lib/components/BreakWindow.svelte"; +import MicrobreakOverlay from "./lib/components/MicrobreakOverlay.svelte"; +import DimOverlay from "./lib/components/DimOverlay.svelte"; +import BreakOverlay from "./lib/components/BreakOverlay.svelte"; import { mount } from "svelte"; const params = new URLSearchParams(window.location.search); +const isMicrobreak = params.has("microbreak"); +const isDim = params.has("dim"); +const isBreakOverlay = params.has("breakoverlay"); const isMini = params.has("mini"); const isBreak = params.has("break"); -if (isMini || isBreak) { +if (isMini || isBreak || isMicrobreak || isDim || isBreakOverlay) { // Transparent body so rounded shapes show through the transparent window document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; } -const component = isMini ? MiniTimer : isBreak ? BreakWindow : App; +const component = isMicrobreak + ? MicrobreakOverlay + : isDim + ? DimOverlay + : isBreakOverlay + ? BreakOverlay + : isMini + ? MiniTimer + : isBreak + ? BreakWindow + : App; const app = mount(component, { target: document.getElementById("app")!,
Break history for the last {history.length} days