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

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

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

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

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

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

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

View File

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

2
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
pub natural_break_occurred: bool,
pub smart_breaks_enabled: bool,
pub smart_break_threshold: u32,
// F1: Microbreaks
pub microbreak_enabled: bool,
pub microbreak_active: bool,
pub microbreak_time_remaining: u64,
pub microbreak_total_duration: u64,
pub microbreak_countdown: u64, // seconds until next microbreak
pub microbreak_frequency: u32,
// F3: Pomodoro
pub pomodoro_enabled: bool,
pub pomodoro_cycle_position: u32,
pub pomodoro_total_in_cycle: u32,
pub pomodoro_is_long_break: bool,
pub pomodoro_next_is_long: bool,
// F5: Screen dimming
pub screen_dim_active: bool,
pub screen_dim_progress: f64,
// F2: Presentation mode
pub presentation_mode_active: bool,
pub deferred_break_pending: bool,
// F10: Gamification
pub is_long_break: bool,
}
/// Events emitted by the timer to the frontend
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
pub strict_mode: bool,
pub snooze_duration: u32,
pub fullscreen_mode: bool,
pub is_long_break: bool,
}
pub struct TimerManager {
@@ -84,6 +106,17 @@ pub struct TimerManager {
// Smart breaks: track when idle started for natural break detection
pub idle_start_time: Option<Instant>,
pub natural_break_occurred: bool,
// F1: Microbreaks
pub microbreak_time_remaining: u64,
pub microbreak_active: bool,
pub microbreak_time_until_end: u64,
pub microbreak_total_duration: u64,
// F3: Pomodoro
pub pomodoro_cycle_position: u32,
pub pomodoro_is_long_break: bool,
// F2: Presentation mode
pub presentation_mode_active: bool,
pub deferred_break_pending: bool,
}
impl TimerManager {
@@ -92,6 +125,7 @@ impl TimerManager {
let freq = config.break_frequency_seconds();
let pending = config.clone();
let auto_start = config.auto_start;
let microbreak_freq = config.microbreak_frequency as u64 * 60;
Self {
state: if auto_start {
@@ -113,6 +147,17 @@ impl TimerManager {
idle_paused: false,
idle_start_time: None,
natural_break_occurred: false,
// F1: Microbreaks
microbreak_time_remaining: microbreak_freq,
microbreak_active: false,
microbreak_time_until_end: 0,
microbreak_total_duration: 0,
// F3: Pomodoro
pomodoro_cycle_position: 0,
pomodoro_is_long_break: false,
// F2: Presentation mode
presentation_mode_active: false,
deferred_break_pending: false,
}
}
@@ -173,6 +218,18 @@ impl TimerManager {
}
}
/// F2: Check if the foreground window is fullscreen (presentation mode)
pub fn check_presentation_mode(&mut self) -> bool {
if !self.config.presentation_mode_enabled {
self.presentation_mode_active = false;
return false;
}
let fs = is_foreground_fullscreen();
self.presentation_mode_active = fs;
fs
}
/// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
@@ -223,15 +280,18 @@ impl TimerManager {
TickResult::None
}
} else {
// F2: Check presentation mode before starting break
if self.check_presentation_mode() {
self.deferred_break_pending = true;
// Keep time at 0 so it triggers immediately when cleared
self.time_until_next_break = 0;
return TickResult::BreakDeferred;
}
// Clear any deferred state
self.deferred_break_pending = false;
self.start_break();
TickResult::BreakStarted(BreakStartedPayload {
title: self.config.break_title.clone(),
message: self.config.break_message.clone(),
duration: self.break_total_duration,
strict_mode: self.config.strict_mode,
snooze_duration: self.config.snooze_duration,
fullscreen_mode: self.config.fullscreen_mode,
})
TickResult::BreakStarted(self.make_break_payload())
}
}
TimerState::BreakActive => {
@@ -242,28 +302,139 @@ impl TimerManager {
// Break completed naturally
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
TickResult::BreakEnded
}
}
TimerState::Paused => TickResult::None,
TimerState::Paused => {
// F2: Check if deferred break can now proceed
if self.deferred_break_pending && !self.idle_paused {
if !self.check_presentation_mode() {
self.deferred_break_pending = false;
self.state = TimerState::Running;
self.start_break();
return TickResult::BreakStarted(self.make_break_payload());
}
}
TickResult::None
}
}
}
/// F1: Called every second for microbreak logic. Returns microbreak events.
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
if !self.config.microbreak_enabled {
return MicrobreakTickResult::None;
}
// Don't tick microbreaks during main breaks
if self.config.microbreak_pause_during_break && self.state == TimerState::BreakActive {
return MicrobreakTickResult::None;
}
// Don't tick when manually paused (but not during idle-pause which auto-resumes)
if self.state == TimerState::Paused && !self.idle_paused {
return MicrobreakTickResult::None;
}
// F2: Defer microbreaks during presentation mode
if self.config.presentation_mode_defer_microbreaks && self.presentation_mode_active {
return MicrobreakTickResult::None;
}
if self.microbreak_active {
// Counting down microbreak
if self.microbreak_time_until_end > 0 {
self.microbreak_time_until_end -= 1;
MicrobreakTickResult::None
} else {
// Microbreak ended
self.microbreak_active = false;
self.reset_microbreak_timer();
MicrobreakTickResult::MicrobreakEnded
}
} else {
// Counting down to next microbreak
if self.microbreak_time_remaining > 0 {
self.microbreak_time_remaining -= 1;
MicrobreakTickResult::None
} else {
// Start microbreak
self.microbreak_active = true;
self.microbreak_total_duration = self.config.microbreak_duration as u64;
self.microbreak_time_until_end = self.microbreak_total_duration;
MicrobreakTickResult::MicrobreakStarted
}
}
}
fn reset_microbreak_timer(&mut self) {
self.microbreak_time_remaining = self.config.microbreak_frequency as u64 * 60;
}
fn make_break_payload(&self) -> BreakStartedPayload {
BreakStartedPayload {
title: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_title.clone()
} else {
self.config.break_title.clone()
},
message: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_message.clone()
} else {
self.config.break_message.clone()
},
duration: self.break_total_duration,
strict_mode: self.config.strict_mode,
snooze_duration: self.config.snooze_duration,
fullscreen_mode: self.config.fullscreen_mode,
is_long_break: self.pomodoro_is_long_break,
}
}
pub fn start_break(&mut self) {
// F3: Determine if this should be a long break (Pomodoro)
if self.config.pomodoro_enabled {
// cycle_position counts from 0. Position == short_breaks means it's the long break.
self.pomodoro_is_long_break = self.pomodoro_cycle_position >= self.config.pomodoro_short_breaks;
} else {
self.pomodoro_is_long_break = false;
}
self.state = TimerState::BreakActive;
self.current_view = AppView::BreakScreen;
self.break_total_duration = self.config.break_duration_seconds();
if self.pomodoro_is_long_break {
self.break_total_duration = self.config.pomodoro_long_break_duration as u64 * 60;
} else {
self.break_total_duration = self.config.break_duration_seconds();
}
self.time_until_break_end = self.break_total_duration;
self.prebreak_notification_active = false;
self.snoozes_used = 0;
}
/// F3: Advance the Pomodoro cycle position after a break completes
fn advance_pomodoro_cycle(&mut self) {
if !self.config.pomodoro_enabled {
return;
}
if self.pomodoro_is_long_break {
// After long break, reset cycle
self.pomodoro_cycle_position = 0;
} else {
self.pomodoro_cycle_position += 1;
}
}
pub fn reset_timer(&mut self) {
self.state = TimerState::Running;
self.current_view = AppView::Dashboard;
self.time_until_next_break = self.config.break_frequency_seconds();
self.prebreak_notification_active = false;
self.pomodoro_is_long_break = false;
}
pub fn toggle_timer(&mut self) {
@@ -281,14 +452,7 @@ impl TimerManager {
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
if self.state == TimerState::Running || self.state == TimerState::Paused {
self.start_break();
Some(BreakStartedPayload {
title: self.config.break_title.clone(),
message: self.config.break_message.clone(),
duration: self.break_total_duration,
strict_mode: self.config.strict_mode,
snooze_duration: self.config.snooze_duration,
fullscreen_mode: self.config.fullscreen_mode,
})
Some(self.make_break_payload())
} else {
None
}
@@ -311,10 +475,15 @@ impl TimerManager {
// "End break" — counts as completed
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.advance_pomodoro_cycle();
self.reset_timer();
true
} else if !past_half {
// "Cancel break" — doesn't count
// F3: Pomodoro reset-on-skip
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
self.pomodoro_cycle_position = 0;
}
self.reset_timer();
true
} else {
@@ -420,6 +589,25 @@ impl TimerManager {
&self.config
};
// F5: Screen dim active when running and close to break
let dim_secs = self.config.screen_dim_seconds as u64;
let screen_dim_active = self.config.screen_dim_enabled
&& self.state == TimerState::Running
&& self.time_until_next_break <= dim_secs
&& self.time_until_next_break > 0
&& !self.deferred_break_pending;
let screen_dim_progress = if screen_dim_active && dim_secs > 0 {
1.0 - (self.time_until_next_break as f64 / dim_secs as f64)
} else {
0.0
};
// F3: Pomodoro info
let pomo_total = self.config.pomodoro_short_breaks + 1;
let pomo_next_is_long = self.config.pomodoro_enabled
&& self.pomodoro_cycle_position + 1 >= pomo_total
&& self.state != TimerState::BreakActive;
TimerSnapshot {
state: self.state,
current_view: self.current_view,
@@ -431,8 +619,16 @@ impl TimerManager {
prebreak_warning: self.prebreak_notification_active,
snoozes_used: self.snoozes_used,
can_snooze: self.can_snooze(),
break_title: display_config.break_title.clone(),
break_message: display_config.break_message.clone(),
break_title: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_title.clone()
} else {
display_config.break_title.clone()
},
break_message: if self.pomodoro_is_long_break {
self.config.pomodoro_long_break_message.clone()
} else {
display_config.break_message.clone()
},
break_progress,
break_time_remaining: self.time_until_break_end,
break_total_duration: self.break_total_duration,
@@ -442,6 +638,27 @@ impl TimerManager {
natural_break_occurred: self.natural_break_occurred,
smart_breaks_enabled: display_config.smart_breaks_enabled,
smart_break_threshold: display_config.smart_break_threshold,
// F1: Microbreaks
microbreak_enabled: self.config.microbreak_enabled,
microbreak_active: self.microbreak_active,
microbreak_time_remaining: self.microbreak_time_until_end,
microbreak_total_duration: self.microbreak_total_duration,
microbreak_countdown: self.microbreak_time_remaining,
microbreak_frequency: self.config.microbreak_frequency,
// F3: Pomodoro
pomodoro_enabled: self.config.pomodoro_enabled,
pomodoro_cycle_position: self.pomodoro_cycle_position,
pomodoro_total_in_cycle: pomo_total,
pomodoro_is_long_break: self.pomodoro_is_long_break,
pomodoro_next_is_long: pomo_next_is_long,
// F5: Screen dimming
screen_dim_active,
screen_dim_progress,
// F2: Presentation mode
presentation_mode_active: self.presentation_mode_active,
deferred_break_pending: self.deferred_break_pending,
// F10
is_long_break: self.pomodoro_is_long_break,
}
}
}
@@ -452,6 +669,14 @@ pub enum TickResult {
BreakEnded,
PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 },
BreakDeferred, // F2
}
/// F1: Microbreak tick result
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
pub fn get_idle_seconds() -> u64 {
0
}
/// F2: Check if the foreground window is a fullscreen application
#[cfg(windows)]
pub fn is_foreground_fullscreen() -> bool {
use std::mem;
use winapi::shared::windef::{HWND, RECT};
use winapi::um::winuser::{
GetForegroundWindow, GetWindowRect, MonitorFromWindow, GetMonitorInfoW,
MONITORINFO, MONITOR_DEFAULTTONEAREST,
};
unsafe {
let hwnd: HWND = GetForegroundWindow();
if hwnd.is_null() {
return false;
}
// Get the monitor this window is on
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if monitor.is_null() {
return false;
}
let mut mi: MONITORINFO = mem::zeroed();
mi.cbSize = mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) == 0 {
return false;
}
let mut wr: RECT = mem::zeroed();
if GetWindowRect(hwnd, &mut wr) == 0 {
return false;
}
// Check if window rect covers the monitor rect
let mr = mi.rcMonitor;
wr.left <= mr.left && wr.top <= mr.top && wr.right >= mr.right && wr.bottom >= mr.bottom
}
}
#[cfg(not(windows))]
pub fn is_foreground_fullscreen() -> bool {
false
}
/// F9: Get all monitor rects for multi-monitor break enforcement
#[cfg(windows)]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
use winapi::um::winuser::{EnumDisplayMonitors, GetMonitorInfoW, MONITORINFO};
unsafe extern "system" fn callback(
monitor: HMONITOR,
_hdc: HDC,
_rect: LPRECT,
data: isize,
) -> i32 {
let monitors = &mut *(data as *mut Vec<MonitorInfo>);
let mut mi: MONITORINFO = std::mem::zeroed();
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
if GetMonitorInfoW(monitor, &mut mi) != 0 {
let r = mi.rcMonitor;
monitors.push(MonitorInfo {
x: r.left,
y: r.top,
width: (r.right - r.left) as u32,
height: (r.bottom - r.top) as u32,
is_primary: (mi.dwFlags & 1) != 0, // MONITORINFOF_PRIMARY = 1
});
}
1 // continue enumeration
}
let mut monitors: Vec<MonitorInfo> = Vec::new();
unsafe {
EnumDisplayMonitors(
std::ptr::null_mut(),
std::ptr::null(),
Some(callback),
&mut monitors as *mut Vec<MonitorInfo> as isize,
);
}
monitors
}
#[cfg(not(windows))]
pub fn get_all_monitors() -> Vec<MonitorInfo> {
Vec::new()
}
#[derive(Debug, Clone)]
pub struct MonitorInfo {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub is_primary: bool,
}

