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 - 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 - Grouped configuration cards with live preview
+ Settings - 18 grouped configuration cards with live preview
- 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 cdc906b..c3fff7b 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 05cc5a2..acd2057 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 d738e0a..1469257 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(); }}
+ />
+
+
+
+
+
{ dropdownOpen = !dropdownOpen; }}
+ aria-haspopup="listbox"
+ aria-expanded={dropdownOpen}
+ aria-label="Category: {getCategoryLabel(newActivityCategory)}"
+ >
+ {getCategoryLabel(newActivityCategory)}
+
+
+
+
+ {#if dropdownOpen}
+
+ {#each categories as cat, i}
+ { newActivityCategory = cat; dropdownOpen = false; dropdownTriggerRef?.focus(); }}
+ >
+ {getCategoryLabel(cat)}
+
+ {/each}
+
+ {/if}
+
+
+ +
+
+
+
+
+
+ {#each categories as cat}
+ {@const total = builtinCount(cat) + customCount(cat)}
+ {@const disabled = disabledCount(cat)}
+ {@const isExpanded = expandedCategory === cat}
+ toggleCategory(cat)}
+ aria-expanded={isExpanded}
+ aria-controls="activity-panel-{cat}"
+ >
+ {getCategoryLabel(cat)}
+
+ {total - disabled}/{total}
+
+
+ {/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}
+
+ {/if}
+
+
+
+
+
+ {/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 f449ed1..97512ad 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}
+
+
+
+ {#each confettiParticles as p (p.id)}
+
+ {/each}
+
+
+
+
+
{streakDays}
+
+ day streak!
+
+
+
+{/if}
+
+{#if showGoal && !showMilestone}
+
+
+
+
+
+
Daily goal reached!
+
+
+{/if}
+
+
diff --git a/src/lib/components/Dashboard.svelte b/src/lib/components/Dashboard.svelte
index 829d325..2f5c1ba 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..68357b5
--- /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
+
+
+
+
+
+
+ Long break title
+
+
+
+
+
+ Long break message
+
+
+
+
+
+
+
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}
+
{
+ $config.breathing_pattern = bp.id;
+ markChanged();
+ }}
+ >
+
+
+ {bp.label}
+
+
+ {bp.desc}
+
+
+ {/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}
- addTimeRange(dayIndex)}
- aria-label="Add time range"
- >
-
-
-
-
- {/if}
-
-
- cloneTimeRange(dayIndex, rangeIndex)}
- aria-label="Clone time range"
- >
-
-
-
-
-
-
-
- {#if rangeIndex > 0}
- removeTimeRange(dayIndex, rangeIndex)}
- aria-label="Remove time range"
- >
-
-
-
-
- {/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}
+ {
+ $config.sound_preset = preset;
+ markChanged();
+ playSound(preset, $config.sound_volume);
+ }}
+ >
+ {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}
-
{
- $config.sound_preset = preset;
- markChanged();
- playSound(preset, $config.sound_volume);
- }}
- >
- {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}
+ addTimeRange(dayIndex)}
+ aria-label="Add time range"
+ >
+
+
+
+
+ {/if}
+
+
+ cloneTimeRange(dayIndex, rangeIndex)}
+ aria-label="Clone time range"
+ >
+
+
+
+
+
+
+
+ {#if rangeIndex > 0}
+ removeTimeRange(dayIndex, rangeIndex)}
+ aria-label="Remove time range"
+ >
+
+
+
+
+ {/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 @@
-
-
+
+
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
diff --git a/src/lib/components/StatsView.svelte b/src/lib/components/StatsView.svelte
index 1adcbb9..2bd381e 100644
--- a/src/lib/components/StatsView.svelte
+++ b/src/lib/components/StatsView.svelte
@@ -9,9 +9,13 @@
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
+ todayNaturalBreaks: number;
+ todayNaturalBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
+ dailyGoalProgress: number;
+ dailyGoalMet: boolean;
}
interface DayRecord {
@@ -22,13 +26,27 @@
totalBreakTimeSecs: number;
}
+ interface WeekSummary {
+ weekStart: string;
+ totalCompleted: number;
+ totalSkipped: number;
+ totalBreakTimeSecs: number;
+ complianceRate: number;
+ avgDailyCompleted: number;
+ }
+
let stats = $state(null);
let history = $state([]);
+ let monthHistory = $state([]);
+ let weeklySummaries = $state([]);
+ let activeTab = $state<"today" | "weekly" | "monthly">("today");
async function loadStats() {
try {
stats = await invoke("get_stats");
history = await invoke("get_daily_history", { days: 7 });
+ monthHistory = await invoke("get_daily_history", { days: 30 });
+ weeklySummaries = await invoke("get_weekly_summary", { weeks: 4 });
} catch (e) {
console.error("Failed to load stats:", e);
}
@@ -56,7 +74,21 @@
return `${hrs}h ${rem}m`;
});
- // Chart rendering
+ // F10: Daily goal progress
+ const goalPercent = $derived(
+ $config.daily_goal_breaks > 0
+ ? Math.min(100, Math.round(((stats?.dailyGoalProgress ?? 0) / $config.daily_goal_breaks) * 100))
+ : 0,
+ );
+
+ // F10: Next milestone
+ const milestones = [3, 5, 7, 14, 21, 30, 50, 100, 365];
+ const nextMilestone = $derived(() => {
+ const current = stats?.currentStreak ?? 0;
+ return milestones.find((m) => m > current) ?? null;
+ });
+
+ // Chart rendering - 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -64,6 +96,22 @@
drawChart(chartCanvas, history);
});
+ // Chart rendering - 30-day
+ let monthChartCanvas: HTMLCanvasElement | undefined = $state();
+
+ $effect(() => {
+ if (!monthChartCanvas || monthHistory.length === 0) return;
+ drawChart(monthChartCanvas, monthHistory);
+ });
+
+ // Heatmap canvas
+ let heatmapCanvas: HTMLCanvasElement | undefined = $state();
+
+ $effect(() => {
+ if (!heatmapCanvas || monthHistory.length === 0) return;
+ drawHeatmap(heatmapCanvas, monthHistory);
+ });
+
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
@@ -78,8 +126,8 @@
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
- const barWidth = Math.floor((w - 40) / data.length) - 8;
- const barGap = 8;
+ const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
+ const barGap = data.length > 10 ? 2 : 8;
const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
@@ -90,34 +138,87 @@
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
- // Completed bar
if (completedH > 0) {
ctx.fillStyle = accentColor;
ctx.beginPath();
const barY = chartHeight - completedH;
- roundedRect(ctx, x, barY, barWidth, completedH, 4);
+ roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
ctx.fill();
}
- // Skipped bar (stacked on top)
if (skippedH > 0) {
ctx.fillStyle = "#333";
ctx.beginPath();
const barY = chartHeight - completedH - skippedH;
- roundedRect(ctx, x, barY, barWidth, skippedH, 4);
+ roundedRect(ctx, x, barY, barWidth, skippedH, Math.min(4, barWidth / 2));
ctx.fill();
}
- // Day label
- ctx.fillStyle = "#8a8a8a";
- ctx.font = "10px -apple-system, sans-serif";
- ctx.textAlign = "center";
- const label = day.date.slice(5); // "MM-DD"
- ctx.fillText(label, x + barWidth / 2, h - 5);
+ // Day label - show every Nth for 30-day
+ if (data.length <= 7 || i % 5 === 0) {
+ ctx.fillStyle = "#8a8a8a";
+ ctx.font = "10px -apple-system, sans-serif";
+ ctx.textAlign = "center";
+ const label = day.date.slice(5);
+ ctx.fillText(label, x + barWidth / 2, h - 5);
+ }
+ });
+ }
+
+ function drawHeatmap(canvas: HTMLCanvasElement, data: DayRecord[]) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const cellSize = 16;
+ const gap = 2;
+ const cols = 7; // days of week
+ const rows = Math.ceil(data.length / cols);
+
+ const w = cols * (cellSize + gap) - gap;
+ const h = rows * (cellSize + gap) - gap;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, w, h);
+
+ const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted));
+ const accentColor = $config.accent_color || "#ff4d00";
+
+ // Parse accent color for intensity scaling
+ const r = parseInt(accentColor.slice(1, 3), 16);
+ const g = parseInt(accentColor.slice(3, 5), 16);
+ const b = parseInt(accentColor.slice(5, 7), 16);
+
+ data.forEach((day, i) => {
+ const col = i % cols;
+ const row = Math.floor(i / cols);
+ const x = col * (cellSize + gap);
+ const y = row * (cellSize + gap);
+
+ const intensity = day.breaksCompleted > 0
+ ? Math.min(1, day.breaksCompleted / maxBreaks)
+ : 0;
+
+ if (intensity === 0) {
+ ctx.fillStyle = "#161616";
+ } else {
+ // Blend from dark (#161616 = 22,22,22) to accent color
+ const level = 0.2 + intensity * 0.8;
+ const cr = Math.round(22 + (r - 22) * level);
+ const cg = Math.round(22 + (g - 22) * level);
+ const cb = Math.round(22 + (b - 22) * level);
+ ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
+ }
+
+ ctx.beginPath();
+ roundedRect(ctx, x, y, cellSize, cellSize, 3);
+ ctx.fill();
});
}
- // Accessible chart summary
const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
@@ -125,6 +226,15 @@
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
});
+ // Monthly aggregations
+ const monthTotalCompleted = $derived(monthHistory.reduce((s, d) => s + d.breaksCompleted, 0));
+ const monthTotalSkipped = $derived(monthHistory.reduce((s, d) => s + d.breaksSkipped, 0));
+ const monthTotalTime = $derived(monthHistory.reduce((s, d) => s + d.totalBreakTimeSecs, 0));
+ const monthAvgCompliance = $derived(() => {
+ const total = monthTotalCompleted + monthTotalSkipped;
+ return total > 0 ? Math.round((monthTotalCompleted / total) * 100) : 100;
+ });
+
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
@@ -183,14 +293,30 @@
+
+
+ {#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
+ activeTab = tab as any}
+ >
+ {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}
Break history for the last {history.length} days
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+ Activity Heatmap
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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")!,