1 Commits

Author SHA1 Message Date
Your Name
a339dd1bb3 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
Major feature release (v0.1.3) adding 15 new features to the break timer:

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

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

Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
2026-02-07 15:11:44 +02:00
28 changed files with 3792 additions and 448 deletions

78
CHANGELOG.md Normal file
View File

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

422
README.md
View File

@@ -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. 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
<p align="center"> <p align="center">
<img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br /> <img src="screenshots/01-dashboard.png" alt="Dashboard - Main Timer" width="420" /><br />
<sub><strong>Dashboard</strong> - Focus timer with countdown ring, status pill, and quick controls</sub> <sub><strong>Dashboard</strong> - Focus timer with countdown ring, status pill, pomodoro dots, and quick controls</sub>
</p> </p>
<br /> <br />
@@ -77,14 +77,14 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<p align="center"> <p align="center">
<img src="screenshots/03-settings.png" alt="Settings" width="420" /><br /> <img src="screenshots/03-settings.png" alt="Settings" width="420" /><br />
<sub><strong>Settings</strong> - Grouped configuration cards with live preview</sub> <sub><strong>Settings</strong> - 18 grouped configuration cards with live preview</sub>
</p> </p>
<br /> <br />
<p align="center"> <p align="center">
<img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br /> <img src="screenshots/04-break.png" alt="Break Screen" width="600" /><br />
<sub><strong>Break Screen</strong> - Always-on-top break overlay with activity suggestions</sub> <sub><strong>Break Screen</strong> - Always-on-top break overlay with breathing guide and activity suggestions</sub>
</p> </p>
<br /> <br />
@@ -100,23 +100,62 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
## Features ## Features
### ⏱️ Timer & Breaks ### Timer & Breaks
| | Feature | Description | | | Feature | Description |
|:--|:--------|:------------| |:--|:--------|:------------|
| 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min | | 🔄 | **Configurable intervals** | Work sessions from 5-120 min, breaks from 1-60 min |
| 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break | | 🔔 | **Pre-break warnings** | Toast notification + optional sound alert before each break |
| 🛡️ | **Break enforcement** | Always-on-top break window with optional fullscreen mode | | 🛡️ | **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 | | 🔒 | **Strict mode** | Removes skip and cancel buttons entirely |
| ⏩ | **Early end** | Optionally allow ending a break after 50% completion | | ⏩ | **Early end** | Optionally allow ending a break after 50% completion |
| 😴 | **Snooze** | Delay breaks by a configurable duration (with limits) | | 😴 | **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 | | ⚡ | **Immediate breaks** | Skip pre-break notification, go straight into break |
| 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu | | 🎯 | **Manual break** | Start a break anytime from the dashboard or tray menu |
<br /> <br />
### 🧠 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
<br />
### 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
<br />
### 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 |
<br />
### Idle Detection & Smart Breaks
| | Feature | Description | | | Feature | Description |
|:--|:--------|:------------| |:--|:--------|:------------|
@@ -127,9 +166,20 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
<br /> <br />
### 🧘 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
<br />
### Break Activities
Each break shows a randomized suggestion from a curated library of **71 activities** across four categories:
| Category | Examples | | 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. 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
<br /> <br />
### 📅 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 |
<br />
### 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
<br />
### 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. 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
<br /> <br />
### 📊 Statistics & History ### Statistics & History
| | Metric | Description | | | Metric | Description |
|:--|:-------|:------------| |:--|:-------|:------------|
@@ -166,7 +243,7 @@ All statistics stored locally in a plain JSON file next to the executable.
<br /> <br />
### 🔊 Sound Effects ### Sound Effects
Synthesized notification sounds via Web Audio API - no bundled audio files, no network requests. 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
<br /> <br />
### ⌨️ Global Keyboard Shortcuts ### Global Keyboard Shortcuts
| Shortcut | Action | | Shortcut | Action |
|:---------|:-------| |:---------|:-------|
@@ -188,87 +265,89 @@ Works system-wide, even when Core Cooldown is not focused.
<br /> <br />
### 🔲 System Tray ### System Tray
- 🎨 **Dynamic icon** - 32x32 progress arc rendered in real-time (orange = focus, purple = break, dimmed = paused) - **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 - **Countdown tooltip** - hover over tray icon to see time remaining
- 📋 **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit - **Context menu** - pause/resume, start break, toggle mini mode, show/hide, quit
<br /> <br />
### 📌 Mini Mode ### Mini Mode
A compact floating timer (200x50px) that sits on top of your other windows. 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 - **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 - **Hover to grab** - hover for a configurable number of seconds and it becomes draggable
- 🖱️ **Double-click** - opens the main window - **Double-click** - opens the main window
- 🔀 **Togglable** - enable/disable from the tray menu - **Togglable** - enable/disable from the tray menu
<br /> <br />
### 🎨 Appearance & Customization ### Appearance & Customization
| Setting | Range | | Setting | Range |
|:--------|:------| |:--------|:------|
| 🔍 **UI zoom** | 50-200% with live preview | | **UI zoom** | 50-200% with live preview |
| 🎯 **Accent color** | Hex color picker for the main UI accent | | **Accent color** | Full color picker (SL pad + hue bar) for the main UI accent |
| 💜 **Break color** | Separate hex for the break screen ring | | **Break color** | Separate color for the break screen ring and breathing guide |
| 🌈 **Color schemes** | Ocean, Forest, Sunset, Midnight, Dawn | | **Countdown font** | Google Fonts selector for timer display |
| 🔤 **Countdown font** | Google Fonts selector for timer display | | **Background blobs** | Animated gradient blobs with film grain overlay |
| 🫧 **Background blobs** | Animated gradient blobs with film grain overlay | | **Break title & message** | Fully customizable text shown during breaks |
| 🌑 **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) |
<br /> <br />
### 🔔 Notifications ### Notifications
Native Windows toast notifications for: Native Windows toast notifications for:
- Pre-break warnings (configurable seconds before break) - Pre-break warnings (configurable seconds before break)
- Break completion - Break completion
- Streak milestones
- Daily goal achievement
- Break deferral (presentation mode)
<br /> <br />
### 🪟 Window Behavior ### Window Behavior
- **Frameless window** with custom titlebar and drag region - **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 - **Window position persistence** - main and mini windows remember position between launches
- **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing) - **Animated view transitions** - directional fly/scale/fade transitions (700ms, cubicOut easing)
- **Momentum scroll** - iOS-style drag scrolling with elastic overscroll in settings
<br /> <br />
### 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. 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 | | | 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 | | 🔍 | **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. | | 🗣️ | **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. | | 🎯 | **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 (WCAG AA) | | 🎨 | **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 | | 🖥️ | **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. | | 🐢 | **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, and form controls have descriptive accessible names instead of generic labels | | 🏷️ | **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 |
<br /> <br />
--- ---
## 📦 Portability ## Portability
Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself: Core Cooldown is **fully portable**. The executable carries everything it needs and stores everything it creates right next to itself:
``` ```
📁 anywhere-you-want/ anywhere-you-want/
├── core-cooldown.exe the application ├── core-cooldown.exe <- the application
├── config.json your settings (auto-created on first run) ├── config.json <- your settings (auto-created on first run)
├── stats.json your break history (auto-created on first run) ├── stats.json <- your break history (auto-created on first run)
└── data/ WebView2 runtime data (auto-created on first run) └── data/ <- WebView2 runtime data (auto-created on first run)
``` ```
- No installer - 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 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. 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)**
<br /> <br />
--- ---
## 🔨 Building from Source ## Building from Source
<details> <details>
<summary><strong>Prerequisites</strong></summary> <summary><strong>Prerequisites</strong></summary>
@@ -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. A split-architecture desktop app: Rust backend for system integration and timer logic, Svelte frontend rendered in a native WebView.
@@ -393,10 +472,10 @@ A split-architecture desktop app: Rust backend for system integration and timer
| Module | Responsibility | | Module | Responsibility |
|:-------|:---------------| |:-------|:---------------|
| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts | | `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 with serde serialization, validation (clamping all values to safe ranges), portable file I/O | | `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 | | `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, history queries | | `stats.rs` | Daily break statistics, streak calculation, daily goal tracking, history queries, weekly summaries |
| `main.rs` | Entry point, WebView2 Runtime detection | | `main.rs` | Entry point, WebView2 Runtime detection |
| `msvc_compat.rs` | MSVC CRT compatibility stubs for static WebView2Loader linking on MinGW | | `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` | | **Views** | `Dashboard`, `BreakScreen`, `Settings`, `StatsView` |
| **Windows** | `BreakWindow` (standalone break modal), `MiniTimer` (floating mini mode) | | **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) | | **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) |
</details> </details>
<details> <details>
<summary><strong>IPC contract</strong></summary> <summary><strong>IPC contract</strong></summary>
**Commands** (frontend backend): **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` `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): **Events** (backend -> frontend):
`timer-tick` · `break-started` · `break-ended` · `prebreak-warning` · `config-changed` · `natural-break-detected` `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`
</details> </details>
@@ -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. 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.
<details> <details>
<summary><strong>Full configuration schema (35 keys)</strong></summary> <summary><strong>Full configuration schema (71 keys)</strong></summary>
<br /> <br />
| Key | Type | Default | Range | Description | **Timer**
|:----|:-----|:--------|:------|:------------|
| `break_duration` | `u32` | `5` | 1-60 min | Duration of each break | | Key | Type | Default | Description |
| `break_frequency` | `u32` | `25` | 5-120 min | Interval between breaks | |:----|:-----|:--------|:------------|
| `auto_start` | `bool` | `true` | - | Start timer on launch | | `break_duration` | `u32` | `5` | Duration of each break (1-60 min) |
| `break_title` | `string` | `"Rest your eyes"` | max 100 chars | Title shown on break screen | | `break_frequency` | `u32` | `25` | Interval between breaks (5-120 min) |
| `break_message` | `string` | `"Look away..."` | max 500 chars | Message shown during breaks | | `auto_start` | `bool` | `true` | Start timer on launch |
| `fullscreen_mode` | `bool` | `true` | - | Use fullscreen break window |
| `strict_mode` | `bool` | `false` | - | Remove skip/cancel buttons | **Pomodoro**
| `allow_end_early` | `bool` | `true` | - | Allow ending break after 50% |
| `immediately_start_breaks` | `bool` | `false` | - | Skip pre-break notification | | Key | Type | Default | Description |
| `working_hours_enabled` | `bool` | `false` | - | Restrict timer to schedule | |:----|:-----|:--------|:------------|
| `working_hours_schedule` | `array` | Mon-Fri 09-18 | 7 days | Per-day time ranges | | `pomodoro_enabled` | `bool` | `false` | Enable Pomodoro mode |
| `dark_mode` | `bool` | `true` | - | Dark theme | | `pomodoro_short_breaks` | `u32` | `3` | Short breaks before long (1-10) |
| `color_scheme` | `string` | `"Ocean"` | 5 presets | Color scheme name | | `pomodoro_long_break_duration` | `u32` | `15` | Long break duration (5-60 min) |
| `backdrop_opacity` | `f32` | `0.92` | 0.5-1.0 | Break screen opacity | | `pomodoro_long_break_title` | `string` | `"Long break"` | Long break title |
| `notification_enabled` | `bool` | `true` | - | Enable toast notifications | | `pomodoro_long_break_message` | `string` | `"Great work!..."` | Long break message |
| `notification_before_break` | `u32` | `30` | 0-300 sec | Pre-break warning time | | `pomodoro_reset_on_skip` | `bool` | `false` | Reset cycle when skipping |
| `snooze_duration` | `u32` | `5` | 1-30 min | Snooze delay |
| `snooze_limit` | `u32` | `3` | 0-5 | Max snoozes per cycle | **Microbreaks**
| `skip_cooldown` | `u32` | `60` | 0-600 sec | Cooldown between skips |
| `sound_enabled` | `bool` | `true` | - | Play notification sounds | | Key | Type | Default | Description |
| `sound_volume` | `u32` | `70` | 0-100 | Volume percentage | |:----|:-----|:--------|:------------|
| `sound_preset` | `string` | `"bell"` | 8 presets | Sound preset name | | `microbreak_enabled` | `bool` | `false` | Enable 20-20-20 eye breaks |
| `idle_detection_enabled` | `bool` | `true` | - | Enable idle auto-pause | | `microbreak_frequency` | `u32` | `20` | Microbreak interval (5-60 min) |
| `idle_timeout` | `u32` | `120` | 30-600 sec | Idle threshold | | `microbreak_duration` | `u32` | `20` | Microbreak duration (10-60 sec) |
| `smart_breaks_enabled` | `bool` | `true` | - | Detect natural breaks | | `microbreak_sound_enabled` | `bool` | `true` | Play sound on microbreak |
| `smart_break_threshold` | `u32` | `300` | 120-900 sec | Natural break threshold | | `microbreak_show_activity` | `bool` | `true` | Show activity during microbreak |
| `smart_break_count_stats` | `bool` | `false` | - | Count natural breaks in stats | | `microbreak_pause_during_break` | `bool` | `true` | No microbreaks during main breaks |
| `show_break_activities` | `bool` | `true` | - | Show activity suggestions |
| `ui_zoom` | `u32` | `100` | 50-200% | Interface zoom level | **Break Screen**
| `accent_color` | `string` | `"#ff4d00"` | hex | Main accent color |
| `break_color` | `string` | `"#7c6aef"` | hex | Break screen ring color | | Key | Type | Default | Description |
| `countdown_font` | `string` | `""` | font family | Google Font for countdown | |:----|:-----|:--------|:------------|
| `background_blobs_enabled` | `bool` | `false` | - | Animated background blobs | | `break_title` | `string` | `"Rest your eyes"` | Title shown on break screen |
| `mini_click_through` | `bool` | `true` | - | Mini mode click-through | | `break_message` | `string` | `"Look away..."` | Message shown during breaks |
| `mini_hover_threshold` | `f32` | `3.0` | 1.0-10.0 sec | Hover delay before drag | | `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 |
</details> </details>
@@ -483,7 +677,7 @@ All settings stored in `config.json` next to the executable. The settings panel
--- ---
## 📚 Dependencies ## Dependencies
<details> <details>
<summary><strong>Rust crates</strong></summary> <summary><strong>Rust crates</strong></summary>
@@ -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. 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:** **Some ways to help:**
- 🐛 Report bugs or rough edges - Report bugs or rough edges
- 🧘 Suggest new break activities (especially with physiotherapy or ergonomics knowledge) - Suggest new break activities (especially with physiotherapy or ergonomics knowledge)
- Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further) - Improve accessibility (WCAG 2.1 AA foundation is in place - help us push further)
- 🐧 Port idle detection to macOS/Linux - Port idle detection to macOS/Linux
- 🌍 Translate the interface - Translate the interface
- 💌 Share it with someone who needs it - 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. 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
<p align="center"> <p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/"> <a href="https://creativecommons.org/publicdomain/zero/1.0/">
@@ -564,7 +758,7 @@ See [`LICENSE`](LICENSE) for the full legal text.
<p align="center"> <p align="center">
<sub> <sub>
Built with care. Shared without conditions. 🧊<br /> Built with care. Shared without conditions.<br />
<em>Rest well.</em> <em>Rest well.</em>
</sub> </sub>
</p> </p>