View File

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

View File

@@ -12,6 +12,7 @@
import Settings from "./lib/components/Settings.svelte";
import StatsView from "./lib/components/StatsView.svelte";
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
import Celebration from "./lib/components/Celebration.svelte";
const appWindow = getCurrentWebviewWindow();
@@ -133,4 +134,5 @@
</div>
{/if}
</div>
<Celebration />
</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 { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
import BreathingGuide from "./BreathingGuide.svelte";
interface Props {
standalone?: boolean;
@@ -16,14 +17,14 @@
const appWindow = $derived(standalone ? getCurrentWebviewWindow() : null);
let currentActivity = $state<BreakActivity>(pickRandomActivity());
let currentActivity = $state<BreakActivity>(pickRandomActivity(undefined, $config));
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
// Cycle activity every 30 seconds during break
$effect(() => {
if ($config.show_break_activities && $timer.state === "breakActive") {
activityCycleTimer = setInterval(() => {
currentActivity = pickRandomActivity(currentActivity);
currentActivity = pickRandomActivity(currentActivity, $config);
}, 30_000);
}
return () => {
@@ -34,6 +35,9 @@
};
});
// F3: Long break indicator
const isLongBreak = $derived($timer.isLongBreak);
async function cancelBreak() {
const snap = await invoke<TimerSnapshot>("cancel_break");
timer.set(snap);
@@ -65,6 +69,32 @@
const showButtons = $derived(!$config.strict_mode);
// Breathing guide bindable state
let breathPhase = $state("Inhale");
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Map raw 0.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
const barGradient = $derived(
`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-3" style="--ripple-color: {$config.break_color}"></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
progress={breakRingProgress}
size={140}
@@ -114,13 +158,23 @@
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white text-[26px]"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.breakTimeRemaining)}
</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>
</TimerRing>
</div>
@@ -140,7 +194,7 @@
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</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}
</p>
</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>
<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
progress={breakRingProgress}
size={isModal ? 160 : 200}
@@ -218,7 +287,7 @@
label="Break timer"
valueText="{formatTime($timer.breakTimeRemaining)} remaining"
>
<div class="break-breathe-counter">
<div class="flex flex-col items-center">
<span
class="font-semibold leading-none tabular-nums text-white"
class:text-[30px]={isModal}
@@ -227,11 +296,33 @@
>
{formatTime($timer.breakTimeRemaining)}
</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>
</TimerRing>
</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 }}>
{$timer.breakTitle}
</h2>
@@ -253,7 +344,7 @@
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
{getCategoryLabel(currentActivity.category)}
</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}
</p>
</div>
@@ -411,23 +502,6 @@
background: rgba(255, 255, 255, 0.05);
}
/* ── Breathing pulse on the ring ── */
.break-breathe {
animation: breathe 4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.break-breathe-counter {
animation: breathe-counter 4s ease-in-out infinite;
}
@keyframes breathe-counter {
0%, 100% { transform: scale(1); }
50% { transform: scale(0.962); }
}
/* ── Ripple circles ── */
.break-ripple {
position: absolute;

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 TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
import { listen } from "@tauri-apps/api/event";
async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer");
@@ -28,15 +29,45 @@
}
const statusText = $derived(
$timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
: $timer.state === "running"
? "FOCUS"
: "PAUSED",
$timer.deferredBreakPending
? "DEFERRED"
: $timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
: $timer.state === "running"
? "FOCUS"
: "PAUSED",
);
// F1: Microbreak countdown
const microbreakCountdown = $derived(() => {
if (!$timer.microbreakEnabled || $timer.microbreakActive) return "";
const secs = $timer.microbreakCountdown;
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
});
// F10: Daily goal from stats
let dailyGoalProgress = $state(0);
let dailyGoalMet = $state(false);
// Load stats for daily goal display
async function loadGoalProgress() {
try {
const stats = await invoke<{ dailyGoalProgress: number; dailyGoalMet: boolean }>("get_stats");
dailyGoalProgress = stats.dailyGoalProgress;
dailyGoalMet = stats.dailyGoalMet;
} catch {}
}
$effect(() => {
// Reload goal progress on each tick (approximately)
const _state = $timer.state;
loadGoalProgress();
});
// Track status changes for aria-live region (announce only on change, not every tick)
let lastAnnouncedStatus = $state("");
let statusAnnouncement = $state("");
@@ -146,11 +177,74 @@
<!-- Status label -->
<span
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-[#fca311]={$timer.deferredBreakPending}
>
{statusText}
</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>
</TimerRing>
</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 countdownFont = $state("");
let draggable = $state(false);
let pomodoroEnabled = $state(false);
let pomodoroCyclePosition = $state(0);
let pomodoroTotalInCycle = $state(4);
// Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom);
@@ -132,6 +135,9 @@
timeText = formatTime(snap.timeRemaining);
progress = snap.progress;
}
pomodoroEnabled = snap.pomodoroEnabled;
pomodoroCyclePosition = snap.pomodoroCyclePosition;
pomodoroTotalInCycle = snap.pomodoroTotalInCycle;
}
// Click opens main window
@@ -333,6 +339,12 @@ const fontStyle = $derived(
>
{timeText}
</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>

View File

@@ -7,10 +7,36 @@
import ColorPicker from "./ColorPicker.svelte";
import FontSelector from "./FontSelector.svelte";
import TimeSpinner from "./TimeSpinner.svelte";
import ActivityManager from "./ActivityManager.svelte";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
import { playSound } from "../utils/sounds";
import type { TimeRange } from "../stores/config";
const breathingPatternMeta = [
{ id: "box", label: "Box", desc: "4s in \u00b7 4s hold \u00b7 4s out \u00b7 4s hold" },
{ id: "relaxing", label: "Relaxing", desc: "4s in \u00b7 7s hold \u00b7 8s out" },
{ id: "energizing", label: "Energizing", desc: "6s in \u00b7 2s hold \u00b7 6s out \u00b7 2s hold" },
{ id: "calm", label: "Calm", desc: "4s in \u00b7 4s hold \u00b7 6s out" },
{ id: "deep", label: "Deep", desc: "5s in \u00b7 5s out" },
] as const;
// F8: Auto-start on login
let autoStartEnabled = $state(false);
async function loadAutoStartStatus() {
try {
autoStartEnabled = await invoke<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 daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
@@ -131,11 +157,10 @@
use:dragScroll
>
<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 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Timer
</h3>
@@ -188,11 +213,137 @@
</div>
</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 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<h3 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
</h3>
@@ -261,13 +412,90 @@
onchange={markChanged}
/>
</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>
<!-- Behavior -->
<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"
>
<!-- 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 }}>
<h3 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
</h3>
@@ -357,114 +585,152 @@
</div>
</section>
<!-- Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<!-- 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.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-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 class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.working_hours_enabled}
label="Working hours"
bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged}
/>
</div>
{#if $config.working_hours_enabled}
{#if $config.notification_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 class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#8a8a8a]">
{$config.notification_before_break}s before
</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}
<Stepper
bind:value={$config.notification_before_break}
label="Alert timing"
min={0}
max={300}
step={10}
onchange={markChanged}
/>
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Screen dimming</div>
<div class="text-[11px] text-[#8a8a8a]">Gradually dim screen before breaks</div>
</div>
<ToggleSwitch bind:checked={$config.screen_dim_enabled} label="Screen dimming" onchange={markChanged} />
</div>
{#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}
/>
</div>
{/if}
</section>
<!-- Idle Detection -->
<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"
>
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.24 }}>
<h3 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);
}}
>
{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>
<div class="flex items-center">
@@ -500,19 +766,12 @@
/>
</div>
{/if}
</section>
<!-- Smart Breaks -->
<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="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<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>
<ToggleSwitch
@@ -561,119 +820,90 @@
{/if}
</section>
<!-- Notifications -->
<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"
>
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.30 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Presentation Mode
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#8a8a8a]">Warn before breaks</div>
<div class="text-[13px] text-white">Auto-detect fullscreen</div>
<div class="text-[11px] text-[#8a8a8a]">Defer breaks during fullscreen apps</div>
</div>
<ToggleSwitch
bind:checked={$config.notification_enabled}
label="Pre-break alert"
onchange={markChanged}
/>
<ToggleSwitch bind:checked={$config.presentation_mode_enabled} label="Presentation mode" 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]">
{$config.notification_before_break}s before
{#if $config.presentation_mode_enabled}
{#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">Defer microbreaks</div>
<div class="text-[11px] text-[#8a8a8a]">Also pause eye breaks</div>
</div>
<ToggleSwitch bind:checked={$config.presentation_mode_defer_microbreaks} label="Defer microbreaks" onchange={markChanged} />
</div>
<Stepper
bind:value={$config.notification_before_break}
label="Alert timing"
min={0}
max={300}
step={10}
onchange={markChanged}
/>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Notification</div>
<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>
{/if}
</section>
<!-- Sound -->
<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"
>
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.33 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Goals & Streaks
</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 class="text-[13px] text-white">Daily goal</div>
<div class="text-[11px] text-[#8a8a8a]">Track daily break target</div>
</div>
<ToggleSwitch
bind:checked={$config.sound_enabled}
label="Sound effects"
onchange={markChanged}
/>
<ToggleSwitch bind:checked={$config.daily_goal_enabled} label="Daily goal" onchange={markChanged} />
</div>
{#if $config.sound_enabled}
{#if $config.daily_goal_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-[#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 class="text-[13px] text-white">Target breaks</div>
<div class="text-[11px] text-[#8a8a8a]">{$config.daily_goal_breaks} per day</div>
</div>
<Stepper bind:value={$config.daily_goal_breaks} label="Daily goal breaks" min={1} max={30} onchange={markChanged} />
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<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 class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Streak notifications</div>
<div class="text-[11px] text-[#8a8a8a]">Toast on streak milestones</div>
</div>
<ToggleSwitch bind:checked={$config.streak_notifications} label="Streak notifications" onchange={markChanged} />
</div>
</section>
<!-- Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<!-- 13. Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Appearance
</h3>
@@ -741,11 +971,111 @@
</div>
</section>
<!-- Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<!-- 14. Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.39 }}>
<div class="flex items-center">
<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
</h3>
@@ -784,11 +1114,28 @@
{/if}
</section>
<!-- Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase"
>
<!-- 16. General -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.45 }}>
<h3 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
</h3>
@@ -808,8 +1155,8 @@
</div>
</section>
<!-- Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}>
<!-- 18. Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.51 }}>
<button
use:pressable
class="w-full rounded-full border py-3 text-[12px]
@@ -817,7 +1164,7 @@
transition-all duration-200
{resetConfirming
? '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}
>
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}

View File

@@ -9,9 +9,13 @@
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
todayNaturalBreaks: number;
todayNaturalBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
dailyGoalProgress: number;
dailyGoalMet: boolean;
}
interface DayRecord {
@@ -22,13 +26,27 @@
totalBreakTimeSecs: number;
}
interface WeekSummary {
weekStart: string;
totalCompleted: number;
totalSkipped: number;
totalBreakTimeSecs: number;
complianceRate: number;
avgDailyCompleted: number;
}
let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]);
let monthHistory = $state<DayRecord[]>([]);
let weeklySummaries = $state<WeekSummary[]>([]);
let activeTab = $state<"today" | "weekly" | "monthly">("today");
async function loadStats() {
try {
stats = await invoke<StatsSnapshot>("get_stats");
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) {
console.error("Failed to load stats:", e);
}
@@ -56,7 +74,21 @@
return `${hrs}h ${rem}m`;
});
// Chart rendering
// F10: Daily goal progress
const goalPercent = $derived(
$config.daily_goal_breaks > 0
? Math.min(100, Math.round(((stats?.dailyGoalProgress ?? 0) / $config.daily_goal_breaks) * 100))
: 0,
);
// F10: Next milestone
const milestones = [3, 5, 7, 14, 21, 30, 50, 100, 365];
const nextMilestone = $derived(() => {
const current = stats?.currentStreak ?? 0;
return milestones.find((m) => m > current) ?? null;
});
// Chart rendering — 7-day
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
@@ -64,6 +96,22 @@
drawChart(chartCanvas, history);
});
// Chart rendering — 30-day
let monthChartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!monthChartCanvas || monthHistory.length === 0) return;
drawChart(monthChartCanvas, monthHistory);
});
// Heatmap canvas
let heatmapCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!heatmapCanvas || monthHistory.length === 0) return;
drawHeatmap(heatmapCanvas, monthHistory);
});
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
@@ -78,8 +126,8 @@
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
const barWidth = Math.floor((w - 40) / data.length) - 8;
const barGap = 8;
const barWidth = Math.max(2, Math.floor((w - 40) / data.length) - (data.length > 10 ? 2 : 8));
const barGap = data.length > 10 ? 2 : 8;
const chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
@@ -90,34 +138,87 @@
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
// Completed bar
if (completedH > 0) {
ctx.fillStyle = accentColor;
ctx.beginPath();
const barY = chartHeight - completedH;
roundedRect(ctx, x, barY, barWidth, completedH, 4);
roundedRect(ctx, x, barY, barWidth, completedH, Math.min(4, barWidth / 2));
ctx.fill();
}
// Skipped bar (stacked on top)
if (skippedH > 0) {
ctx.fillStyle = "#333";
ctx.beginPath();
const barY = chartHeight - completedH - skippedH;
roundedRect(ctx, x, barY, barWidth, skippedH, 4);
roundedRect(ctx, x, barY, barWidth, skippedH, Math.min(4, barWidth / 2));
ctx.fill();
}
// Day label
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD"
ctx.fillText(label, x + barWidth / 2, h - 5);
// Day label — show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#8a8a8a";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5);
ctx.fillText(label, x + barWidth / 2, h - 5);
}
});
}
function drawHeatmap(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const cellSize = 16;
const gap = 2;
const cols = 7; // days of week
const rows = Math.ceil(data.length / cols);
const w = cols * (cellSize + gap) - gap;
const h = rows * (cellSize + gap) - gap;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted));
const accentColor = $config.accent_color || "#ff4d00";
// Parse accent color for intensity scaling
const r = parseInt(accentColor.slice(1, 3), 16);
const g = parseInt(accentColor.slice(3, 5), 16);
const b = parseInt(accentColor.slice(5, 7), 16);
data.forEach((day, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * (cellSize + gap);
const y = row * (cellSize + gap);
const intensity = day.breaksCompleted > 0
? Math.min(1, day.breaksCompleted / maxBreaks)
: 0;
if (intensity === 0) {
ctx.fillStyle = "#161616";
} else {
// Blend from dark (#161616 = 22,22,22) to accent color
const level = 0.2 + intensity * 0.8;
const cr = Math.round(22 + (r - 22) * level);
const cg = Math.round(22 + (g - 22) * level);
const cb = Math.round(22 + (b - 22) * level);
ctx.fillStyle = `rgb(${cr}, ${cg}, ${cb})`;
}
ctx.beginPath();
roundedRect(ctx, x, y, cellSize, cellSize, 3);
ctx.fill();
});
}
// Accessible chart summary
const chartAriaLabel = $derived(() => {
if (history.length === 0) return "No break history data available";
const total = history.reduce((sum, d) => sum + d.breaksCompleted, 0);
@@ -125,6 +226,15 @@
return `Weekly break chart: ${total} completed and ${skipped} skipped over ${history.length} days`;
});
// Monthly aggregations
const monthTotalCompleted = $derived(monthHistory.reduce((s, d) => s + d.breaksCompleted, 0));
const monthTotalSkipped = $derived(monthHistory.reduce((s, d) => s + d.breaksSkipped, 0));
const monthTotalTime = $derived(monthHistory.reduce((s, d) => s + d.totalBreakTimeSecs, 0));
const monthAvgCompliance = $derived(() => {
const total = monthTotalCompleted + monthTotalSkipped;
return total > 0 ? Math.round((monthTotalCompleted / total) * 100) : 100;
});
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
@@ -183,14 +293,30 @@
</h1>
</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 -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3">
{#if activeTab === "today"}
<!-- Today's summary -->
<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"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Today
</h3>
@@ -224,11 +350,42 @@
</div>
</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 -->
<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"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Streak
</h3>
@@ -253,13 +410,25 @@
{stats?.bestStreak ?? 0}
</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>
<!-- Weekly chart -->
<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"
>
<h3 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
Last 7 Days
</h3>
@@ -271,7 +440,6 @@
aria-label={chartAriaLabel()}
></canvas>
<!-- Screen-reader accessible data table for the chart -->
{#if history.length > 0}
<table class="sr-only">
<caption>Break history for the last {history.length} days</caption>
@@ -301,6 +469,142 @@
</div>
</div>
</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>

View File

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

View File

@@ -11,7 +11,13 @@ export interface DaySchedule {
ranges: TimeRange[];
}
export type { TimeRange, DaySchedule };
export interface CustomActivity {
id: string;
category: string;
text: string;
is_favorite: boolean;
enabled: boolean;
}
export interface Config {
break_duration: number;
@@ -24,7 +30,7 @@ export interface Config {
allow_end_early: boolean;
immediately_start_breaks: boolean;
working_hours_enabled: boolean;
working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
working_hours_schedule: DaySchedule[];
dark_mode: boolean;
color_scheme: string;
backdrop_opacity: number;
@@ -49,6 +55,45 @@ export interface Config {
background_blobs_enabled: boolean;
mini_click_through: boolean;
mini_hover_threshold: number;
// F8: Auto-start on login
auto_start_on_login: boolean;
// F6: Custom activities
custom_activities: CustomActivity[];
disabled_builtin_activities: string[];
favorite_builtin_activities: string[];
favorite_weight: number;
// F4: Breathing guide
breathing_guide_enabled: boolean;
breathing_pattern: string;
// F10: Gamification
daily_goal_enabled: boolean;
daily_goal_breaks: number;
milestone_celebrations: boolean;
streak_notifications: boolean;
// F1: Microbreaks
microbreak_enabled: boolean;
microbreak_frequency: number;
microbreak_duration: number;
microbreak_sound_enabled: boolean;
microbreak_show_activity: boolean;
microbreak_pause_during_break: boolean;
// F3: Pomodoro
pomodoro_enabled: boolean;
pomodoro_short_breaks: number;
pomodoro_long_break_duration: number;
pomodoro_long_break_title: string;
pomodoro_long_break_message: string;
pomodoro_reset_on_skip: boolean;
// F5: Screen dimming
screen_dim_enabled: boolean;
screen_dim_seconds: number;
screen_dim_max_opacity: number;
// F2: Presentation mode
presentation_mode_enabled: boolean;
presentation_mode_defer_microbreaks: boolean;
presentation_mode_notification: boolean;
// F9: Multi-monitor
multi_monitor_break: boolean;
}
const defaultConfig: Config = {
@@ -63,13 +108,13 @@ const defaultConfig: Config = {
immediately_start_breaks: false,
working_hours_enabled: false,
working_hours_schedule: [
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] },
],
dark_mode: true,
color_scheme: "Ocean",
@@ -95,6 +140,45 @@ const defaultConfig: Config = {
background_blobs_enabled: true,
mini_click_through: true,
mini_hover_threshold: 3.0,
// F8
auto_start_on_login: false,
// F6
custom_activities: [],
disabled_builtin_activities: [],
favorite_builtin_activities: [],
favorite_weight: 3,
// F4
breathing_guide_enabled: true,
breathing_pattern: "box",
// F10
daily_goal_enabled: true,
daily_goal_breaks: 8,
milestone_celebrations: true,
streak_notifications: true,
// F1
microbreak_enabled: false,
microbreak_frequency: 20,
microbreak_duration: 20,
microbreak_sound_enabled: true,
microbreak_show_activity: true,
microbreak_pause_during_break: true,
// F3
pomodoro_enabled: false,
pomodoro_short_breaks: 3,
pomodoro_long_break_duration: 15,
pomodoro_long_break_title: "Long break",
pomodoro_long_break_message: "Great work! Take a longer rest.",
pomodoro_reset_on_skip: false,
// F5
screen_dim_enabled: false,
screen_dim_seconds: 10,
screen_dim_max_opacity: 0.3,
// F2
presentation_mode_enabled: true,
presentation_mode_defer_microbreaks: true,
presentation_mode_notification: true,
// F9
multi_monitor_break: true,
};
export const config = writable<Config>(defaultConfig);

View File

@@ -26,6 +26,27 @@ export interface TimerSnapshot {
naturalBreakOccurred: boolean;
smartBreaksEnabled: boolean;
smartBreakThreshold: number;
// F1: Microbreaks
microbreakEnabled: boolean;
microbreakActive: boolean;
microbreakTimeRemaining: number;
microbreakTotalDuration: number;
microbreakCountdown: number;
microbreakFrequency: number;
// F3: Pomodoro
pomodoroEnabled: boolean;
pomodoroCyclePosition: number;
pomodoroTotalInCycle: number;
pomodoroIsLongBreak: boolean;
pomodoroNextIsLong: boolean;
// F5: Screen dimming
screenDimActive: boolean;
screenDimProgress: number;
// F2: Presentation mode
presentationModeActive: boolean;
deferredBreakPending: boolean;
// F10: Gamification
isLongBreak: boolean;
}
const defaultSnapshot: TimerSnapshot = {
@@ -50,6 +71,22 @@ const defaultSnapshot: TimerSnapshot = {
naturalBreakOccurred: false,
smartBreaksEnabled: true,
smartBreakThreshold: 300,
microbreakEnabled: false,
microbreakActive: false,
microbreakTimeRemaining: 0,
microbreakTotalDuration: 0,
microbreakCountdown: 0,
microbreakFrequency: 1200,
pomodoroEnabled: false,
pomodoroCyclePosition: 0,
pomodoroTotalInCycle: 4,
pomodoroIsLongBreak: false,
pomodoroNextIsLong: false,
screenDimActive: false,
screenDimProgress: 0,
presentationModeActive: false,
deferredBreakPending: false,
isLongBreak: false,
};
export const timer = writable<TimerSnapshot>(defaultSnapshot);
@@ -114,8 +151,43 @@ export async function initTimerStore() {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
}
});
// F1: Microbreak events
await listen("microbreak-started", () => {
const cfg = get(config);
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.4);
}
});
await listen("microbreak-ended", () => {
const cfg = get(config);
if (cfg.microbreak_sound_enabled && cfg.sound_enabled) {
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume * 0.3);
}
});
// F10: Milestone and daily goal events
await listen<{ streak: number }>("milestone-reached", (event) => {
milestoneEvent.set(event.payload.streak);
setTimeout(() => milestoneEvent.set(null), 4000);
});
await listen("daily-goal-met", () => {
dailyGoalEvent.set(true);
setTimeout(() => dailyGoalEvent.set(false), 4000);
});
// F2: Break deferred
await listen("break-deferred", () => {
// Dashboard will show deferred status from snapshot
});
}
// F10: Gamification event stores
export const milestoneEvent = writable<number | null>(null);
export const dailyGoalEvent = writable<boolean>(false);
// Helper: format seconds as MM:SS
export function formatTime(secs: number): string {
const m = Math.floor(secs / 60);

View File

@@ -106,10 +106,55 @@ export function getCategoryLabel(cat: BreakActivity["category"]): string {
return categoryLabels[cat];
}
/** Pick a random activity, optionally excluding a previous one */
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
const pool = exclude
? breakActivities.filter((a) => a.text !== exclude.text)
: breakActivities;
return pool[Math.floor(Math.random() * pool.length)];
/** Pick a random activity, optionally excluding a previous one.
* When config is provided, respects disabled/favorite/custom activity settings. */
export function pickRandomActivity(
exclude?: BreakActivity,
config?: {
disabled_builtin_activities?: string[];
favorite_builtin_activities?: string[];
custom_activities?: Array<{ category: string; text: string; is_favorite: boolean; enabled: boolean }>;
favorite_weight?: number;
},
): BreakActivity {
const disabled = new Set(config?.disabled_builtin_activities ?? []);
const favorites = new Set(config?.favorite_builtin_activities ?? []);
const weight = config?.favorite_weight ?? 3;
// Build pool: enabled builtins + enabled customs
let pool: BreakActivity[] = breakActivities.filter((a) => !disabled.has(a.text));
// Add enabled custom activities
if (config?.custom_activities) {
for (const ca of config.custom_activities) {
if (ca.enabled) {
const cat = (["eyes", "stretch", "breathing", "movement"].includes(ca.category)
? ca.category
: "movement") as BreakActivity["category"];
pool.push({ category: cat, text: ca.text });
}
}
}
// Exclude previous
if (exclude) {
pool = pool.filter((a) => a.text !== exclude.text);
}
if (pool.length === 0) {
return exclude ?? breakActivities[0];
}
// Build weighted pool: favorites appear `weight` times
const weighted: BreakActivity[] = [];
for (const a of pool) {
const isFav = favorites.has(a.text) ||
(config?.custom_activities?.some((c) => c.text === a.text && c.is_favorite) ?? false);
const count = isFav ? weight : 1;
for (let i = 0; i < count; i++) {
weighted.push(a);
}
}
return weighted[Math.floor(Math.random() * weighted.length)];
}

View File

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