View File

@@ -1,7 +1,7 @@
{ {
"name": "core-cooldown", "name": "core-cooldown",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json", "$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "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": [ "permissions": [
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-minimize", "core:window:allow-minimize",

View File

@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; 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) /// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange { pub struct TimeRange {
@@ -107,6 +117,54 @@ pub struct Config {
pub mini_click_through: bool, // Mini mode is click-through until hovered 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) pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0)
// F8: Auto-start on Windows login
pub auto_start_on_login: bool,
// F6: Custom break activities
pub custom_activities: Vec<CustomActivity>,
pub disabled_builtin_activities: Vec<String>,
pub favorite_builtin_activities: Vec<String>,
pub favorite_weight: u32, // Multiplier for favorites in random pool (2-10)
// F4: Guided breathing animation
pub breathing_guide_enabled: bool,
pub breathing_pattern: String, // "box", "relaxing", "energizing", "calm", "deep"
// F10: Break streaks & gamification
pub daily_goal_enabled: bool,
pub daily_goal_breaks: u32, // 1-30
pub milestone_celebrations: bool,
pub streak_notifications: bool,
// F1: Microbreaks & 20-20-20
pub microbreak_enabled: bool,
pub microbreak_frequency: u32, // 5-60 min
pub microbreak_duration: u32, // 10-60 sec
pub microbreak_sound_enabled: bool,
pub microbreak_show_activity: bool,
pub microbreak_pause_during_break: bool,
// F3: Pomodoro Mode
pub pomodoro_enabled: bool,
pub pomodoro_short_breaks: u32, // 1-10 (short breaks before long)
pub pomodoro_long_break_duration: u32, // 5-60 min
pub pomodoro_long_break_title: String, // max 100 chars
pub pomodoro_long_break_message: String, // max 500 chars
pub pomodoro_reset_on_skip: bool,
// F5: Screen dimming pre-break nudge
pub screen_dim_enabled: bool,
pub screen_dim_seconds: u32, // 3-60 sec before break
pub screen_dim_max_opacity: f32, // 0.1-0.7
// F2: Presentation mode / fullscreen detection
pub presentation_mode_enabled: bool,
pub presentation_mode_defer_microbreaks: bool,
pub presentation_mode_notification: bool,
// F9: Multi-monitor break enforcement
pub multi_monitor_break: bool,
// Window positions (persisted between launches) // Window positions (persisted between launches)
pub main_window_x: Option<i32>, pub main_window_x: Option<i32>,
pub main_window_y: Option<i32>, pub main_window_y: Option<i32>,
@@ -186,6 +244,54 @@ impl Default for Config {
mini_click_through: true, mini_click_through: true,
mini_hover_threshold: 3.0, 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 // Window positions
main_window_x: None, main_window_x: None,
main_window_y: None, main_window_y: None,
@@ -349,6 +455,44 @@ impl Config {
// UI zoom: 50-200% // UI zoom: 50-200%
self.ui_zoom = self.ui_zoom.clamp(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 // Validate color hex strings
if !Self::is_valid_hex_color(&self.accent_color) { if !Self::is_valid_hex_color(&self.accent_color) {
self.accent_color = "#ff4d00".to_string(); self.accent_color = "#ff4d00".to_string();

View File

@@ -15,7 +15,7 @@ use tauri::{
AppHandle, Emitter, Manager, State, AppHandle, Emitter, Manager, State,
}; };
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use timer::{AppView, TickResult, TimerManager, TimerSnapshot}; use timer::{AppView, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
pub struct AppState { pub struct AppState {
pub timer: Arc<Mutex<TimerManager>>, pub timer: Arc<Mutex<TimerManager>>,
@@ -127,7 +127,13 @@ fn set_view(state: State<AppState>, view: AppView) {
#[tauri::command] #[tauri::command]
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot { fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
let s = state.stats.lock().unwrap(); 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] #[tauri::command]
@@ -136,6 +142,126 @@ fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord>
s.recent_days(days) s.recent_days(days)
} }
// F7: Weekly summary command
#[tauri::command]
fn get_weekly_summary(state: State<AppState>, weeks: u32) -> Vec<stats::WeekSummary> {
let s = state.stats.lock().unwrap();
s.weekly_summary(weeks)
}
// F8: Auto-start on Windows login
#[tauri::command]
fn set_auto_start(enabled: bool) -> Result<(), String> {
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winreg::{RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, HKEY_CURRENT_USER};
use winapi::um::winnt::{KEY_SET_VALUE, REG_SZ};
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = std::ptr::null_mut();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
0,
KEY_SET_VALUE,
&mut hkey,
);
if result != 0 {
return Err("Failed to open registry key".to_string());
}
if enabled {
let exe_path = std::env::current_exe()
.map_err(|e| e.to_string())?;
let path_str = exe_path.to_string_lossy();
let value_data: Vec<u16> = OsStr::new(&*path_str)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let res = RegSetValueExW(
hkey,
value_name.as_ptr(),
0,
REG_SZ,
value_data.as_ptr() as *const u8,
(value_data.len() * 2) as u32,
);
winapi::um::winreg::RegCloseKey(hkey);
if res != 0 {
return Err("Failed to set registry value".to_string());
}
} else {
let _res = RegDeleteValueW(hkey, value_name.as_ptr());
winapi::um::winreg::RegCloseKey(hkey);
}
}
Ok(())
}
#[cfg(not(windows))]
{
Err("Auto-start is only supported on Windows".to_string())
}
}
#[tauri::command]
fn get_auto_start_status() -> bool {
#[cfg(windows)]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
use winapi::um::winnt::KEY_READ;
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut hkey = std::ptr::null_mut();
let result = RegOpenKeyExW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
0,
KEY_READ,
&mut hkey,
);
if result != 0 {
return false;
}
let res = RegQueryValueExW(
hkey,
value_name.as_ptr(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
);
winapi::um::winreg::RegCloseKey(hkey);
res == 0
}
}
#[cfg(not(windows))]
{
false
}
}
// ── Cursor / Window Position Commands ──────────────────────────────────── // ── Cursor / Window Position Commands ────────────────────────────────────
#[tauri::command] #[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. /// 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( fn render_tray_icon(
progress: f64, progress: f64,
is_break: bool, is_break: bool,
is_paused: bool, is_paused: bool,
accent: (u8, u8, u8), accent: (u8, u8, u8),
break_color: (u8, u8, u8), break_color: (u8, u8, u8),
goal_met: bool,
) -> Vec<u8> { ) -> Vec<u8> {
let size: usize = 32; let size: usize = 32;
let mut rgba = vec![0u8; size * size * 4]; 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 rgba
} }
@@ -249,14 +399,19 @@ fn update_tray(
snapshot: &TimerSnapshot, snapshot: &TimerSnapshot,
accent: (u8, u8, u8), accent: (u8, u8, u8),
break_color: (u8, u8, u8), break_color: (u8, u8, u8),
goal_met: bool,
) { ) {
// Update tooltip // Update tooltip
let tooltip = match snapshot.state { let tooltip = match snapshot.state {
timer::TimerState::Running => { timer::TimerState::Running => {
if snapshot.deferred_break_pending {
"Core Cooldown — Break deferred (fullscreen)".to_string()
} else {
let m = snapshot.time_remaining / 60; let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60; let s = snapshot.time_remaining % 60;
format!("Core Cooldown — {:02}:{:02} until break", m, s) format!("Core Cooldown — {:02}:{:02} until break", m, s)
} }
}
timer::TimerState::Paused => { timer::TimerState::Paused => {
if snapshot.idle_paused { if snapshot.idle_paused {
"Core Cooldown — Paused (idle)".to_string() "Core Cooldown — Paused (idle)".to_string()
@@ -279,7 +434,7 @@ fn update_tray(
timer::TimerState::BreakActive => (snapshot.break_progress, true, false), 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 icon = Image::new_owned(icon_data, 32, 32);
let _ = tray.set_icon(Some(icon)); let _ = tray.set_icon(Some(icon));
} }
@@ -369,41 +524,133 @@ pub fn run() {
let handle = app.handle().clone(); let handle = app.handle().clone();
let timer_ref = app.state::<AppState>().timer.clone(); let timer_ref = app.state::<AppState>().timer.clone();
let stats_ref = app.state::<AppState>().stats.clone(); let stats_ref = app.state::<AppState>().stats.clone();
let data_dir_clone = data_dir.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut dim_window_open = false;
let mut microbreak_window_open = false;
let mut break_deferred_notified = false;
loop { loop {
std::thread::sleep(Duration::from_secs(1)); 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 mut timer = timer_ref.lock().unwrap();
let result = timer.tick(); let result = timer.tick();
let mb = timer.tick_microbreak();
let snap = timer.snapshot(); let snap = timer.snapshot();
let ac = timer.config.accent_color.clone(); let ac = timer.config.accent_color.clone();
let bc = timer.config.break_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 // Update tray icon and tooltip with configured colors
let accent = parse_hex_color(&accent_hex, (255, 77, 0)); let accent = parse_hex_color(&accent_hex, (255, 77, 0));
let break_c = parse_hex_color(&break_hex, (124, 106, 239)); 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 // Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &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 // Emit specific events for state transitions
match tick_result { match tick_result {
TickResult::BreakStarted(payload) => { 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); 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); let _ = handle.emit("break-started", &payload);
break_deferred_notified = false;
} }
TickResult::BreakEnded => { TickResult::BreakEnded => {
// Restore normal window state and close break window // Restore normal window state and close break window
handle_break_end(&handle); handle_break_end(&handle);
// F9: Close multi-monitor overlays
close_multi_monitor_overlays(&handle);
// Record completed break in stats // Record completed break in stats
{ let break_result = {
let timer = timer_ref.lock().unwrap(); let timer = timer_ref.lock().unwrap();
let goal = if daily_goal_enabled { daily_goal } else { 0 };
let mut s = stats_ref.lock().unwrap(); 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 let _ = handle
.notification() .notification()
@@ -464,6 +711,22 @@ pub fn run() {
.show(); .show();
let _ = handle.emit("natural-break-detected", &duration_seconds); 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 => {} TickResult::None => {}
} }
} }
@@ -493,6 +756,9 @@ pub fn run() {
set_view, set_view,
get_stats, get_stats,
get_daily_history, get_daily_history,
get_weekly_summary,
set_auto_start,
get_auto_start_status,
get_cursor_position, get_cursor_position,
save_window_position, save_window_position,
]) ])
@@ -688,3 +954,114 @@ fn toggle_mini_window(app: &AppHandle) {
let _ = builder.build(); let _ = builder.build();
} }
} }
// ── F1: Microbreak Window ──────────────────────────────────────────────────
fn open_microbreak_window(app: &AppHandle, data_dir: &std::path::Path) {
if app.get_webview_window("microbreak").is_some() {
return; // already open
}
let _ = tauri::WebviewWindowBuilder::new(
app,
"microbreak",
tauri::WebviewUrl::App("index.html?microbreak=1".into()),
)
.title("Eye Break")
.inner_size(400.0, 180.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.center()
.data_directory(data_dir.to_path_buf())
.build();
}
fn close_microbreak_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("microbreak") {
let _ = win.close();
}
}
// ── F5: Dim Overlay Window ──────────────────────────────────────────────────
fn open_dim_overlay(app: &AppHandle, data_dir: &std::path::Path) {
if app.get_webview_window("dim").is_some() {
return;
}
let builder = tauri::WebviewWindowBuilder::new(
app,
"dim",
tauri::WebviewUrl::App("index.html?dim=1".into()),
)
.title("")
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.maximized(true)
.data_directory(data_dir.to_path_buf());
if let Ok(win) = builder.build() {
let _ = win.set_ignore_cursor_events(true);
}
}
fn close_dim_overlay(app: &AppHandle) {
if let Some(win) = app.get_webview_window("dim") {
let _ = win.close();
}
}
// ── F9: Multi-Monitor Break Overlays ────────────────────────────────────────
fn open_multi_monitor_overlays(app: &AppHandle, data_dir: &std::path::Path) {
let monitors = timer::get_all_monitors();
for (i, mon) in monitors.iter().enumerate() {
if mon.is_primary {
continue; // Primary is handled by the main break window
}
if i > 5 {
break; // Cap at 6 monitors
}
let label = format!("break-overlay-{}", i);
if app.get_webview_window(&label).is_some() {
continue;
}
let _ = tauri::WebviewWindowBuilder::new(
app,
&label,
tauri::WebviewUrl::App("index.html?breakoverlay=1".into()),
)
.title("")
.position(mon.x as f64, mon.y as f64)
.inner_size(mon.width as f64, mon.height as f64)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.data_directory(data_dir.to_path_buf())
.build();
}
}
fn close_multi_monitor_overlays(app: &AppHandle) {
// Close any window with label starting with "break-overlay-"
for i in 0..6 {
let label = format!("break-overlay-{}", i);
if let Some(win) = app.get_webview_window(&label) {
let _ = win.close();
}
}
}

View File

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

View File

@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
pub natural_break_occurred: bool, pub natural_break_occurred: bool,
pub smart_breaks_enabled: bool, pub smart_breaks_enabled: bool,
pub smart_break_threshold: u32, 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 /// Events emitted by the timer to the frontend
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
pub strict_mode: bool, pub strict_mode: bool,
pub snooze_duration: u32, pub snooze_duration: u32,
pub fullscreen_mode: bool, pub fullscreen_mode: bool,
pub is_long_break: bool,
} }
pub struct TimerManager { pub struct TimerManager {
@@ -84,6 +106,17 @@ pub struct TimerManager {
// Smart breaks: track when idle started for natural break detection // Smart breaks: track when idle started for natural break detection
pub idle_start_time: Option<Instant>, pub idle_start_time: Option<Instant>,
pub natural_break_occurred: bool, 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 { impl TimerManager {
@@ -92,6 +125,7 @@ impl TimerManager {
let freq = config.break_frequency_seconds(); let freq = config.break_frequency_seconds();
let pending = config.clone(); let pending = config.clone();
let auto_start = config.auto_start; let auto_start = config.auto_start;
let microbreak_freq = config.microbreak_frequency as u64 * 60;
Self { Self {
state: if auto_start { state: if auto_start {
@@ -113,6 +147,17 @@ impl TimerManager {
idle_paused: false, idle_paused: false,
idle_start_time: None, idle_start_time: None,
natural_break_occurred: false, 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. /// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult { pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection // Idle detection and natural break detection
@@ -223,15 +280,18 @@ impl TimerManager {
TickResult::None TickResult::None
} }
} else { } 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(); self.start_break();
TickResult::BreakStarted(BreakStartedPayload { TickResult::BreakStarted(self.make_break_payload())
title: self.config.break_title.clone(),
message: self.config.break_message.clone(),
duration: self.break_total_duration,
strict_mode: self.config.strict_mode,
snooze_duration: self.config.snooze_duration,
fullscreen_mode: self.config.fullscreen_mode,
})
} }
} }
TimerState::BreakActive => { TimerState::BreakActive => {
@@ -242,28 +302,139 @@ impl TimerManager {
// Break completed naturally // Break completed naturally
self.has_had_break = true; self.has_had_break = true;
self.seconds_since_last_break = 0; self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer(); self.reset_timer();
TickResult::BreakEnded 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) { 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.state = TimerState::BreakActive;
self.current_view = AppView::BreakScreen; self.current_view = AppView::BreakScreen;
if self.pomodoro_is_long_break {
self.break_total_duration = self.config.pomodoro_long_break_duration as u64 * 60;
} else {
self.break_total_duration = self.config.break_duration_seconds(); self.break_total_duration = self.config.break_duration_seconds();
}
self.time_until_break_end = self.break_total_duration; self.time_until_break_end = self.break_total_duration;
self.prebreak_notification_active = false; self.prebreak_notification_active = false;
self.snoozes_used = 0; 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) { pub fn reset_timer(&mut self) {
self.state = TimerState::Running; self.state = TimerState::Running;
self.current_view = AppView::Dashboard; self.current_view = AppView::Dashboard;
self.time_until_next_break = self.config.break_frequency_seconds(); self.time_until_next_break = self.config.break_frequency_seconds();
self.prebreak_notification_active = false; self.prebreak_notification_active = false;
self.pomodoro_is_long_break = false;
} }
pub fn toggle_timer(&mut self) { pub fn toggle_timer(&mut self) {
@@ -281,14 +452,7 @@ impl TimerManager {
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> { pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
if self.state == TimerState::Running || self.state == TimerState::Paused { if self.state == TimerState::Running || self.state == TimerState::Paused {
self.start_break(); self.start_break();
Some(BreakStartedPayload { Some(self.make_break_payload())
title: self.config.break_title.clone(),
message: self.config.break_message.clone(),
duration: self.break_total_duration,
strict_mode: self.config.strict_mode,
snooze_duration: self.config.snooze_duration,
fullscreen_mode: self.config.fullscreen_mode,
})
} else { } else {
None None
} }
@@ -311,10 +475,15 @@ impl TimerManager {
// "End break" — counts as completed // "End break" — counts as completed
self.has_had_break = true; self.has_had_break = true;
self.seconds_since_last_break = 0; self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer(); self.reset_timer();
true true
} else if !past_half { } else if !past_half {
// "Cancel break" — doesn't count // "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(); self.reset_timer();
true true
} else { } else {
@@ -420,6 +589,25 @@ impl TimerManager {
&self.config &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 { TimerSnapshot {
state: self.state, state: self.state,
current_view: self.current_view, current_view: self.current_view,
@@ -431,8 +619,16 @@ impl TimerManager {
prebreak_warning: self.prebreak_notification_active, prebreak_warning: self.prebreak_notification_active,
snoozes_used: self.snoozes_used, snoozes_used: self.snoozes_used,
can_snooze: self.can_snooze(), can_snooze: self.can_snooze(),
break_title: display_config.break_title.clone(), break_title: if self.pomodoro_is_long_break {
break_message: display_config.break_message.clone(), 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_progress,
break_time_remaining: self.time_until_break_end, break_time_remaining: self.time_until_break_end,
break_total_duration: self.break_total_duration, break_total_duration: self.break_total_duration,
@@ -442,6 +638,27 @@ impl TimerManager {
natural_break_occurred: self.natural_break_occurred, natural_break_occurred: self.natural_break_occurred,
smart_breaks_enabled: display_config.smart_breaks_enabled, smart_breaks_enabled: display_config.smart_breaks_enabled,
smart_break_threshold: display_config.smart_break_threshold, 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, BreakEnded,
PreBreakWarning { seconds_until_break: u64 }, PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 }, NaturalBreakDetected { duration_seconds: u64 },
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
} }
/// Result of checking idle state /// Result of checking idle state
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
pub fn get_idle_seconds() -> u64 { pub fn get_idle_seconds() -> u64 {
0 0
} }
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
use winapi::shared::windef::{HWND, RECT};
use winapi::um::winuser::{
GetForegroundWindow, GetWindowRect, MonitorFromWindow, GetMonitorInfoW,
MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
unsafe {
let hwnd: HWND = GetForegroundWindow();
if hwnd.is_null() {
return false;
}
// Get the monitor this window is on
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if monitor.is_null() {
return false;
}
let mut mi: MONITORINFO = mem::zeroed();
mi.cbSize = mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) == 0 {
return false;
}
let mut wr: RECT = mem::zeroed();
if GetWindowRect(hwnd, &mut wr) == 0 {
return false;
}
// Check if window rect covers the monitor rect
let mr = mi.rcMonitor;
wr.left <= mr.left && wr.top <= mr.top && wr.right >= mr.right && wr.bottom >= mr.bottom
}
}
#[cfg(not(windows))]
pub fn is_foreground_fullscreen() -> bool {
false
}
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
use winapi::um::winuser::{EnumDisplayMonitors, GetMonitorInfoW, MONITORINFO};
unsafe extern "system" fn callback(
monitor: HMONITOR,
_hdc: HDC,
_rect: LPRECT,
data: isize,
) -> i32 {
let monitors = &mut *(data as *mut Vec<MonitorInfo>);
let mut mi: MONITORINFO = std::mem::zeroed();
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) != 0 {
let r = mi.rcMonitor;
monitors.push(MonitorInfo {
x: r.left,
y: r.top,
width: (r.right - r.left) as u32,
height: (r.bottom - r.top) as u32,
is_primary: (mi.dwFlags & 1) != 0, // MONITORINFOF_PRIMARY = 1
});
}
1 // continue enumeration
}
let mut monitors: Vec<MonitorInfo> = Vec::new();
unsafe {
EnumDisplayMonitors(
std::ptr::null_mut(),
std::ptr::null(),
Some(callback),
&mut monitors as *mut Vec<MonitorInfo> as isize,
);
}
monitors
}
#[cfg(not(windows))]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
Vec::new()
}
#[derive(Debug, Clone)]
pub struct MonitorInfo {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub is_primary: bool,
}

View File

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

View File

@@ -12,6 +12,7 @@
import Settings from "./lib/components/Settings.svelte"; import Settings from "./lib/components/Settings.svelte";
import StatsView from "./lib/components/StatsView.svelte"; import StatsView from "./lib/components/StatsView.svelte";
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte"; import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
import Celebration from "./lib/components/Celebration.svelte";
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
@@ -133,4 +134,5 @@
</div> </div>
{/if} {/if}
</div> </div>
<Celebration />
</main> </main>

View File

@@ -0,0 +1,581 @@
<script lang="ts">
import { config, autoSave } from "../stores/config";
import { breakActivities, getCategoryLabel } from "../utils/activities";
import ToggleSwitch from "./ToggleSwitch.svelte";
import { pressable } from "../utils/animate";
const categories = ["eyes", "stretch", "breathing", "movement"] as const;
let expandedCategory = $state<string | null>(null);
let newActivityText = $state("");
let newActivityCategory = $state<string>("eyes");
let dropdownOpen = $state(false);
let dropdownRef = $state<HTMLElement>(undefined!);
let dropdownTriggerRef = $state<HTMLElement>(undefined!);
let focusedOptionIndex = $state(-1);
// Close dropdown on outside click
function handleOutsideClick(e: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
dropdownOpen = false;
}
}
$effect(() => {
if (dropdownOpen) {
document.addEventListener("mousedown", handleOutsideClick);
// Set initial focused option to current selection
focusedOptionIndex = categories.indexOf(newActivityCategory as typeof categories[number]);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}
});
// Focus the highlighted option when index changes
$effect(() => {
if (dropdownOpen && focusedOptionIndex >= 0) {
const options = dropdownRef?.querySelectorAll<HTMLElement>('[role="option"]');
options?.[focusedOptionIndex]?.focus();
}
});
function handleDropdownKeydown(e: KeyboardEvent) {
if (!dropdownOpen) {
// Open on ArrowDown/Up/Enter/Space when closed
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
dropdownOpen = true;
}
return;
}
switch (e.key) {
case "Escape":
e.preventDefault();
dropdownOpen = false;
dropdownTriggerRef?.focus();
break;
case "ArrowDown":
e.preventDefault();
focusedOptionIndex = Math.min(focusedOptionIndex + 1, categories.length - 1);
break;
case "ArrowUp":
e.preventDefault();
focusedOptionIndex = Math.max(focusedOptionIndex - 1, 0);
break;
case "Home":
e.preventDefault();
focusedOptionIndex = 0;
break;
case "End":
e.preventDefault();
focusedOptionIndex = categories.length - 1;
break;
case "Enter":
case " ":
e.preventDefault();
if (focusedOptionIndex >= 0) {
newActivityCategory = categories[focusedOptionIndex];
dropdownOpen = false;
dropdownTriggerRef?.focus();
}
break;
case "Tab":
dropdownOpen = false;
break;
}
}
function markChanged() {
autoSave();
}
function toggleCategory(cat: string) {
expandedCategory = expandedCategory === cat ? null : cat;
}
function isBuiltinDisabled(text: string): boolean {
return $config.disabled_builtin_activities.includes(text);
}
function isBuiltinFavorite(text: string): boolean {
return $config.favorite_builtin_activities.includes(text);
}
function toggleBuiltinEnabled(text: string) {
if (isBuiltinDisabled(text)) {
$config.disabled_builtin_activities = $config.disabled_builtin_activities.filter((t) => t !== text);
} else {
$config.disabled_builtin_activities = [...$config.disabled_builtin_activities, text];
}
markChanged();
}
function toggleBuiltinFavorite(text: string) {
if (isBuiltinFavorite(text)) {
$config.favorite_builtin_activities = $config.favorite_builtin_activities.filter((t) => t !== text);
} else {
$config.favorite_builtin_activities = [...$config.favorite_builtin_activities, text];
}
markChanged();
}
function addCustomActivity() {
const trimmed = newActivityText.trim();
if (!trimmed || trimmed.length > 500) return;
if ($config.custom_activities.length >= 100) return;
const id = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
$config.custom_activities = [
...$config.custom_activities,
{ id, category: newActivityCategory, text: trimmed, is_favorite: false, enabled: true },
];
newActivityText = "";
markChanged();
}
function removeCustomActivity(id: string) {
$config.custom_activities = $config.custom_activities.filter((a) => a.id !== id);
markChanged();
}
function toggleCustomEnabled(id: string) {
$config.custom_activities = $config.custom_activities.map((a) =>
a.id === id ? { ...a, enabled: !a.enabled } : a,
);
markChanged();
}
function toggleCustomFavorite(id: string) {
$config.custom_activities = $config.custom_activities.map((a) =>
a.id === id ? { ...a, is_favorite: !a.is_favorite } : a,
);
markChanged();
}
// Get counts per category
function builtinCount(cat: string): number {
return breakActivities.filter((a) => a.category === cat).length;
}
function customCount(cat: string): number {
return $config.custom_activities.filter((a) => a.category === cat).length;
}
function disabledCount(cat: string): number {
return breakActivities
.filter((a) => a.category === cat)
.filter((a) => isBuiltinDisabled(a.text)).length
+ $config.custom_activities
.filter((a) => a.category === cat)
.filter((a) => !a.enabled).length;
}
// ── Svelte action: blocks mousedown from reaching parent dragScroll ──
// Uses native addEventListener (not Svelte delegation) so stopPropagation
// fires BEFORE the parent's node-level mousedown handler in bubble phase.
function blockParentDrag(node: HTMLElement) {
function stop(e: MouseEvent) { e.stopPropagation(); }
node.addEventListener("mousedown", stop);
return { destroy() { node.removeEventListener("mousedown", stop); } };
}
// ── Svelte action: inner drag-scroll with momentum, overscroll, custom scrollbar ──
const prefersReducedMotion = typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
function innerDragScroll(node: HTMLElement) {
const scrollEl = node.querySelector(".activity-scroll") as HTMLElement;
const content = scrollEl?.querySelector(".scroll-content") as HTMLElement;
if (!scrollEl || !content) return { destroy() {} };
// Reduced motion: skip custom scroll physics, allow normal scroll
if (prefersReducedMotion) {
// Still block wheel propagation for isolation
function onWheel(e: WheelEvent) {
e.stopPropagation();
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
const atTop = scrollTop <= 0 && e.deltaY < 0;
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
if (!atTop && !atBottom) {
e.preventDefault();
scrollEl.scrollTop += e.deltaY;
}
}
node.addEventListener("wheel", onWheel, { passive: false });
return { destroy() { node.removeEventListener("wheel", onWheel); } };
}
// Create custom scrollbar thumb
const thumb = document.createElement("div");
thumb.style.cssText =
"position:absolute;right:2px;top:0;width:3px;border-radius:1.5px;" +
"background:rgba(255,255,255,0.15);opacity:0;transition:opacity 0.3s ease;" +
"z-index:10;pointer-events:none;";
node.appendChild(thumb);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let coastFrame = 0;
let overscroll = 0;
function getMaxScroll() {
return scrollEl.scrollHeight - scrollEl.clientHeight;
}
function showThumb() {
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
if (scrollHeight <= clientHeight) { thumb.style.opacity = "0"; return; }
const h = Math.max(20, (clientHeight / scrollHeight) * clientHeight);
const t = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
thumb.style.height = `${h}px`;
thumb.style.transform = `translateY(${t}px)`;
thumb.style.opacity = "1";
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => { thumb.style.opacity = "0"; }, 1000);
}
function setOverscroll(amount: number) {
overscroll = amount;
if (Math.abs(amount) < 0.5) {
content.style.transform = "";
overscroll = 0;
} else {
content.style.transform = `translateY(${-amount}px)`;
}
}
function springBack() {
const from = overscroll;
if (Math.abs(from) < 0.5) { setOverscroll(0); return; }
const start = performance.now();
function frame() {
const t = Math.min(1, (performance.now() - start) / 500);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
setOverscroll(from * (1 - ease));
showThumb();
if (t < 1) coastFrame = requestAnimationFrame(frame);
else { content.style.transform = ""; overscroll = 0; }
}
coastFrame = requestAnimationFrame(frame);
}
function forceReset() {
cancelAnimationFrame(coastFrame);
content.style.transform = "";
overscroll = 0;
}
function onDown(e: MouseEvent) {
e.stopPropagation();
if (e.button !== 0) return;
const tag = (e.target as HTMLElement).tagName;
if (["BUTTON", "INPUT", "LABEL", "SELECT"].includes(tag)) return;
forceReset();
const startY = e.clientY;
const startScroll = scrollEl.scrollTop;
let lastY = e.clientY;
let lastTime = Date.now();
const vSamples: number[] = [];
scrollEl.style.cursor = "grabbing";
showThumb();
function onMove(ev: MouseEvent) {
ev.preventDefault();
ev.stopPropagation();
const y = ev.clientY;
const now = Date.now();
const dt = now - lastTime;
if (dt > 0) {
vSamples.push((lastY - y) / dt);
if (vSamples.length > 5) vSamples.shift();
}
lastY = y;
lastTime = now;
const desired = startScroll - (y - startY);
const max = getMaxScroll();
if (desired < 0) {
scrollEl.scrollTop = 0;
setOverscroll(desired * 0.3);
} else if (desired > max) {
scrollEl.scrollTop = max;
setOverscroll((desired - max) * 0.3);
} else {
scrollEl.scrollTop = desired;
if (overscroll !== 0) setOverscroll(0);
}
showThumb();
}
function onUp(ev: MouseEvent) {
ev.stopPropagation();
scrollEl.style.cursor = "";
window.removeEventListener("mousemove", onMove, true);
window.removeEventListener("mouseup", onUp, true);
if (overscroll !== 0) { springBack(); return; }
// Momentum coast
const avgV = vSamples.length > 0
? vSamples.reduce((a, b) => a + b, 0) / vSamples.length : 0;
const v0 = Math.max(-4, Math.min(4, avgV));
if (Math.abs(v0) < 0.005) return;
const tau = 300;
const coastStart = performance.now();
const scrollStart2 = scrollEl.scrollTop;
const totalDist = v0 * tau;
function coast() {
const t = performance.now() - coastStart;
const decay = Math.exp(-t / tau);
const offset = totalDist * (1 - decay);
const target = scrollStart2 + offset;
const max = getMaxScroll();
if (target < 0) {
scrollEl.scrollTop = 0;
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
setOverscroll(-bounce);
springBack();
return;
}
if (target > max) {
scrollEl.scrollTop = max;
const bounce = Math.min(30, Math.abs(v0 * decay) * 40);
setOverscroll(bounce);
springBack();
return;
}
scrollEl.scrollTop = target;
showThumb();
if (Math.abs(v0 * decay) > 0.0005) {
coastFrame = requestAnimationFrame(coast);
}
}
coastFrame = requestAnimationFrame(coast);
}
window.addEventListener("mousemove", onMove, true);
window.addEventListener("mouseup", onUp, true);
}
function onWheel(e: WheelEvent) {
e.stopPropagation();
const { scrollTop, scrollHeight, clientHeight } = scrollEl;
const atTop = scrollTop <= 0 && e.deltaY < 0;
const atBottom = scrollTop + clientHeight >= scrollHeight && e.deltaY > 0;
if (!atTop && !atBottom) {
e.preventDefault();
scrollEl.scrollTop += e.deltaY;
showThumb();
}
}
node.addEventListener("mousedown", onDown);
node.addEventListener("wheel", onWheel, { passive: false });
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("wheel", onWheel);
forceReset();
if (hideTimer) clearTimeout(hideTimer);
thumb.remove();
},
};
}
</script>
<div class="space-y-3">
<!-- Add custom activity -->
<div class="flex gap-2 items-center">
<div class="flex-1">
<input
type="text"
placeholder="Add custom activity..."
aria-label="New activity text"
maxlength={500}
class="w-full rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px]
text-white outline-none placeholder:text-[#2a2a2a] focus:border-[#333]"
bind:value={newActivityText}
onkeydown={(e) => { if (e.key === "Enter") addCustomActivity(); }}
/>
</div>
<!-- Custom category dropdown -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative" bind:this={dropdownRef} onkeydown={handleDropdownKeydown} role="presentation">
<button
bind:this={dropdownTriggerRef}
use:pressable
class="flex items-center gap-1.5 rounded-xl border border-[#161616] bg-black px-3 py-2.5 text-[13px] text-[#8a8a8a]
hover:border-[#333] hover:text-white transition-colors"
onclick={() => { dropdownOpen = !dropdownOpen; }}
aria-haspopup="listbox"
aria-expanded={dropdownOpen}
aria-label="Category: {getCategoryLabel(newActivityCategory)}"
>
<span>{getCategoryLabel(newActivityCategory)}</span>
<svg aria-hidden="true" class="w-3 h-3 opacity-50 transition-transform duration-200 {dropdownOpen ? 'rotate-180' : ''}" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
{#if dropdownOpen}
<div class="absolute top-full left-0 mt-1 z-50 min-w-[140px] rounded-xl border border-[#222] bg-[#111] shadow-xl shadow-black/50 overflow-hidden"
role="listbox" aria-label="Activity category">
{#each categories as cat, i}
<button
class="w-full text-left px-3.5 py-2.5 text-[13px] transition-colors outline-none
{newActivityCategory === cat ? 'text-white bg-[#1a1a1a]' : 'text-[#8a8a8a] hover:bg-[#1a1a1a] hover:text-white'}
{focusedOptionIndex === i ? 'bg-[#1a1a1a] text-white' : ''}"
role="option"
aria-selected={newActivityCategory === cat}
tabindex={focusedOptionIndex === i ? 0 : -1}
onclick={() => { newActivityCategory = cat; dropdownOpen = false; dropdownTriggerRef?.focus(); }}
>
{getCategoryLabel(cat)}
</button>
{/each}
</div>
{/if}
</div>
<button
use:pressable
class="flex items-center justify-center w-10 h-10 rounded-xl border border-[#161616] text-[18px] text-[#8a8a8a]
hover:border-[#333] hover:text-white transition-colors disabled:opacity-30"
onclick={addCustomActivity}
disabled={!newActivityText.trim()}
aria-label="Add custom activity"
>
+
</button>
</div>
<!-- Category buttons -->
<div class="flex flex-wrap gap-1.5">
{#each categories as cat}
{@const total = builtinCount(cat) + customCount(cat)}
{@const disabled = disabledCount(cat)}
{@const isExpanded = expandedCategory === cat}
<button
use:pressable
class="rounded-xl px-3 py-2 text-[11px] tracking-wider transition-all duration-200
{isExpanded
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
onclick={() => toggleCategory(cat)}
aria-expanded={isExpanded}
aria-controls="activity-panel-{cat}"
>
<span class="uppercase">{getCategoryLabel(cat)}</span>
<span class="ml-1.5 text-[10px] {isExpanded ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] opacity-60'}">
{total - disabled}/{total}
</span>
</button>
{/each}
</div>
<!-- Animated accordion for activity list -->
{#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)}
<div class="accordion-wrapper" class:accordion-open={isExpanded} id="activity-panel-{cat}" role="region" aria-label="{getCategoryLabel(cat)} activities">
<div class="accordion-inner">
<div class="rounded-xl border border-[#161616] bg-[#0a0a0a] overflow-hidden" use:blockParentDrag>
{#if catCustoms.length > 0}
<div class="px-3 pt-2.5 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Custom</div>
</div>
{/if}
<div class="relative" use:innerDragScroll>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="activity-scroll max-h-[200px] overflow-y-auto overflow-x-hidden" tabindex="0" role="group" aria-label="{getCategoryLabel(cat)} activity list">
<div class="scroll-content">
{#each catCustoms as activity (activity.id)}
<div class="flex items-center gap-1 px-1.5 py-0.5 group hover:bg-[#111]">
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {activity.is_favorite ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleCustomFavorite(activity.id)}
aria-label="{activity.is_favorite ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {activity.enabled ? 'text-white' : 'text-[#8a8a8a] line-through'}">
{activity.text}
</span>
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[#8a8a8a] hover:text-[#f85149] opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-all text-[11px]"
onclick={() => removeCustomActivity(activity.id)}
aria-label="Remove {activity.text}"
>
</button>
<ToggleSwitch
checked={activity.enabled}
label="Enable {activity.text}"
onchange={() => toggleCustomEnabled(activity.id)}
/>
</div>
{/each}
{#if catCustoms.length > 0 && catBuiltins.length > 0}
<div class="px-3 pt-2 pb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">Built-in</div>
</div>
{/if}
{#each catBuiltins as activity (activity.text)}
<div class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-[#111]">
<button
class="flex items-center justify-center min-w-[32px] min-h-[32px] text-[13px] transition-opacity {isBuiltinFavorite(activity.text) ? 'opacity-100' : 'opacity-30 hover:opacity-60'}"
onclick={() => toggleBuiltinFavorite(activity.text)}
aria-label="{isBuiltinFavorite(activity.text) ? 'Remove from favorites' : 'Add to favorites'}"
>
</button>
<span class="flex-1 text-[12px] {!isBuiltinDisabled(activity.text) ? 'text-[#8a8a8a]' : 'text-[#8a8a8a] line-through opacity-60'}">
{activity.text}
</span>
<ToggleSwitch
checked={!isBuiltinDisabled(activity.text)}
label="Enable {activity.text}"
onchange={() => toggleBuiltinEnabled(activity.text)}
/>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
<style>
/* Accordion: CSS grid row transition for height animation */
.accordion-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s cubic-bezier(0.22, 0.03, 0.26, 1);
}
.accordion-wrapper.accordion-open {
grid-template-rows: 1fr;
}
.accordion-inner {
overflow: hidden;
min-height: 0;
}
/* Hide native scrollbar completely */
.activity-scroll {
overscroll-behavior: contain;
scrollbar-width: none; /* Firefox */
}
.activity-scroll::-webkit-scrollbar {
display: none; /* Chrome/Edge */
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { loadConfig, config } from "../stores/config";
import type { TimerSnapshot } from "../stores/timer";
let breakTimeRemaining = $state(0);
let breakTotalDuration = $state(0);
const progress = $derived(breakTotalDuration > 0 ? breakTimeRemaining / breakTotalDuration : 0);
function formatTime(secs: number): string {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
onMount(async () => {
await loadConfig();
try {
const snap = await invoke<TimerSnapshot>("get_timer_state");
breakTimeRemaining = snap.breakTimeRemaining;
breakTotalDuration = snap.breakTotalDuration;
} catch {}
await listen<TimerSnapshot>("timer-tick", (event) => {
breakTimeRemaining = event.payload.breakTimeRemaining;
breakTotalDuration = event.payload.breakTotalDuration;
});
await listen("break-ended", () => {
// Window will be closed by backend
});
});
</script>
<div
class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
>
<p class="text-[16px] font-medium text-white mb-4">Break in progress</p>
<span class="text-[42px] font-semibold tabular-nums text-white leading-none mb-6">
{formatTime(breakTimeRemaining)}
</span>
<!-- Progress bar -->
<div class="w-48 h-[3px] rounded-full overflow-hidden" style="background: rgba(255,255,255,0.06);">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {(1 - progress) * 100}%; background: {$config.break_color};"
></div>
</div>
</div>

View File

@@ -7,6 +7,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate"; import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities"; import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
import BreathingGuide from "./BreathingGuide.svelte";
interface Props { interface Props {
standalone?: boolean; standalone?: boolean;
@@ -16,14 +17,14 @@
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null); const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
let currentActivity = $state<BreakActivity>(pickRandomActivity()); let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
let activityCycleTimer: ReturnType<typeof setInterval> | null = null; let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
// Cycle activity every 30 seconds during break // Cycle activity every 30 seconds during break
$effect(() => { $effect(() => {
if ($config.show_break_activities && $timer.state === "breakActive") { if ($config.show_break_activities && $timer.state === "breakActive") {
activityCycleTimer = setInterval(() => { activityCycleTimer = setInterval(() => {
currentActivity = pickRandomActivity(currentActivity); currentActivity = pickRandomActivity(currentActivity, $config);
}, 30_000); }, 30_000);
} }
return () => { return () => {
@@ -34,6 +35,9 @@
}; };
}); });
// F3: Long break indicator
const isLongBreak = $derived($timer.isLongBreak);
async function cancelBreak() { async function cancelBreak() {
const snap = await invoke<TimerSnapshot>("cancel_break"); const snap = await invoke<TimerSnapshot>("cancel_break");
timer.set(snap); timer.set(snap);
@@ -65,6 +69,32 @@
const showButtons = $derived(!$config.strict_mode); 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.61.0 scale to 0.91.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 // Bottom progress bar uses a gradient from break color to accent
const barGradient = $derived( const barGradient = $derived(
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`, `linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
@@ -105,7 +135,21 @@
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div> <div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div> <div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
</div> </div>
<div class="break-breathe"> <!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div>
<TimerRing <TimerRing
progress={breakRingProgress} progress={breakRingProgress}
size={140} size={140}
@@ -114,13 +158,23 @@
label="Break timer" label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining" valueText="{formatTime($timer.breakTimeRemaining)} remaining"
> >
<div class="break-breathe-counter"> <div class="flex flex-col items-center">
<span <span
class="font-semibold leading-none tabular-nums text-white text-[26px]" class="font-semibold leading-none tabular-nums text-white text-[26px]"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""} style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
> >
{formatTime($timer.breakTimeRemaining)} {formatTime($timer.breakTimeRemaining)}
</span> </span>
{#if $config.breathing_guide_enabled}
<span
class="block mt-1.5 text-[9px] tracking-wider uppercase text-center font-medium"
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div> </div>
</TimerRing> </TimerRing>
</div> </div>
@@ -140,7 +194,7 @@
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1"> <div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[12px] leading-relaxed text-[#999]" aria-live="polite"> <p class="text-[12px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -209,7 +263,22 @@
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div> <div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}; --ripple-size: {isModal ? 160 : 200}px"></div>
</div> </div>
<div class="break-breathe relative"> <!-- Hidden: runs breathing animation logic, we only use its phase data -->
{#if $config.breathing_guide_enabled}
<div class="hidden" aria-hidden="true">
<BreathingGuide
pattern={$config.breathing_pattern}
size={0}
color={$config.break_color}
showLabel={false}
bind:phaseLabel={breathPhase}
bind:countdown={breathCountdown}
bind:breathScale={breathScale}
/>
</div>
{/if}
<div class="relative">
<TimerRing <TimerRing
progress={breakRingProgress} progress={breakRingProgress}
size={isModal ? 160 : 200} size={isModal ? 160 : 200}
@@ -218,7 +287,7 @@
label="Break timer" label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining" valueText="{formatTime($timer.breakTimeRemaining)} remaining"
> >
<div class="break-breathe-counter"> <div class="flex flex-col items-center">
<span <span
class="font-semibold leading-none tabular-nums text-white" class="font-semibold leading-none tabular-nums text-white"
class:text-[30px]={isModal} class:text-[30px]={isModal}
@@ -227,11 +296,33 @@
> >
{formatTime($timer.breakTimeRemaining)} {formatTime($timer.breakTimeRemaining)}
</span> </span>
{#if $config.breathing_guide_enabled}
<span
class="block mt-2 tracking-wider uppercase text-center font-medium"
class:text-[10px]={!isModal}
class:text-[9px]={isModal}
style="transform: scale({textScale}); color: {breathColor}; opacity: {0.5 + breathT * 0.5}; transition: transform 0.15s ease-out, opacity 0.15s ease-out, color 0.15s ease-out;"
aria-live="polite" aria-atomic="true"
aria-label="Breathing guide: {breathPhase} {breathCountdown > 0 ? breathCountdown + ' seconds' : ''}"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
{/if}
</div> </div>
</TimerRing> </TimerRing>
</div> </div>
</div> </div>
<!-- F3: Long break badge -->
{#if isLongBreak}
<div class="mb-2 rounded-full px-3 py-1 text-[10px] font-medium tracking-wider uppercase"
style="background: {$config.break_color}20; color: {$config.break_color}; border: 1px solid {$config.break_color}30;"
use:fadeIn={{ delay: 0.2, y: 8 }}
>
Long break
</div>
{/if}
<h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}> <h2 class="mb-2 text-lg font-medium text-white" tabindex="-1" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle} {$timer.breakTitle}
</h2> </h2>
@@ -253,7 +344,7 @@
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"> <div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)} {getCategoryLabel(currentActivity.category)}
</div> </div>
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite"> <p class="text-[13px] leading-relaxed text-[#8a8a8a]" aria-live="polite">
{currentActivity.text} {currentActivity.text}
</p> </p>
</div> </div>
@@ -411,23 +502,6 @@
background: rgba(255, 255, 255, 0.05); 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 ── */ /* ── Ripple circles ── */
.break-ripple { .break-ripple {
position: absolute; position: absolute;

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { onMount } from "svelte";
interface Props {
pattern?: string;
size?: number;
color?: string;
showLabel?: boolean;
phaseLabel?: string;
countdown?: number;
breathScale?: number;
}
let {
pattern = "box",
size = 200,
color = "#7c6aef",
showLabel = true,
phaseLabel = $bindable("Inhale"),
countdown = $bindable(4),
breathScale = $bindable(0.6),
}: Props = $props();
// Breathing patterns: arrays of [phase, durationSeconds]
const patterns: Record<string, [string, number][]> = {
box: [["Inhale", 4], ["Hold", 4], ["Exhale", 4], ["Hold", 4]],
relaxing: [["Inhale", 4], ["Hold", 7], ["Exhale", 8]],
energizing: [["Inhale", 6], ["Hold", 2], ["Exhale", 6], ["Hold", 2]],
calm: [["Inhale", 4], ["Hold", 4], ["Exhale", 6]],
deep: [["Inhale", 5], ["Exhale", 5]],
};
const phases = $derived(patterns[pattern] ?? patterns.box);
const totalCycleDuration = $derived(phases.reduce((sum, [, d]) => sum + d, 0));
let scale = $state(0.6);
let animationId: number | null = null;
let startTime = 0;
function animate(timestamp: number) {
if (!startTime) startTime = timestamp;
const elapsed = ((timestamp - startTime) / 1000) % totalCycleDuration;
let accumulated = 0;
for (const [label, duration] of phases) {
if (elapsed < accumulated + duration) {
phaseLabel = label;
const phaseElapsed = elapsed - accumulated;
countdown = Math.ceil(duration - phaseElapsed);
// Calculate scale based on phase
const t = phaseElapsed / duration;
if (label === "Inhale") {
scale = 0.6 + 0.4 * t; // 60% -> 100%
} else if (label === "Exhale") {
scale = 1.0 - 0.4 * t; // 100% -> 60%
}
// Hold phases keep current scale
breathScale = scale;
break;
}
accumulated += duration;
}
animationId = requestAnimationFrame(animate);
}
onMount(() => {
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
phaseLabel = "Breathe";
countdown = 0;
scale = 0.8;
return;
}
animationId = requestAnimationFrame(animate);
return () => {
if (animationId !== null) cancelAnimationFrame(animationId);
};
});
const circleR = $derived(size / 2 - 8);
const cx = $derived(size / 2);
const cy = $derived(size / 2);
</script>
<div class="breathing-guide" style="width: {size}px; height: {size}px;">
<svg
width={size}
height={size}
viewBox="0 0 {size} {size}"
class="breathing-svg"
aria-label="Breathing guide: {phaseLabel}"
role="img"
>
<defs>
<filter id="breathing-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="12" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Outer glow circle -->
<circle
cx={cx}
cy={cy}
r={circleR * scale}
fill="none"
stroke={color}
stroke-width="2"
opacity="0.15"
filter="url(#breathing-glow)"
class="transition-r"
/>
<!-- Main circle -->
<circle
cx={cx}
cy={cy}
r={circleR * scale}
fill="none"
stroke={color}
stroke-width="2"
opacity="0.4"
class="transition-r"
/>
<!-- Inner fill -->
<circle
cx={cx}
cy={cy}
r={circleR * scale * 0.85}
fill={color}
opacity="0.06"
class="transition-r"
/>
</svg>
<!-- Phase label + countdown -->
{#if showLabel}
<div class="breathing-label">
<span class="text-[14px] font-medium text-white tracking-wider uppercase opacity-80">
{phaseLabel}
</span>
{#if countdown > 0}
<span class="text-[24px] font-semibold text-white tabular-nums mt-0.5">
{countdown}
</span>
{/if}
</div>
{/if}
</div>
<style>
.breathing-guide {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.breathing-svg {
position: absolute;
inset: 0;
}
.breathing-label {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
pointer-events: none;
}
/* Smooth radius transitions via CSS */
.transition-r {
transition: r 0.3s ease-out, opacity 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
import { config } from "../stores/config";
const showMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const showGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const streakDays = $derived($milestoneEvent ?? 0);
// Generate confetti particles on milestone
const confettiColors = ["#ff4d00", "#7c6aef", "#3fb950", "#fca311", "#f72585", "#4361ee"];
const confettiParticles = $derived(
showMilestone
? Array.from({ length: 24 }, (_, i) => ({
id: i,
color: confettiColors[i % confettiColors.length],
angle: (i / 24) * 360 + Math.random() * 15,
distance: 60 + Math.random() * 80,
delay: Math.random() * 0.3,
size: 4 + Math.random() * 4,
}))
: [],
);
</script>
{#if showMilestone}
<div class="celebration-overlay" role="alert" aria-live="assertive">
<!-- Confetti burst -->
<div class="confetti-container">
{#each confettiParticles as p (p.id)}
<div
class="confetti-particle"
style="
--angle: {p.angle}deg;
--distance: {p.distance}px;
--delay: {p.delay}s;
--size: {p.size}px;
background: {p.color};
"
></div>
{/each}
</div>
<!-- Milestone text -->
<div class="celebration-text">
<div class="text-[32px] font-bold text-white mb-1">{streakDays}</div>
<div class="text-[13px] font-medium tracking-wider uppercase text-white opacity-80">
day streak!
</div>
</div>
</div>
{/if}
{#if showGoal && !showMilestone}
<div class="goal-overlay" role="alert" aria-live="assertive">
<div class="goal-badge">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[14px] font-medium text-[#3fb950] ml-2">Daily goal reached!</span>
</div>
</div>
{/if}
<style>
.celebration-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
pointer-events: none;
animation: celebration-fade 3.5s ease forwards;
}
@keyframes celebration-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
.confetti-container {
position: absolute;
width: 0;
height: 0;
}
.confetti-particle {
position: absolute;
width: var(--size);
height: var(--size);
border-radius: 2px;
animation: confetti-burst 1.2s ease-out var(--delay) forwards;
opacity: 0;
}
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(0);
opacity: 1;
}
30% {
opacity: 1;
}
100% {
transform:
translate(
calc(cos(var(--angle)) * var(--distance)),
calc(sin(var(--angle)) * var(--distance) + 40px)
)
rotate(720deg)
scale(1);
opacity: 0;
}
}
.celebration-text {
text-align: center;
animation: celebration-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
}
@keyframes celebration-pop {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.goal-overlay {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
animation: goal-slide 3.5s ease forwards;
}
@keyframes goal-slide {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
10% { transform: translateX(-50%) translateY(0); opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(-50%) translateY(-10px); opacity: 0; }
}
.goal-badge {
display: flex;
align-items: center;
background: rgba(63, 185, 80, 0.12);
border: 1px solid rgba(63, 185, 80, 0.25);
border-radius: 12px;
padding: 10px 18px;
backdrop-filter: blur(16px);
}
@media (prefers-reduced-motion: reduce) {
.celebration-overlay,
.confetti-particle,
.celebration-text,
.goal-overlay {
animation: none;
opacity: 1;
}
.confetti-particle {
display: none;
}
}
</style>

View File

@@ -10,6 +10,7 @@
import { config } from "../stores/config"; import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte"; import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate"; import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
import { listen } from "@tauri-apps/api/event";
async function toggleTimer() { async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer"); const snap = await invoke<TimerSnapshot>("toggle_timer");
@@ -28,7 +29,9 @@
} }
const statusText = $derived( const statusText = $derived(
$timer.idlePaused $timer.deferredBreakPending
? "DEFERRED"
: $timer.idlePaused
? "IDLE" ? "IDLE"
: $timer.prebreakWarning : $timer.prebreakWarning
? "BREAK SOON" ? "BREAK SOON"
@@ -37,6 +40,34 @@
: "PAUSED", : "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) // Track status changes for aria-live region (announce only on change, not every tick)
let lastAnnouncedStatus = $state(""); let lastAnnouncedStatus = $state("");
let statusAnnouncement = $state(""); let statusAnnouncement = $state("");
@@ -146,11 +177,74 @@
<!-- Status label --> <!-- Status label -->
<span <span
class="block text-center text-[11px] font-medium tracking-[0.25em]" class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#8a8a8a]={!$timer.prebreakWarning} class:text-[#8a8a8a]={!$timer.prebreakWarning && !$timer.deferredBreakPending}
class:text-warning={$timer.prebreakWarning} class:text-warning={$timer.prebreakWarning}
class:text-[#fca311]={$timer.deferredBreakPending}
> >
{statusText} {statusText}
</span> </span>
<!-- Indicators inside ring -->
<div class="mt-2 flex flex-col items-center gap-1">
<!-- Pomodoro cycle -->
{#if $timer.pomodoroEnabled}
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1">
{#each Array($timer.pomodoroTotalInCycle) as _, i}
{@const isLong = i === $timer.pomodoroTotalInCycle - 1}
{@const isFilled = i < $timer.pomodoroCyclePosition}
{@const isCurrent = i === $timer.pomodoroCyclePosition}
<div
class="rounded-full transition-colors duration-300"
style="
width: {isLong ? 8 : 5}px;
height: {isLong ? 8 : 5}px;
background: {isFilled ? $config.accent_color : isCurrent ? $config.accent_color + '60' : '#222'};
{isCurrent ? 'box-shadow: 0 0 4px ' + $config.accent_color + '40;' : ''}
"
></div>
{/each}
</div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
</span>
</div>
{/if}
<!-- Microbreak countdown -->
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
<div class="flex items-center gap-1 text-[9px] text-[#8a8a8a]">
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="tabular-nums">{microbreakCountdown()}</span>
</div>
{/if}
<!-- Daily goal -->
{#if $config.daily_goal_enabled}
<div class="flex items-center gap-1.5">
{#if dailyGoalMet}
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[9px] text-[#3fb950]">Goal met</span>
{:else}
<span class="text-[9px] text-[#8a8a8a]">Goal</span>
<div class="w-16 h-[2px] rounded-full overflow-hidden" style="background: #161616;">
<div
class="h-full rounded-full transition-[width] duration-500"
style="width: {Math.min(100, (dailyGoalProgress / $config.daily_goal_breaks) * 100)}%; background: {$config.accent_color};"
></div>
</div>
<span class="text-[9px] text-[#8a8a8a] tabular-nums">
{dailyGoalProgress}/{$config.daily_goal_breaks}
</span>
{/if}
</div>
{/if}
</div>
</div> </div>
</TimerRing> </TimerRing>
</div> </div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
let opacity = $state(0);
onMount(async () => {
await listen<{ progress: number; maxOpacity: number }>("screen-dim-update", (event) => {
opacity = event.payload.progress * event.payload.maxOpacity;
});
// When break starts, this window gets destroyed by backend
await listen("break-started", () => {
opacity = 0;
});
});
</script>
<div
class="fixed inset-0 pointer-events-none"
style="background: rgba(0, 0, 0, {opacity}); transition: opacity 1s linear;"
aria-hidden="true"
></div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import type { TimerSnapshot } from "../stores/timer";
import { loadConfig, config } from "../stores/config";
import { pickRandomActivity, getCategoryLabel, type BreakActivity } from "../utils/activities";
let timeRemaining = $state(20);
let totalDuration = $state(20);
let activity = $state<BreakActivity | null>(null);
const progress = $derived(totalDuration > 0 ? 1 - timeRemaining / totalDuration : 0);
onMount(async () => {
await loadConfig();
if ($config.microbreak_show_activity) {
// Pick an eye-focused activity for microbreaks
activity = pickRandomActivity(undefined, $config);
}
try {
const snap = await invoke<TimerSnapshot>("get_timer_state");
timeRemaining = snap.microbreakTimeRemaining;
totalDuration = snap.microbreakTotalDuration;
} catch {}
await listen<TimerSnapshot>("timer-tick", (event) => {
const snap = event.payload;
timeRemaining = snap.microbreakTimeRemaining;
totalDuration = snap.microbreakTotalDuration;
});
await listen("microbreak-ended", () => {
// Window will be closed by backend
});
});
</script>
<div class="microbreak-card">
<div class="flex items-center gap-3 mb-2">
<svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#7c6aef" stroke-width="1.5">
<path d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="text-[15px] font-medium text-white">Look away — 20 feet for {timeRemaining}s</span>
</div>
{#if activity && $config.microbreak_show_activity}
<p class="text-[12px] text-[#8a8a8a] mb-3 ml-[34px]">
{activity.text}
</p>
{/if}
<!-- Progress bar -->
<div class="h-[3px] w-full rounded-full overflow-hidden" style="background: rgba(255,255,255,0.05);">
<div
class="h-full transition-[width] duration-1000 ease-linear rounded-full"
style="width: {progress * 100}%; background: linear-gradient(to right, #7c6aef, #4361ee);"
></div>
</div>
</div>
<style>
.microbreak-card {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
width: 380px;
background: rgba(12, 12, 12, 0.95);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 16px 20px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
</style>

View File

@@ -16,6 +16,9 @@
let breakColor = $state("#7c6aef"); let breakColor = $state("#7c6aef");
let countdownFont = $state(""); let countdownFont = $state("");
let draggable = $state(false); let draggable = $state(false);
let pomodoroEnabled = $state(false);
let pomodoroCyclePosition = $state(0);
let pomodoroTotalInCycle = $state(4);
// Use config store directly for live updates // Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom); const uiZoom = $derived($config.ui_zoom);
@@ -132,6 +135,9 @@
timeText = formatTime(snap.timeRemaining); timeText = formatTime(snap.timeRemaining);
progress = snap.progress; progress = snap.progress;
} }
pomodoroEnabled = snap.pomodoroEnabled;
pomodoroCyclePosition = snap.pomodoroCyclePosition;
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
} }
// Click opens main window // Click opens main window
@@ -333,6 +339,12 @@ const fontStyle = $derived(
> >
{timeText} {timeText}
</span> </span>
<!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #8a8a8a;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,10 +7,36 @@
import ColorPicker from "./ColorPicker.svelte"; import ColorPicker from "./ColorPicker.svelte";
import FontSelector from "./FontSelector.svelte"; import FontSelector from "./FontSelector.svelte";
import TimeSpinner from "./TimeSpinner.svelte"; import TimeSpinner from "./TimeSpinner.svelte";
import ActivityManager from "./ActivityManager.svelte";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate"; import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
import { playSound } from "../utils/sounds"; import { playSound } from "../utils/sounds";
import type { TimeRange } from "../stores/config"; 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<boolean>("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 soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const;
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const; const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
@@ -131,11 +157,10 @@
use:dragScroll use:dragScroll
> >
<div class="space-y-3"> <div class="space-y-3">
<!-- Timer -->
<!-- 1. Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Timer Timer
</h3> </h3>
@@ -188,11 +213,137 @@
</div> </div>
</section> </section>
<!-- Break Screen --> <!-- 2. Pomodoro Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.03 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Pomodoro Mode
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Enable Pomodoro</div>
<div class="text-[11px] text-[#8a8a8a]">Short breaks then a long break</div>
</div>
<ToggleSwitch bind:checked={$config.pomodoro_enabled} label="Pomodoro mode" onchange={markChanged} />
</div>
{#if $config.pomodoro_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Short breaks before long</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_short_breaks} short + 1 long</div>
</div>
<Stepper bind:value={$config.pomodoro_short_breaks} label="Short breaks" min={1} max={10} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Long break duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.pomodoro_long_break_duration} min</div>
</div>
<Stepper bind:value={$config.pomodoro_long_break_duration} label="Long break duration" min={5} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-title">Long break title</label>
<input id="pomo-title" type="text" maxlength={100}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_title} oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="pomo-msg">Long break message</label>
<input id="pomo-msg" type="text" maxlength={500}
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px] text-white outline-none transition-colors placeholder:text-[#2a2a2a] focus:border-[#333]"
bind:value={$config.pomodoro_long_break_message} oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Reset on skip</div>
<div class="text-[11px] text-[#8a8a8a]">Reset cycle when skipping a break</div>
</div>
<ToggleSwitch bind:checked={$config.pomodoro_reset_on_skip} label="Reset on skip" onchange={markChanged} />
</div>
{/if}
</section>
<!-- 3. Microbreaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" Microbreaks
> </h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">20-20-20 eye breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Quick eye rest reminders</div>
</div>
<ToggleSwitch
bind:checked={$config.microbreak_enabled}
label="Microbreaks"
onchange={markChanged}
/>
</div>
{#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Frequency</div>
<div class="text-[11px] text-[#8a8a8a]">Every {$config.microbreak_frequency} min</div>
</div>
<Stepper bind:value={$config.microbreak_frequency} label="Microbreak frequency" min={5} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Duration</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.microbreak_duration} seconds</div>
</div>
<Stepper bind:value={$config.microbreak_duration} label="Microbreak duration" min={10} max={60} step={5} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound</div>
<div class="text-[11px] text-[#8a8a8a]">Play sound on eye break</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_sound_enabled} label="Microbreak sound" onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Show activity</div>
<div class="text-[11px] text-[#8a8a8a]">Activity suggestion during eye break</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_show_activity} label="Microbreak activity" onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pause during breaks</div>
<div class="text-[11px] text-[#8a8a8a]">No eye breaks during main breaks</div>
</div>
<ToggleSwitch bind:checked={$config.microbreak_pause_during_break} label="Pause during breaks" onchange={markChanged} />
</div>
{/if}
</section>
<!-- 4. Break Screen (stripped down) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.09 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Break Screen Break Screen
</h3> </h3>
@@ -261,13 +412,90 @@
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
{#if $config.fullscreen_mode}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Block all monitors</div>
<div class="text-[11px] text-[#8a8a8a]">Show overlay on all screens during breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.multi_monitor_break}
label="Block all monitors"
onchange={markChanged}
/>
</div>
{/if}
</section> </section>
<!-- Behavior --> <!-- 5. Break Activities (own card, conditional) -->
{#if $config.show_break_activities}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" Break Activities
</h3>
<ActivityManager />
</section>
{/if}
<!-- 6. Breathing Guide (own card) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.15 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Breathing Guide
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Guided breathing</div>
<div class="text-[11px] text-[#8a8a8a]">Visual breathing guide during breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.breathing_guide_enabled}
label="Guided breathing"
onchange={markChanged}
/>
</div>
{#if $config.breathing_guide_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div>
<div class="mb-3 text-[13px] text-white">Breathing pattern</div>
<div class="flex flex-col gap-1.5">
{#each breathingPatternMeta as bp}
<button
use:pressable
class="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-left
transition-all duration-200
{$config.breathing_pattern === bp.id
? 'bg-[#1a1a1a] border border-[#333]'
: 'bg-[#0a0a0a] border border-[#161616] hover:border-[#333]'}"
onclick={() => {
$config.breathing_pattern = bp.id;
markChanged();
}}
> >
<div
class="w-3 h-3 rounded-full border-2 flex-shrink-0 transition-colors duration-200"
style="border-color: {$config.breathing_pattern === bp.id ? $config.accent_color : '#333'};
background: {$config.breathing_pattern === bp.id ? $config.accent_color : 'transparent'};"
></div>
<span class="text-[12px] font-medium {$config.breathing_pattern === bp.id ? 'text-white' : 'text-[#8a8a8a]'}">
{bp.label}
</span>
<span class="ml-auto text-[11px] text-[#8a8a8a] opacity-60 tabular-nums">
{bp.desc}
</span>
</button>
{/each}
</div>
</div>
{/if}
</section>
<!-- 7. Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Behavior Behavior
</h3> </h3>
@@ -357,114 +585,152 @@
</div> </div>
</section> </section>
<!-- Working Hours --> <!-- 8. Alerts (MERGED: Notifications + Pre-Break Nudge) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Alerts
</h3>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Working hours</div> <div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged}
/>
</div>
{#if $config.notification_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-[#8a8a8a]">
Only show breaks during your configured work schedule {$config.notification_before_break}s before
</div> </div>
</div> </div>
<ToggleSwitch <Stepper
bind:checked={$config.working_hours_enabled} bind:value={$config.notification_before_break}
label="Working hours" label="Alert timing"
min={0}
max={300}
step={10}
onchange={markChanged} onchange={markChanged}
/> />
</div> </div>
{/if}
{#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-[#161616]"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex} <div class="flex items-center">
{@const dayName = daysOfWeek[dayIndex]} <div class="flex-1">
<div class="mb-4"> <div class="text-[13px] text-white">Screen dimming</div>
<!-- Day header with toggle --> <div class="text-[11px] text-[#8a8a8a]">Gradually dim screen before breaks</div>
<div class="flex items-center gap-3 mb-3"> </div>
<ToggleSwitch <ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} />
bind:checked={$config.working_hours_schedule[dayIndex].enabled} </div>
label={dayName}
{#if $config.screen_dim_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Start dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.screen_dim_seconds}s before break</div>
</div>
<Stepper bind:value={$config.screen_dim_seconds} label="Dim start" min={3} max={60} onchange={markChanged} />
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Max dimming</div>
<div class="text-[11px] text-[#8a8a8a]">{Math.round($config.screen_dim_max_opacity * 100)}%</div>
</div>
<Stepper
bind:value={$config.screen_dim_max_opacity}
label="Max dim opacity"
min={0.1}
max={0.7}
step={0.05}
formatValue={(v) => `${Math.round(v * 100)}%`}
onchange={markChanged} onchange={markChanged}
/> />
<span class="text-[13px] text-white w-20">{dayName}</span>
</div> </div>
{#if daySchedule.enabled}
<!-- Time ranges for this day -->
<div class="space-y-2">
{#each daySchedule.ranges as range, rangeIndex}
<div class="flex items-center gap-2">
<TimeSpinner
value={range.start}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/>
<span class="text-[#555] text-[13px]">to</span>
<TimeSpinner
value={range.end}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
/>
<!-- Add range button -->
{#if rangeIndex === daySchedule.ranges.length - 1}
<button
use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
{/if}
<!-- Clone button -->
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<!-- Delete button (never show for first range) -->
{#if rangeIndex > 0}
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div>
{/if}
{/each}
{/if} {/if}
</section> </section>
<!-- Idle Detection --> <!-- 9. Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" Sound
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div>
</div>
<ToggleSwitch
bind:checked={$config.sound_enabled}
label="Sound effects"
onchange={markChanged}
/>
</div>
{#if $config.sound_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div>
</div>
<Stepper
bind:value={$config.sound_volume}
label="Volume"
min={0}
max={100}
step={10}
formatValue={(v) => `${v}%`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div>
<div class="mb-3 text-[13px] text-white">Sound preset</div>
<div class="grid grid-cols-4 gap-2">
{#each soundPresets as preset}
<button
use:pressable
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200
{$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#8a8a8a] border border-[#161616] hover:border-[#333] hover:text-white'}"
onclick={() => {
$config.sound_preset = preset;
markChanged();
playSound(preset, $config.sound_volume);
}}
> >
Idle Detection {preset}
</button>
{/each}
</div>
</div>
{/if}
</section>
<!-- 10. Idle & Smart Breaks (MERGED) -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Idle & Smart Breaks
</h3> </h3>
<div class="flex items-center"> <div class="flex items-center">
@@ -500,19 +766,12 @@
/> />
</div> </div>
{/if} {/if}
</section>
<!-- Smart Breaks --> <div class="my-4 h-px bg-[#161616]"></div>
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Smart Breaks
</h3>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Enable smart breaks</div> <div class="text-[13px] text-white">Smart breaks</div>
<div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div> <div class="text-[11px] text-[#8a8a8a]">Auto-reset timer when you step away</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
@@ -561,119 +820,90 @@
{/if} {/if}
</section> </section>
<!-- Notifications --> <!-- 11. Presentation Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.30 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" Presentation Mode
>
Notifications
</h3> </h3>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div> <div class="text-[13px] text-white">Auto-detect fullscreen</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div> <div class="text-[11px] text-[#8a8a8a]">Defer breaks during fullscreen apps</div>
</div> </div>
<ToggleSwitch <ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" onchange={markChanged} />
bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged}
/>
</div> </div>
{#if $config.notification_enabled} {#if $config.presentation_mode_enabled}
{#if $config.microbreak_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div> <div class="text-[13px] text-white">Defer microbreaks</div>
<div class="text-[11px] text-[#8a8a8a]"> <div class="text-[11px] text-[#8a8a8a]">Also pause eye breaks</div>
{$config.notification_before_break}s before
</div> </div>
<ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} />
</div> </div>
<Stepper {/if}
bind:value={$config.notification_before_break}
label="Alert timing" <div class="my-4 h-px bg-[#161616]"></div>
min={0} <div class="flex items-center">
max={300} <div class="flex-1">
step={10} <div class="text-[13px] text-white">Notification</div>
onchange={markChanged} <div class="text-[11px] text-[#8a8a8a]">Show toast when break is deferred</div>
/> </div>
<ToggleSwitch bind:checked={$config.presentation_mode_notification} label="Deferral notification" onchange={markChanged} />
</div> </div>
{/if} {/if}
</section> </section>
<!-- Sound --> <!-- 12. Goals & Streaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" Goals & Streaks
>
Sound
</h3> </h3>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div> <div class="text-[13px] text-white">Daily goal</div>
<div class="text-[11px] text-[#8a8a8a]">Play sounds on break events</div> <div class="text-[11px] text-[#8a8a8a]">Track daily break target</div>
</div> </div>
<ToggleSwitch <ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} />
bind:checked={$config.sound_enabled}
label="Sound effects"
onchange={markChanged}
/>
</div> </div>
{#if $config.sound_enabled} {#if $config.daily_goal_enabled}
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-1"> <div class="flex-1">
<div class="text-[13px] text-white">Volume</div> <div class="text-[13px] text-white">Target breaks</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.sound_volume}%</div> <div class="text-[11px] text-[#8a8a8a]">{$config.daily_goal_breaks} per day</div>
</div> </div>
<Stepper <Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} />
bind:value={$config.sound_volume} </div>
label="Volume" {/if}
min={0}
max={100} <div class="my-4 h-px bg-[#161616]"></div>
step={10} <div class="flex items-center">
formatValue={(v) => `${v}%`} <div class="flex-1">
onchange={markChanged} <div class="text-[13px] text-white">Celebrations</div>
/> <div class="text-[11px] text-[#8a8a8a]">Confetti on milestones and goals</div>
</div>
<ToggleSwitch bind:checked={$config.milestone_celebrations} label="Celebrations" onchange={markChanged} />
</div> </div>
<div class="my-4 h-px bg-[#161616]"></div> <div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div> <div class="flex-1">
<div class="mb-3 text-[13px] text-white">Sound preset</div> <div class="text-[13px] text-white">Streak notifications</div>
<div class="grid grid-cols-4 gap-2"> <div class="text-[11px] text-[#8a8a8a]">Toast on streak milestones</div>
{#each soundPresets as preset}
<button
use:pressable
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200
{$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#555] border border-[#161616] hover:border-[#333] hover:text-[#999]'}"
onclick={() => {
$config.sound_preset = preset;
markChanged();
playSound(preset, $config.sound_volume);
}}
>
{preset}
</button>
{/each}
</div> </div>
<ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} />
</div> </div>
{/if}
</section> </section>
<!-- Appearance --> <!-- 13. Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Appearance Appearance
</h3> </h3>
@@ -741,11 +971,111 @@
</div> </div>
</section> </section>
<!-- Mini Mode --> <!-- 14. Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
<h3 <div class="flex items-center">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" <div class="flex-1">
<div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#8a8a8a]">
Only show breaks during your configured work schedule
</div>
</div>
<ToggleSwitch
bind:checked={$config.working_hours_enabled}
label="Working hours"
onchange={markChanged}
/>
</div>
{#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex}
{@const dayName = daysOfWeek[dayIndex]}
<div class="mb-4">
<!-- Day header with toggle -->
<div class="flex items-center gap-3 mb-3">
<ToggleSwitch
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
label={dayName}
onchange={markChanged}
/>
<span class="text-[13px] text-white w-20">{dayName}</span>
</div>
{#if daySchedule.enabled}
<!-- Time ranges for this day -->
<div class="space-y-2">
{#each daySchedule.ranges as range, rangeIndex}
<div class="flex items-center gap-2">
<TimeSpinner
value={range.start}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/>
<span class="text-[#8a8a8a] text-[13px]">to</span>
<TimeSpinner
value={range.end}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
/>
<!-- Add range button -->
{#if rangeIndex === daySchedule.ranges.length - 1}
<button
use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
{/if}
<!-- Clone button -->
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<!-- Delete button (never show for first range) -->
{#if rangeIndex > 0}
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#8a8a8a] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div>
{/if}
{/each}
{/if}
</section>
<!-- 15. Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.42 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Mini Mode Mini Mode
</h3> </h3>
@@ -784,11 +1114,28 @@
{/if} {/if}
</section> </section>
<!-- Shortcuts --> <!-- 16. General -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase" General
> </h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Start on Windows login</div>
<div class="text-[11px] text-[#8a8a8a]">Launch automatically at startup</div>
</div>
<ToggleSwitch
checked={autoStartEnabled}
label="Start on login"
onchange={toggleAutoStart}
/>
</div>
</section>
<!-- 17. Keyboard Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.48 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Keyboard Shortcuts Keyboard Shortcuts
</h3> </h3>
@@ -808,8 +1155,8 @@
</div> </div>
</section> </section>
<!-- Reset --> <!-- 18. Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}> <div class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
<button <button
use:pressable use:pressable
class="w-full rounded-full border py-3 text-[12px] class="w-full rounded-full border py-3 text-[12px]
@@ -817,7 +1164,7 @@
transition-all duration-200 transition-all duration-200
{resetConfirming {resetConfirming
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white' ? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
: 'border-[#1a1a1a] text-[#444] hover:border-[#333] hover:text-white'}" : 'border-[#1a1a1a] text-[#8a8a8a] hover:border-[#333] hover:text-white'}"
onclick={handleReset} onclick={handleReset}
> >
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"} {resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}

View File

@@ -9,9 +9,13 @@
todaySkipped: number; todaySkipped: number;
todaySnoozed: number; todaySnoozed: number;
todayBreakTimeSecs: number; todayBreakTimeSecs: number;
todayNaturalBreaks: number;
todayNaturalBreakTimeSecs: number;
complianceRate: number; complianceRate: number;
currentStreak: number; currentStreak: number;
bestStreak: number; bestStreak: number;
dailyGoalProgress: number;
dailyGoalMet: boolean;
} }
interface DayRecord { interface DayRecord {
@@ -22,13 +26,27 @@
totalBreakTimeSecs: number; totalBreakTimeSecs: number;
} }
interface WeekSummary {
weekStart: string;
totalCompleted: number;
totalSkipped: number;
totalBreakTimeSecs: number;
complianceRate: number;
avgDailyCompleted: number;
}
let stats = $state<StatsSnapshot | null>(null); let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]); let history = $state<DayRecord[]>([]);
let monthHistory = $state<DayRecord[]>([]);
let weeklySummaries = $state<WeekSummary[]>([]);
let activeTab = $state<"today" | "weekly" | "monthly">("today");
async function loadStats() { async function loadStats() {
try { try {
stats = await invoke<StatsSnapshot>("get_stats"); stats = await invoke<StatsSnapshot>("get_stats");
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 }); history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
monthHistory = await invoke<DayRecord[]>("get_daily_history", { days: 30 });
weeklySummaries = await invoke<WeekSummary[]>("get_weekly_summary", { weeks: 4 });
} catch (e) { } catch (e) {
console.error("Failed to load stats:", e); console.error("Failed to load stats:", e);
} }
@@ -56,7 +74,21 @@
return `${hrs}h ${rem}m`; 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(); let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => { $effect(() => {
@@ -64,6 +96,22 @@
drawChart(chartCanvas, history); 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[]) { function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
@@ -78,8 +126,8 @@
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped)); const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
const barWidth = Math.floor((w - 40) / data.length) - 8; const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
const barGap = 8; const barGap = data.length > 10 ? 2 : 8;
const chartHeight = h - 30; const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00"; const accentColor = $config.accent_color || "#ff4d00";
@@ -90,34 +138,87 @@
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0; const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0; const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
// Completed bar
if (completedH > 0) { if (completedH > 0) {
ctx.fillStyle = accentColor; ctx.fillStyle = accentColor;
ctx.beginPath(); ctx.beginPath();
const barY = chartHeight - completedH; const barY = chartHeight - completedH;
roundedRect(ctx, x, barY, barWidth, completedH, 4); roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
ctx.fill(); ctx.fill();
} }
// Skipped bar (stacked on top)
if (skippedH > 0) { if (skippedH > 0) {
ctx.fillStyle = "#333"; ctx.fillStyle = "#333";
ctx.beginPath(); ctx.beginPath();
const barY = chartHeight - completedH - skippedH; 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(); ctx.fill();
} }
// Day label // Day label — show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a"; ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif"; ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD" const label = day.date.slice(5);
ctx.fillText(label, x + barWidth / 2, h - 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(() => { const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available"; if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0); 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`; 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( function roundedRect(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
x: number, x: number,
@@ -183,14 +293,30 @@
</h1> </h1>
</div> </div>
<!-- Tab navigation -->
<div class="flex gap-1 px-5 mb-3" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button
use:pressable
class="rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-[#8a8a8a] hover:text-white'}"
onclick={() => activeTab = tab as any}
>
{label}
</button>
{/each}
</div>
<!-- Scrollable content --> <!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll> <div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3"> <div class="space-y-3">
{#if activeTab === "today"}
<!-- Today's summary --> <!-- Today's summary -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Today Today
</h3> </h3>
@@ -224,11 +350,42 @@
</div> </div>
</section> </section>
<!-- F10: Daily goal -->
{#if $config.daily_goal_enabled}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.04 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Daily Goal
</h3>
<div class="flex items-center gap-4">
<div class="relative w-16 h-16">
<svg width="64" height="64" viewBox="0 0 64 64" style="transform: rotate(-90deg);">
<circle cx="32" cy="32" r="28" fill="none" stroke="#161616" stroke-width="4" />
<circle cx="32" cy="32" r="28" fill="none" stroke={$config.accent_color} stroke-width="4"
stroke-dasharray={2 * Math.PI * 28}
stroke-dashoffset={2 * Math.PI * 28 * (1 - goalPercent / 100)}
stroke-linecap="round"
class="transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-[14px] font-semibold text-white tabular-nums">{goalPercent}%</span>
</div>
</div>
<div>
<div class="text-[14px] text-white font-medium">
{stats?.dailyGoalProgress ?? 0} / {$config.daily_goal_breaks} breaks
</div>
<div class="text-[11px] text-[#8a8a8a]">
{stats?.dailyGoalMet ? "Goal reached!" : `${$config.daily_goal_breaks - (stats?.dailyGoalProgress ?? 0)} more to go`}
</div>
</div>
</div>
</section>
{/if}
<!-- Streak --> <!-- Streak -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Streak Streak
</h3> </h3>
@@ -253,13 +410,25 @@
{stats?.bestStreak ?? 0} {stats?.bestStreak ?? 0}
</div> </div>
</div> </div>
<!-- F10: Next milestone -->
{#if nextMilestone()}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-[#8a8a8a]">{nextMilestone()} day streak</div>
</div>
<div class="text-[13px] text-[#8a8a8a] tabular-nums">
{nextMilestone()! - (stats?.currentStreak ?? 0)} days away
</div>
</div>
{/if}
</section> </section>
<!-- Weekly chart --> <!-- Weekly chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}> <section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 <h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
Last 7 Days Last 7 Days
</h3> </h3>
@@ -271,7 +440,6 @@
aria-label={chartAriaLabel()} aria-label={chartAriaLabel()}
></canvas> ></canvas>
<!-- Screen-reader accessible data table for the chart -->
{#if history.length > 0} {#if history.length > 0}
<table class="sr-only"> <table class="sr-only">
<caption>Break history for the last {history.length} days</caption> <caption>Break history for the last {history.length} days</caption>
@@ -301,6 +469,142 @@
</div> </div>
</div> </div>
</section> </section>
{:else if activeTab === "weekly"}
<!-- Weekly summaries -->
{#each weeklySummaries as week, i}
{@const prevWeek = weeklySummaries[i + 1]}
{@const trend = prevWeek ? week.complianceRate - prevWeek.complianceRate : 0}
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: i * 0.06 }}>
<h3 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Week of {week.weekStart}
</h3>
<div class="grid grid-cols-3 gap-3 mb-3">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Completed</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{week.totalSkipped}</div>
<div class="text-[10px] text-[#8a8a8a]">Skipped</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{Math.round(week.complianceRate * 100)}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Compliance</div>
</div>
</div>
<div class="flex items-center justify-between text-[11px]">
<span class="text-[#8a8a8a]">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek}
<span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#f85149' : '#8a8a8a'};"
>
{#if trend > 0}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 15l-6-6-6 6"/></svg>
{:else if trend < 0}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M6 9l6 6 6-6"/></svg>
{:else}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14"/></svg>
{/if}
{Math.abs(Math.round(trend * 100))}%
</span>
{/if}
</div>
</section>
{/each}
{:else}
<!-- Monthly: 30-day chart -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 30 Days
</h3>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={monthChartCanvas}
class="h-[140px] w-full"
role="img"
aria-label="30-day break history chart"
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
</div>
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm bg-[#333]"></div>
Skipped
</div>
</div>
</section>
<!-- Heatmap -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Activity Heatmap
</h3>
<div class="flex justify-center">
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
bind:this={heatmapCanvas}
role="img"
aria-label="30-day activity heatmap"
></canvas>
</div>
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-[#8a8a8a]">
<span>Less</span>
<div class="flex gap-1">
<div class="w-3 h-3 rounded-sm" style="background: #161616;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}40;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}80;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color}c0;"></div>
<div class="w-3 h-3 rounded-sm" style="background: {$config.accent_color};"></div>
</div>
<span>More</span>
</div>
</section>
<!-- Monthly totals -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Monthly Summary
</h3>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">{monthTotalCompleted}</div>
<div class="text-[10px] text-[#8a8a8a]">Total breaks</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{monthAvgCompliance()}%
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg compliance</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{Math.floor(monthTotalTime / 60)} min
</div>
<div class="text-[10px] text-[#8a8a8a]">Total break time</div>
</div>
<div class="text-center">
<div class="text-[22px] font-semibold text-white tabular-nums">
{(monthTotalCompleted / 30).toFixed(1)}
</div>
<div class="text-[10px] text-[#8a8a8a]">Avg daily breaks</div>
</div>
</div>
</section>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -62,7 +62,7 @@
type="button" type="button"
aria-label="Decrease" aria-label="Decrease"
class="flex h-7 w-7 items-center justify-center rounded-lg 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 hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(decrement)} onmousedown={() => startHold(decrement)}
@@ -80,7 +80,7 @@
type="button" type="button"
aria-label="Increase" aria-label="Increase"
class="flex h-7 w-7 items-center justify-center rounded-lg 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 hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20" disabled:opacity-20"
onmousedown={() => startHold(increment)} onmousedown={() => startHold(increment)}

View File

@@ -11,7 +11,13 @@ export interface DaySchedule {
ranges: TimeRange[]; ranges: TimeRange[];
} }
export type { TimeRange, DaySchedule }; export interface CustomActivity {
id: string;
category: string;
text: string;
is_favorite: boolean;
enabled: boolean;
}
export interface Config { export interface Config {
break_duration: number; break_duration: number;
@@ -24,7 +30,7 @@ export interface Config {
allow_end_early: boolean; allow_end_early: boolean;
immediately_start_breaks: boolean; immediately_start_breaks: boolean;
working_hours_enabled: 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; dark_mode: boolean;
color_scheme: string; color_scheme: string;
backdrop_opacity: number; backdrop_opacity: number;
@@ -49,6 +55,45 @@ export interface Config {
background_blobs_enabled: boolean; background_blobs_enabled: boolean;
mini_click_through: boolean; mini_click_through: boolean;
mini_hover_threshold: number; 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 = { const defaultConfig: Config = {
@@ -63,13 +108,13 @@ const defaultConfig: Config = {
immediately_start_breaks: false, immediately_start_breaks: false,
working_hours_enabled: false, working_hours_enabled: false,
working_hours_schedule: [ working_hours_schedule: [
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday { enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday { enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
], ],
dark_mode: true, dark_mode: true,
color_scheme: "Ocean", color_scheme: "Ocean",
@@ -95,6 +140,45 @@ const defaultConfig: Config = {
background_blobs_enabled: true, background_blobs_enabled: true,
mini_click_through: true, mini_click_through: true,
mini_hover_threshold: 3.0, 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<Config>(defaultConfig); export const config = writable<Config>(defaultConfig);

View File

@@ -26,6 +26,27 @@ export interface TimerSnapshot {
naturalBreakOccurred: boolean; naturalBreakOccurred: boolean;
smartBreaksEnabled: boolean; smartBreaksEnabled: boolean;
smartBreakThreshold: number; 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 = { const defaultSnapshot: TimerSnapshot = {
@@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = {
naturalBreakOccurred: false, naturalBreakOccurred: false,
smartBreaksEnabled: true, smartBreaksEnabled: true,
smartBreakThreshold: 300, 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<TimerSnapshot>(defaultSnapshot); export const timer = writable<TimerSnapshot>(defaultSnapshot);
@@ -114,7 +151,42 @@ export async function initTimerStore() {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5); 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<number | null>(null);
export const dailyGoalEvent = writable<boolean>(false);
// Helper: format seconds as MM:SS // Helper: format seconds as MM:SS
export function formatTime(secs: number): string { export function formatTime(secs: number): string {

View File

@@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string {
return categoryLabels[cat]; return categoryLabels[cat];
} }
/** Pick a random activity, optionally excluding a previous one */ /** Pick a random activity, optionally excluding a previous one.
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity { * When config is provided, respects disabled/favorite/custom activity settings. */
const pool = exclude export function pickRandomActivity(
? breakActivities.filter((a) => a.text !== exclude.text) exclude?: BreakActivity,
: breakActivities; config?: {
return pool[Math.floor(Math.random() * pool.length)]; 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)];
} }

View File

@@ -2,19 +2,35 @@ import "./app.css";
import App from "./App.svelte"; import App from "./App.svelte";
import MiniTimer from "./lib/components/MiniTimer.svelte"; import MiniTimer from "./lib/components/MiniTimer.svelte";
import BreakWindow from "./lib/components/BreakWindow.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"; import { mount } from "svelte";
const params = new URLSearchParams(window.location.search); 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 isMini = params.has("mini");
const isBreak = params.has("break"); const isBreak = params.has("break");
if (isMini || isBreak) { if (isMini || isBreak || isMicrobreak || isDim || isBreakOverlay) {
// Transparent body so rounded shapes show through the transparent window // Transparent body so rounded shapes show through the transparent window
document.body.style.background = "transparent"; document.body.style.background = "transparent";
document.documentElement.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, { const app = mount(component, {
target: document.getElementById("app")!, target: document.getElementById("app")!,