28 Commits

Author SHA1 Message Date
lashman 241d263d13 tweak contributing section wording 2026-03-13 14:15:37 +02:00
lashman 8c6f2b57a8 strip unicode dashes, trim restating doc comments, untrack forbidden files 2026-03-13 14:05:59 +02:00
lashman 4fdbec9b0f v0.2.0: WCAG 2.2 AAA accessibility + toggle fix + version bump 2026-02-18 19:18:15 +02:00
lashman ee7fe87663 build.rs cfg() checks HOST not TARGET -- use CARGO_CFG_TARGET_ENV 2026-02-18 19:18:15 +02:00
lashman 91a2f7f637 a11y: final cleanup -- remaining hardcoded colors 2026-02-18 19:18:15 +02:00
lashman e66c366ee4 a11y: Tasks 9,13-17 -- Settings ARIA, MiniTimer, overlays, ColorPicker, ActivityManager 2026-02-18 19:18:15 +02:00
lashman f74ab93d3b a11y: Tasks 7-12 -- Dashboard, Settings, StatsView, BreakScreen, Celebration 2026-02-18 19:18:15 +02:00
lashman 1f11214864 a11y: Tasks 2-6 -- App shell, Titlebar, ToggleSwitch, Stepper, animate.ts 2026-02-18 19:18:15 +02:00
lashman 014fa942e3 a11y: update theme tokens and global styles for WCAG AAA 2026-02-18 19:18:15 +02:00
lashman 6f8f731d70 Update README.md 2026-02-07 13:27:24 +00:00
lashman aa8cf4202d Add emojis to all top-level README section headers 2026-02-07 15:24:03 +02:00
lashman 3a2b76c0e8 Add emojis to feature section headers in README 2026-02-07 15:22:11 +02:00
lashman 96cf3b2a86 Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager 2026-02-07 15:11:44 +02:00
lashman 8367cff9e1 Bump version to 0.1.2 2026-02-07 12:16:03 +02:00
lashman 8f7da8cd4a Add WCAG 2.1 Level AA accessibility across all components 2026-02-07 12:13:03 +02:00
lashman 9b9147c737 Update README.md 2026-02-07 09:35:24 +00:00
lashman deabbb6900 Update README.md 2026-02-07 09:34:36 +00:00
lashman 05664e705a Update README - fix download links, activity count, and accuracy 2026-02-07 11:24:16 +02:00
lashman 5c65a9f90e Bump version to 0.1.1 2026-02-07 11:16:29 +02:00
lashman d74961eedd Tighten TimeSpinner spacing and fix build warnings 2026-02-07 11:16:29 +02:00
lashman 0b1f1267e3 Enable custom-protocol for embedded frontend assets 2026-02-07 11:00:45 +02:00
lashman 80a00c9d87 Fix WebView2 detection - use loader API instead of registry 2026-02-07 10:51:49 +02:00
lashman 4c275f5185 Statically link WebView2Loader - single exe, no DLL needed 2026-02-07 02:32:54 +02:00
lashman 34667d89c4 Detect missing WebView2 at startup, show download link 2026-02-07 02:08:51 +02:00
lashman 5507dde439 Fix fullscreen break screen - centering, progress bar, ripple sizing 2026-02-07 01:56:10 +02:00
lashman 0af3c9ca2e Add screenshots, move philosophy section above features 2026-02-07 01:36:43 +02:00
lashman 6660752aee Revamp README - emojis, better formatting, cleaner layout 2026-02-07 01:18:48 +02:00
lashman 590b8e1a74 Initial commit -- Core Cooldown v0.1.0 2026-02-07 01:12:49 +02:00
38 changed files with 4505 additions and 902 deletions
+340 -107
View File
@@ -21,7 +21,7 @@
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte&logoColor=white" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust&logoColor=white" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white" alt="Tailwind v4" />
<img src="https://img.shields.io/badge/WCAG_2.1-AA-228B22?style=flat-square" alt="WCAG 2.1 AA" />
<img src="https://img.shields.io/badge/WCAG_2.2-AAA-228B22?style=flat-square&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA" />
</p>
<p align="center">
@@ -32,7 +32,7 @@
---
## Why does this exist?
## 💡 Why does this exist?
Repetitive strain injury and eye strain are not personal failings. They are the predictable result of systems that treat human attention as an extractable resource. Every person at a screen deserves a tool that gently interrupts the grind - one that serves *them*, not a subscription model, not an analytics dashboard, not a corporate wellness KPI.
@@ -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 />
@@ -98,7 +98,7 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
---
## Features
## Features
### ⏱️ Timer & Breaks
@@ -107,16 +107,55 @@ Tools for human wellbeing should never be enclosed, never be scarce, and never s
| 🔄 | **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,6 +190,33 @@ 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 />
### 🏆 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
@@ -188,11 +265,11 @@ 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 />
@@ -200,10 +277,10 @@ Works system-wide, even when Core Cooldown is not focused.
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 />
@@ -211,49 +288,84 @@ A compact floating timer (200x50px) that sits on top of your other windows.
| 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
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
- **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
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.
<p>
<img src="https://img.shields.io/badge/WCAG_2.2-AAA_Conformance-228B22?style=for-the-badge&logo=w3c&logoColor=white" alt="WCAG 2.2 AAA Conformance" />
<img src="https://img.shields.io/badge/since-v0.2.0-blue?style=for-the-badge" alt="Since v0.2.0" />
</p>
| | 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. |
| 🔍 | **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) |
| 🖥️ | **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 |
Core Cooldown targets **WCAG 2.2 Level AAA** conformance - the highest level of the Web Content Accessibility Guidelines. A break timer for preventing repetitive strain injury should be usable by everyone, including those who already live with disabilities.
> **Why AAA?** Most applications stop at AA. We went further because the people who benefit most from a break timer - those with repetitive strain injuries, chronic pain, or vision impairments - are the same people who need the strongest accessibility support. AAA isn't a checkbox. It's the right thing to do.
#### Contrast & Visual Design
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 🎨 | **Enhanced text contrast** | AAA 7:1 | All body text meets 7:1 contrast ratio against dark backgrounds. Secondary text `#a8a8a8` on `#000` = 7.28:1. |
| 🔤 | **Large text contrast** | AAA 4.5:1 | Headings and large text (18px+) meet 4.5:1 minimum. Timer countdown, break titles validated. |
| 🎯 | **Non-text contrast** | AA 3:1 | UI components (toggles, steppers, rings, chart bars, color swatches) all meet 3:1 against adjacent colors. |
| 🖥️ | **Windows High Contrast** | AAA | `forced-colors: active` maps all theme tokens to system colors. Full usability in all Windows contrast themes. |
| 🐢 | **Reduced motion** | AAA | `prefers-reduced-motion` disables all CSS animations, JS Web Animations API effects, and momentum scroll. Zero functionality loss. |
#### Keyboard & Navigation
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| ⌨️ | **Full keyboard access** | AAA 2.1.3 | Every control operable via keyboard alone - no exceptions. Arrow keys for color pickers, steppers, radio groups. Tab/Shift+Tab cycles all interactive elements. |
| 🔍 | **Visible focus indicators** | AAA 2.4.13 | 2px solid white outline with dark shadow fallback on every interactive element. No hidden or suppressed focus rings. |
| ⏭️ | **Skip navigation** | AA 2.4.1 | Skip-to-content link bypasses the titlebar on Tab. |
| 🏠 | **Focus management** | AA 2.4.3 | View transitions auto-focus the new view's heading. Break screen traps focus. Dropdown focus returns to trigger on close. |
| 🏷️ | **Heading structure** | AAA 2.4.10 | Semantic `h1` > `h2` hierarchy across all views. Settings sections use `h2` with `aria-labelledby`. |
#### Screen Readers & Assistive Technology
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 🗣️ | **Live regions** | AA 4.1.3 | `aria-live` announces timer state, breathing phase, break activities, status changes, and celebration events. |
| 📊 | **Semantic roles** | AA 4.1.2 | `progressbar` on timer rings, `switch` on toggles, `tablist`/`tab`/`tabpanel` on stats view, `radiogroup`/`radio` on breathing patterns, `alertdialog` on overlays. |
| 📋 | **Data tables** | A 1.3.1 | Screen-reader-only data tables behind the 7-day chart provide the same information non-visually. |
| 🏷️ | **Accessible names** | AA 4.1.2 | Every toggle, stepper, button, swatch, and form control has a descriptive `aria-label` or visible label. Sound presets use `aria-pressed`. |
| 📝 | **Page titles** | AAA 2.4.2 | Dynamic `document.title` updates per view ("Core Cooldown - Dashboard", "- Settings", etc.). |
#### Target Sizes & Interaction
| | Feature | Standard | Description |
|:--|:--------|:---------|:------------|
| 👆 | **44px touch targets** | AAA 2.5.8 | All interactive elements (buttons, toggles, steppers, color swatches, titlebar controls) have minimum 44x44px hit areas. Visual size may be smaller - the clickable area extends invisibly. |
| 🎯 | **Celebration persistence** | - | Milestone/goal popups stay visible on hover or focus, with keyboard-accessible dismiss buttons and Escape key support. |
| ⏱️ | **Hold-to-repeat** | - | Stepper +/- buttons support press-and-hold for continuous increment, with keyboard Enter/Space triggering the same behavior. |
<br />
@@ -264,11 +376,11 @@ Core Cooldown targets **WCAG 2.1 Level AA** compliance. A break timer for preven
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
@@ -291,13 +403,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>
@@ -371,7 +483,7 @@ A split-architecture desktop app: Rust backend for system integration and timer
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────┬───────┴──────────────────┘
│ └─────────┬───────┴──────────────────┘ │
│ │ Tauri IPC │
│ ┌─────────┴─────────┐ │
│ │ Rust Backend │ │
@@ -393,10 +505,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 +521,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>
@@ -435,47 +548,161 @@ A split-architecture desktop app: Rust backend for system integration and timer
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>
@@ -527,12 +754,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.2 AAA foundation is in place - help us maintain it)
- 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 +767,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 +791,13 @@ 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>
<p align="center">
<sub>
<em>This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.</em>
</sub>
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "core-cooldown",
"private": true,
"version": "0.1.2",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -480,7 +480,7 @@ dependencies = [
[[package]]
name = "core-cooldown"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"anyhow",
"chrono",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "core-cooldown"
version = "0.1.2"
version = "0.2.0"
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"] }
+57 -4
View File
@@ -8,18 +8,71 @@ fn main() {
}
// On GNU targets, replace the WebView2Loader import library with the static
// library so the loader is baked into the exe - no DLL to ship.
#[cfg(target_env = "gnu")]
// library so the loader is baked into the exe -- no DLL to ship.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
swap_webview2_to_static();
}
tauri_build::build()
tauri_build::build();
// When targeting GNU, embed-resource may find MSVC's rc.exe (via Windows
// SDK) and produce a .res file instead of COFF .o. GNU ld can't link .res
// files. Fix: re-compile with windres if needed.
// Note: cfg() in build scripts checks the HOST, not target. Use env var.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") {
fix_resource_lib();
}
}
/// If resource.lib is in .res format (produced by MSVC rc.exe), re-compile
/// the .rc source with MinGW windres to produce a COFF object that GNU ld
/// can link.
fn fix_resource_lib() {
use std::path::PathBuf;
use std::process::Command;
let out_dir = std::env::var("OUT_DIR").unwrap_or_default();
let out_path = PathBuf::from(&out_dir);
let rc_file = out_path.join("resource.rc");
let lib_file = out_path.join("resource.lib");
if !rc_file.exists() || !lib_file.exists() {
return;
}
// Check if the file is already COFF (starts with COFF machine type or
// archive signature). A .res file starts with 0x00000000.
if let Ok(header) = std::fs::read(&lib_file) {
if header.len() >= 4 && header[0..4] == [0, 0, 0, 0] {
// This is a .res file, not COFF -- re-compile with windres
let windres = "C:/Users/lashman/mingw-w64/mingw64/bin/windres.exe";
let status = Command::new(windres)
.args([
"-i", &rc_file.to_string_lossy(),
"-o", &lib_file.to_string_lossy(),
"--output-format=coff",
])
.status();
match status {
Ok(s) if s.success() => {
println!("cargo:warning=Re-compiled resource.rc with windres (COFF output)");
}
Ok(s) => {
println!("cargo:warning=windres failed with exit code: {}", s);
}
Err(e) => {
println!("cargo:warning=Failed to run windres: {}", e);
}
}
}
}
}
/// Replace `WebView2Loader.dll.lib` (dynamic import lib) with the contents of
/// `WebView2LoaderStatic.lib` (static archive) in the webview2-com-sys build
/// output. The linker then statically links the WebView2 loader code, removing
/// the runtime dependency on WebView2Loader.dll.
#[cfg(target_env = "gnu")]
fn swap_webview2_to_static() {
use std::fs;
use std::path::PathBuf;
+1 -1
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",
+144 -14
View File
@@ -3,7 +3,15 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// A single time range (e.g., 09:00 to 17:00)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomActivity {
pub id: String,
pub category: String,
pub text: String,
pub is_favorite: bool,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: String, // Format: "HH:MM"
@@ -19,7 +27,6 @@ impl Default for TimeRange {
}
}
/// Schedule for a single day
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaySchedule {
pub enabled: bool,
@@ -36,7 +43,6 @@ impl Default for DaySchedule {
}
impl DaySchedule {
/// Create a default schedule for weekend days (disabled by default)
fn weekend_default() -> Self {
Self {
enabled: false,
@@ -107,6 +113,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 +240,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,
@@ -198,14 +300,13 @@ impl Default for Config {
}
impl Config {
/// Get the path to the config file (portable: next to the exe)
// Portable: next to the exe
fn config_path() -> Result<PathBuf> {
let exe_path = std::env::current_exe().context("Failed to determine exe path")?;
let exe_dir = exe_path.parent().context("Failed to determine exe directory")?;
Ok(exe_dir.join("config.json"))
}
/// Load configuration from file, or return default if it doesn't exist
pub fn load_or_default() -> Self {
match Self::load() {
Ok(config) => config,
@@ -221,7 +322,6 @@ impl Config {
}
}
/// Load configuration from file
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
@@ -239,7 +339,6 @@ impl Config {
Ok(config.validate())
}
/// Save configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
@@ -251,7 +350,6 @@ impl Config {
Ok(())
}
/// Validate and sanitize configuration values
pub fn validate(mut self) -> Self {
// Break duration: 1-60 minutes
self.break_duration = self.break_duration.clamp(1, 60);
@@ -349,6 +447,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();
@@ -367,7 +503,6 @@ impl Config {
self
}
/// Check if a time string is in valid HH:MM format
fn is_valid_time_format(time: &str) -> bool {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 {
@@ -392,29 +527,24 @@ impl Config {
hours * 60 + minutes
}
/// Check if a string is a valid hex color (#RRGGBB)
fn is_valid_hex_color(color: &str) -> bool {
color.len() == 7
&& color.starts_with('#')
&& color[1..].chars().all(|c| c.is_ascii_hexdigit())
}
/// Get break duration in seconds
pub fn break_duration_seconds(&self) -> u64 {
self.break_duration as u64 * 60
}
/// Get break frequency in seconds
pub fn break_frequency_seconds(&self) -> u64 {
self.break_frequency as u64 * 60
}
/// Get snooze duration in seconds
pub fn snooze_duration_seconds(&self) -> u64 {
self.snooze_duration as u64 * 60
}
/// Reset to default values
pub fn reset_to_default(&mut self) {
*self = Self::default();
}
+392 -24
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]
@@ -184,7 +310,6 @@ fn save_window_position(
// ── Dynamic Tray Icon Rendering ────────────────────────────────────────────
/// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input.
fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
if hex.len() == 7 && hex.starts_with('#') {
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
@@ -196,13 +321,13 @@ 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.
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 +366,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,37 +396,40 @@ 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 => {
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)
format!("Core Cooldown -- {:02}:{:02} until break", m, s)
}
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown - Paused (idle)".to_string()
"Core Cooldown -- Paused (idle)".to_string()
} else {
"Core Cooldown - Paused".to_string()
"Core Cooldown -- Paused".to_string()
}
}
timer::TimerState::BreakActive => {
let m = snapshot.break_time_remaining / 60;
let s = snapshot.break_time_remaining % 60;
format!("Core Cooldown - Break {:02}:{:02}", m, s)
format!("Core Cooldown -- Break {:02}:{:02}", m, s)
}
};
let _ = tray.set_tooltip(Some(&tooltip));
// Update icon
let (progress, is_break, is_paused) = match snapshot.state {
timer::TimerState::Running => (snapshot.progress, false, false),
timer::TimerState::Paused => (snapshot.progress, false, true),
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 +519,131 @@ 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) => {
handle_break_start(&handle, payload.fullscreen_mode);
let _ = handle.emit("break-started", &payload);
// Close dim overlay if it was open
if dim_window_open {
close_dim_overlay(&handle);
dim_window_open = false;
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// Record completed break in stats
// 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 => {
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 +704,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 +749,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,
])
@@ -617,7 +876,6 @@ fn close_break_window(app: &AppHandle) {
}
}
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
if fullscreen_mode {
// Fullscreen: show break inside the main window
@@ -633,7 +891,6 @@ fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
}
}
/// Handle break end: restore main window state and close break window if open.
fn handle_break_end(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(false);
@@ -688,3 +945,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();
}
}
}
+8 -8
View File
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicI32, Ordering};
// ── MSVC Buffer Security Check (/GS) ────────────────────────────────────────
//
// MSVC's /GS flag instruments functions with stack canaries. These two symbols
// implement the canary check. The cookie value is arbitrary - real MSVC CRT
// implement the canary check. The cookie value is arbitrary -- real MSVC CRT
// randomises it at startup, but for a statically-linked helper library this
// fixed sentinel is sufficient.
@@ -44,15 +44,15 @@ pub unsafe extern "C" fn _Init_thread_header(guard: *mut i32) {
loop {
let val = guard.read_volatile();
if val == 0 {
// Already initialised - tell caller to skip
// Already initialised -- tell caller to skip
return;
}
if val == -1 {
// Not yet initialised - try to claim it
// Not yet initialised -- try to claim it
guard.write_volatile(1);
return;
}
// val == 1: another thread is initialising - yield and retry
// val == 1: another thread is initialising -- yield and retry
std::thread::yield_now();
}
}
@@ -71,22 +71,22 @@ pub unsafe extern "C" fn _Init_thread_footer(guard: *mut i32) {
// MinGW's libstdc++ exports the same operators but with GCC/Itanium mangling,
// so the linker can't match them. We provide the MSVC-mangled versions here.
/// `std::nothrow` - MSVC-mangled `?nothrow@std@@3Unothrow_t@1@B`
/// `std::nothrow` -- MSVC-mangled `?nothrow@std@@3Unothrow_t@1@B`
/// An empty struct constant used as a tag for nothrow `new`.
#[export_name = "?nothrow@std@@3Unothrow_t@1@B"]
pub static MSVC_STD_NOTHROW: u8 = 0;
/// `operator new(size_t, const std::nothrow_t&)` - nothrow allocation
/// `operator new(size_t, const std::nothrow_t&)` -- nothrow allocation
/// MSVC-mangled: `??2@YAPEAX_KAEBUnothrow_t@std@@@Z`
#[export_name = "??2@YAPEAX_KAEBUnothrow_t@std@@@Z"]
pub unsafe extern "C" fn msvc_operator_new_nothrow(size: usize, _nothrow: *const u8) -> *mut u8 {
let size = if size == 0 { 1 } else { size };
let layout = std::alloc::Layout::from_size_align_unchecked(size, 8);
let ptr = std::alloc::alloc(layout);
ptr // null on failure - nothrow semantics
ptr // null on failure -- nothrow semantics
}
/// `operator delete(void*, size_t)` - sized deallocation
/// `operator delete(void*, size_t)` -- sized deallocation
/// MSVC-mangled: `??3@YAXPEAX_K@Z`
#[export_name = "??3@YAXPEAX_K@Z"]
pub unsafe extern "C" fn msvc_operator_delete_sized(ptr: *mut u8, size: usize) {
+87 -8
View File
@@ -3,7 +3,6 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// A single day's break statistics.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DayRecord {
@@ -16,7 +15,6 @@ pub struct DayRecord {
pub natural_break_time_secs: u64,
}
/// Persistent stats stored as JSON.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StatsData {
pub days: HashMap<String, DayRecord>,
@@ -24,12 +22,10 @@ pub struct StatsData {
pub best_streak: u32,
}
/// Runtime stats manager.
pub struct Stats {
pub data: StatsData,
}
/// Snapshot sent to the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsSnapshot {
@@ -42,10 +38,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,
}
pub struct BreakCompletedResult {
pub milestone_reached: Option<u32>,
pub daily_goal_just_met: bool,
}
#[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
// Portable: next to the exe
fn stats_path() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
@@ -91,12 +108,22 @@ impl Stats {
})
}
pub fn record_break_completed(&mut self, duration_secs: u64) {
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 +175,16 @@ impl Stats {
}
}
pub fn snapshot(&self) -> StatsSnapshot {
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,10 +212,11 @@ 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,
}
}
/// Get recent N days of history, sorted chronologically.
pub fn recent_days(&self, n: u32) -> Vec<DayRecord> {
let today = chrono::Local::now().date_naive();
let mut records = Vec::new();
@@ -196,4 +233,46 @@ impl Stats {
records
}
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
}
}
+338 -28
View File
@@ -26,7 +26,6 @@ pub enum AppView {
Stats,
}
/// Snapshot of the full timer state, sent to the frontend on every tick
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimerSnapshot {
@@ -51,9 +50,29 @@ 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
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BreakStartedPayload {
@@ -63,6 +82,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 +104,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 +123,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,11 +145,20 @@ 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,
}
}
/// Check idle state and auto-pause/resume accordingly.
/// Returns IdleCheckResult indicating what happened.
pub fn check_idle(&mut self) -> IdleCheckResult {
if !self.config.idle_detection_enabled {
// If idle detection disabled but we were idle-paused, resume
@@ -173,7 +214,17 @@ impl TimerManager {
}
}
/// Called every second. Returns what events should be emitted.
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
}
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
let idle_result = self.check_idle();
@@ -223,15 +274,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 +296,137 @@ 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
}
}
}
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;
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;
}
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 +444,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
}
@@ -308,13 +464,18 @@ impl TimerManager {
let past_half = total > 0 && elapsed * 2 >= total;
if past_half && self.config.allow_end_early {
// "End break" - counts as completed
// "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
// "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 +581,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 +611,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 +630,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,9 +661,15 @@ pub enum TickResult {
BreakEnded,
PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 },
BreakDeferred, // F2
}
pub enum MicrobreakTickResult {
None,
MicrobreakStarted,
MicrobreakEnded,
}
/// Result of checking idle state
pub enum IdleCheckResult {
None,
JustPaused,
@@ -462,7 +677,6 @@ pub enum IdleCheckResult {
NaturalBreakDetected(u64), // duration in seconds
}
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)]
pub fn get_idle_seconds() -> u64 {
use std::mem;
@@ -486,3 +700,99 @@ pub fn get_idle_seconds() -> u64 {
pub fn get_idle_seconds() -> u64 {
0
}
#[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
}
#[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,
}
+1 -1
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.2.0",
"identifier": "com.corecooldown.app",
"build": {
"frontendDist": "../dist",
+16 -1
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();
@@ -61,7 +62,7 @@
return () => mq.removeEventListener("change", handler);
});
// Transition parameters - zero when reduced motion active
// Transition parameters -- zero when reduced motion active
const DURATION = $derived(reducedMotion ? 0 : 700);
const easing = cubicOut;
@@ -74,6 +75,17 @@
});
});
// WCAG 2.4.2: Document title reflects current view
$effect(() => {
const viewNames: Record<string, string> = {
dashboard: "Dashboard",
breakScreen: "Break",
settings: "Settings",
stats: "Statistics",
};
document.title = `Core Cooldown -- ${viewNames[effectiveView] ?? "Dashboard"}`;
});
// When fullscreen_mode is OFF, the separate break window handles breaks,
// so the main window should keep showing whatever view it was on (dashboard).
const effectiveView = $derived(
@@ -84,11 +96,13 @@
</script>
<main class="relative h-full bg-black">
<a href="#main-content" class="skip-link">Skip to content</a>
{#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if}
<Titlebar />
<div
id="main-content"
class="relative h-full overflow-hidden origin-top-left"
style="
transform: scale({zoomScale});
@@ -133,4 +147,5 @@
</div>
{/if}
</div>
<Celebration />
</main>
+32 -4
View File
@@ -5,17 +5,19 @@
--color-surface: #0e0e0e;
--color-card: #141414;
--color-card-lt: #1c1c1c;
--color-border: #222222;
--color-border: #3a3a3a;
--color-accent: #ff4d00;
--color-accent-lt: #ff7733;
--color-accent-dim: #ff4d0018;
--color-accent-glow: #ff4d0040;
--color-success: #3fb950;
--color-warning: #f0a500;
--color-danger: #f85149;
--color-danger: #ff6b6b;
--color-text-pri: #ffffff;
--color-text-sec: #8a8a8a;
--color-text-dim: #3a3a3a;
--color-text-sec: #a8a8a8;
--color-text-dim: #5c5c5c;
--color-input-border: #444444;
--color-surface-lt: #1e1e1e;
--color-caption-bg: #050505;
}
@@ -30,6 +32,11 @@ body {
Arial, sans-serif;
overflow: hidden;
height: 100%;
line-height: 1.625;
}
[data-tauri-drag-region],
[data-tauri-drag-region] * {
user-select: none;
-webkit-user-select: none;
}
@@ -69,10 +76,31 @@ body {
border-width: 0;
}
/* ── Accessibility: Skip link ── */
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
padding: 8px 16px;
background: var(--color-accent);
color: #000;
font-size: 13px;
font-weight: 600;
border-radius: 0 0 8px 8px;
text-decoration: none;
transition: top 0.15s ease;
}
.skip-link:focus {
top: 0;
}
/* ── Accessibility: Focus indicators ── */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.3);
}
/* ── Accessibility: Reduced motion ── */
+581
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-text-sec
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-border 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-text-sec 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-text-sec
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-text-sec 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-text-sec' : 'text-text-sec 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-text-sec 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 w-9 h-9 min-w-[44px] min-h-[44px] 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-text-sec line-through'}">
{activity.text}
</span>
<button
class="flex items-center justify-center w-9 h-9 min-w-[44px] min-h-[44px] text-text-sec hover:text-[#ff6b6b] 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-text-sec 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 w-9 h-9 min-w-[44px] min-h-[44px] 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-text-sec' : 'text-text-sec 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>
+59
View File
@@ -0,0 +1,59 @@
<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
role="alertdialog"
aria-label="Break in progress"
class="fixed inset-0 flex flex-col items-center justify-center"
style="background: rgba(0, 0, 0, {$config.backdrop_opacity});"
>
<h2 class="sr-only">Break in Progress</h2>
<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>
+131 -35
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,44 @@
const showButtons = $derived(!$config.strict_mode);
// Breathing guide bindable state
let breathPhase = $state("Inhale");
let breathCountdown = $state(4);
let breathScale = $state(0.6);
// Only announce phase name changes (not countdown ticks) to screen readers
let breathAnnouncement = $state("");
let lastBreathPhase = $state("");
$effect(() => {
// Extract just the phase name (e.g., "Inhale" from "Inhale 4")
const phaseName = breathPhase?.split(' ')[0] ?? "";
if (phaseName && phaseName !== lastBreathPhase) {
lastBreathPhase = phaseName;
breathAnnouncement = phaseName;
}
});
// Map raw 0.6-1.0 scale to 0.9-1.6 range for visible breathing text
const textScale = $derived(0.9 + (breathScale - 0.6) * (0.7 / 0.4));
// Interpolate color between break_color (inhale) and accent_color (exhale)
// breathScale: 0.6 = exhale (accent), 1.0 = inhale (break)
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function lerpColor(c1: string, c2: string, t: number): string {
const [r1, g1, b1] = hexToRgb(c1);
const [r2, g2, b2] = hexToRgb(c2);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return `rgb(${r},${g},${b})`;
}
// t=0 at exhale (scale=0.6), t=1 at inhale (scale=1.0)
const breathT = $derived((breathScale - 0.6) / 0.4);
const breathColor = $derived(lerpColor($config.accent_color, $config.break_color, breathT));
// Bottom progress bar uses a gradient from break color to accent
const barGradient = $derived(
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
@@ -105,7 +147,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 +170,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-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</span>
{/if}
</div>
</TimerRing>
</div>
@@ -131,16 +197,16 @@
<h2 class="text-[17px] font-medium text-white mb-1.5" tabindex="-1">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#8a8a8a] mb-4 max-w-[240px]">
<p class="text-[12px] leading-relaxed text-text-sec mb-4 max-w-[240px]">
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
<div class="text-[9px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase mb-1">
<div class="text-[9px] font-medium tracking-[0.15em] text-text-sec 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-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -151,7 +217,7 @@
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#8a8a8a] uppercase
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -172,10 +238,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#8a8a8a]">
<p class="mt-2 text-[9px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
</div>
@@ -209,7 +280,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 +304,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,17 +313,39 @@
>
{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-hidden="true"
>
{breathPhase}{breathCountdown > 0 ? ` ${breathCountdown}s` : ""}
</span>
<span class="sr-only" aria-live="polite" aria-atomic="true">{breathAnnouncement}</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>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#8a8a8a]"
class="max-w-[300px] text-center text-[13px] leading-relaxed text-text-sec"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
@@ -250,10 +358,10 @@
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
use:fadeIn={{ delay: 0.4, y: 10 }}
>
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#8a8a8a] uppercase">
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-text-sec uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]" aria-live="polite">
<p class="text-[13px] leading-relaxed text-text-sec" aria-live="polite">
{currentActivity.text}
</p>
</div>
@@ -263,8 +371,8 @@
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
<button
use:pressable
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
tracking-wider text-[#8a8a8a] uppercase
class="rounded-full border border-border px-6 py-2.5 text-[12px]
tracking-wider text-text-sec uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#ccc]"
onclick={cancelBreak}
@@ -285,10 +393,15 @@
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#8a8a8a]">
<p class="mt-3 text-[10px] text-text-sec">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<span tabindex="0" class="sr-only" aria-live="polite">
Break in progress, please wait
</span>
{/if}
<!-- Bottom progress bar for modal -->
@@ -370,7 +483,7 @@
background: rgba(255, 255, 255, 0.03);
}
/* Ripple container - sits behind the ring, overflows the card */
/* Ripple container -- sits behind the ring, overflows the card */
.ripple-container {
position: absolute;
inset: 0;
@@ -411,23 +524,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;
+183
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>
+307
View File
@@ -0,0 +1,307 @@
<script lang="ts">
import { milestoneEvent, dailyGoalEvent } from "../stores/timer";
import { config } from "../stores/config";
const storeMilestone = $derived($milestoneEvent !== null && $config.milestone_celebrations);
const storeGoal = $derived($dailyGoalEvent && $config.milestone_celebrations);
const streakDays = $derived($milestoneEvent ?? 0);
// Local visibility state (decoupled from store for hover persistence)
let showMilestone = $state(false);
let showGoal = $state(false);
let milestoneHovering = $state(false);
let goalHovering = $state(false);
let milestoneFading = $state(false);
let goalFading = $state(false);
// Timeout handles for auto-dismiss
let milestoneTimeout: ReturnType<typeof setTimeout> | null = null;
let goalTimeout: ReturnType<typeof setTimeout> | null = null;
const DISMISS_DELAY = 3500; // matches original animation duration
const FADE_DURATION = 600; // fade-out transition time
function dismissMilestone() {
milestoneFading = true;
setTimeout(() => {
showMilestone = false;
milestoneFading = false;
}, FADE_DURATION);
}
function dismissGoal() {
goalFading = true;
setTimeout(() => {
showGoal = false;
goalFading = false;
}, FADE_DURATION);
}
function startMilestoneTimer() {
if (milestoneTimeout) clearTimeout(milestoneTimeout);
milestoneTimeout = setTimeout(() => {
milestoneTimeout = null;
dismissMilestone();
}, DISMISS_DELAY);
}
function startGoalTimer() {
if (goalTimeout) clearTimeout(goalTimeout);
goalTimeout = setTimeout(() => {
goalTimeout = null;
dismissGoal();
}, DISMISS_DELAY);
}
// When the store signals a milestone, show locally and start auto-dismiss
$effect(() => {
if (storeMilestone) {
showMilestone = true;
milestoneFading = false;
startMilestoneTimer();
}
});
// When the store signals daily goal, show locally and start auto-dismiss
$effect(() => {
if (storeGoal) {
showGoal = true;
goalFading = false;
startGoalTimer();
}
});
// Pause/resume milestone dismiss on hover/focus
$effect(() => {
if (milestoneHovering) {
if (milestoneTimeout) {
clearTimeout(milestoneTimeout);
milestoneTimeout = null;
}
} else if (showMilestone && !milestoneFading) {
startMilestoneTimer();
}
});
// Pause/resume goal dismiss on hover/focus
$effect(() => {
if (goalHovering) {
if (goalTimeout) {
clearTimeout(goalTimeout);
goalTimeout = null;
}
} else if (showGoal && !goalFading) {
startGoalTimer();
}
});
// 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}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="celebration-overlay"
class:fading={milestoneFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => milestoneHovering = true}
onmouseleave={() => milestoneHovering = false}
onfocusin={() => milestoneHovering = true}
onfocusout={() => milestoneHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissMilestone(); }}
>
<button
onclick={() => dismissMilestone()}
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/50 hover:text-white rounded-full"
aria-label="Dismiss notification"
>
&times;
</button>
<!-- 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}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="goal-overlay"
class:fading={goalFading}
role="alert"
aria-live="assertive"
tabindex="-1"
onmouseenter={() => goalHovering = true}
onmouseleave={() => goalHovering = false}
onfocusin={() => goalHovering = true}
onfocusout={() => goalHovering = false}
onkeydown={(e) => { if (e.key === 'Escape') dismissGoal(); }}
>
<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>
<button
onclick={() => dismissGoal()}
class="w-6 h-6 flex items-center justify-center text-[#3fb950]/50 hover:text-[#3fb950] rounded-full ml-2"
aria-label="Dismiss notification"
>
&times;
</button>
</div>
</div>
{/if}
<style>
.celebration-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 1;
transition: opacity 0.6s ease;
animation: celebration-enter 0.3s ease forwards;
}
.celebration-overlay.fading {
opacity: 0;
}
@keyframes celebration-enter {
0% { opacity: 0; }
100% { opacity: 1; }
}
.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;
opacity: 1;
transition: opacity 0.6s ease;
animation: goal-enter 0.35s ease forwards;
}
.goal-overlay.fading {
opacity: 0;
}
@keyframes goal-enter {
0% { transform: translateX(-50%) translateY(-20px); opacity: 0; }
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.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;
transition: none;
}
.celebration-overlay.fading,
.goal-overlay.fading {
opacity: 0;
}
.confetti-particle {
display: none;
}
}
</style>
+26 -16
View File
@@ -231,7 +231,7 @@
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#8a8a8a]">{value}</div>
<div class="font-mono text-[11px] text-text-sec">{value}</div>
</div>
<div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
@@ -240,34 +240,44 @@
</div>
<!-- Preset swatches -->
<div class="flex flex-wrap gap-[6px]">
<div class="flex flex-wrap gap-[8px]">
{#each presets as color}
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: {color};"
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
onclick={() => selectPreset(color)}
aria-label="Select {getColorName(color)}"
></button>
aria-pressed={value === color}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{value === color
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'group-hover:scale-110 opacity-80 group-hover:opacity-100'}"
style="background: {color};"
></span>
</button>
{/each}
<!-- Custom toggle swatch - ring shows when picker open OR value is custom -->
<!-- Custom toggle swatch -- ring shows when picker open OR value is custom -->
<button
type="button"
class="h-[22px] w-[22px] rounded-full transition-all duration-150
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
class="group flex items-center justify-center min-h-[44px] min-w-[44px] bg-transparent border-none p-0"
onclick={() => { showCustom = !showCustom; }}
aria-label="Custom color"
></button>
aria-pressed={showCustom || !isPreset}
>
<span
class="block h-[28px] w-[28px] rounded-full transition-all duration-150 pointer-events-none
{showCustom || !isPreset
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
: 'opacity-80 group-hover:opacity-100 group-hover:scale-110'}"
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
></span>
</button>
</div>
<!-- Inline custom picker - slides open/closed -->
<!-- Inline custom picker -- slides open/closed -->
{#if showCustom}
<div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
+123 -7
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,7 +29,9 @@
}
const statusText = $derived(
$timer.idlePaused
$timer.deferredBreakPending
? "DEFERRED"
: $timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
@@ -37,6 +40,34 @@
: "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("");
@@ -73,7 +104,7 @@
// Gap between ring and button, compensating for CSS transform phantom space.
// transform: scale() doesn't affect layout, so the 280px box stays full-size
// even when visually shrunk - creating phantom space below the visual ring.
// even when visually shrunk -- creating phantom space below the visual ring.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
@@ -81,6 +112,7 @@
// Natural break notification
let showNaturalBreakToast = $state(false);
let toastHovering = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection
@@ -89,7 +121,9 @@
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
if (!toastHovering) {
showNaturalBreakToast = false;
}
}, 5000);
}
});
@@ -146,11 +180,83 @@
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#8a8a8a]={!$timer.prebreakWarning}
class:text-text-sec={!$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-text-sec tabular-nums">
{$timer.pomodoroCyclePosition + 1}/{$timer.pomodoroTotalInCycle}
</span>
</div>
<span class="sr-only">Pomodoro cycle: session {$timer.pomodoroCyclePosition + 1} of {$timer.pomodoroTotalInCycle}</span>
{/if}
<!-- Microbreak countdown -->
{#if $timer.microbreakEnabled && !$timer.microbreakActive && $timer.state === "running"}
<div class="flex items-center gap-1 text-[9px] text-text-sec">
<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-text-sec">Goal</span>
<div
class="w-16 h-[2px] rounded-full overflow-hidden"
style="background: #161616;"
role="progressbar"
aria-label="Daily goal progress"
aria-valuemin={0}
aria-valuemax={$config.daily_goal_breaks}
aria-valuenow={dailyGoalProgress}
>
<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-text-sec tabular-nums">
{dailyGoalProgress}/{$config.daily_goal_breaks}
</span>
{/if}
</div>
{/if}
</div>
</div>
</TimerRing>
</div>
@@ -159,7 +265,7 @@
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-text-sec">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
@@ -169,17 +275,24 @@
<!-- Natural break notification toast -->
{#if showNaturalBreakToast}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
role="alert"
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
use:scaleIn={{ duration: 0.3, delay: 0 }}
onmouseenter={() => toastHovering = true}
onmouseleave={() => { toastHovering = false; showNaturalBreakToast = false; }}
onfocusin={() => toastHovering = true}
onfocusout={() => { toastHovering = false; showNaturalBreakToast = false; }}
onkeydown={(e) => { if (e.key === 'Escape') showNaturalBreakToast = false; }}
>
<div class="flex items-center gap-2">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
<button onclick={() => showNaturalBreakToast = false} class="ml-2 text-text-sec hover:text-white" aria-label="Dismiss notification">&times;</button>
</div>
</div>
{/if}
@@ -198,13 +311,15 @@
{toggleBtnText}
</button>
<!-- Bottom navigation buttons -->
<nav aria-label="Main actions" class="contents">
<!-- Bottom left: start break now -->
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
<button
aria-label="Start break now"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={startBreakNow}
@@ -231,7 +346,7 @@
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={() => {
@@ -263,7 +378,7 @@
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#8a8a8a]
border border-border text-text-sec
transition-colors duration-200
hover:border-[#333] hover:text-[#aaa]"
onclick={openSettings}
@@ -286,6 +401,7 @@
</svg>
</button>
</div>
</nav>
</div>
<style>
+23
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>
+4 -4
View File
@@ -52,7 +52,7 @@
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#8a8a8a]">
<div class="text-[11px] text-text-sec">
{value || "System default"}
</div>
</div>
@@ -60,7 +60,7 @@
type="button"
aria-expanded={expanded}
aria-label={expanded ? "Close font browser" : "Browse fonts"}
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#8a8a8a]
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-text-sec
transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }}
>
@@ -83,7 +83,7 @@
transition-all duration-150
{value === font.family
? 'border-white/30 bg-[#141414]'
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}"
: 'border-[#141414] bg-[#0a0a0a] hover:border-border hover:bg-[#0f0f0f]'}"
onclick={() => selectFont(font.family)}
>
<span
@@ -92,7 +92,7 @@
>
25:00
</span>
<span class="text-[9px] tracking-wider text-[#8a8a8a] uppercase">
<span class="text-[9px] tracking-wider text-text-sec uppercase">
{font.label}
</span>
</button>
@@ -0,0 +1,81 @@
<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" role="alertdialog" aria-label="Microbreak" aria-describedby="microbreak-msg">
<h2 class="sr-only">Microbreak</h2>
<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 id="microbreak-msg" 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-text-sec 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>
+13 -1
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
@@ -329,10 +335,16 @@ const fontStyle = $derived(
<!-- Countdown text -->
<span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}"
style="color: {state === 'paused' ? '#a8a8a8' : '#fff'}; {fontStyle}"
>
{timeText}
</span>
<!-- F3: Pomodoro cycle indicator -->
{#if pomodoroEnabled}
<span class="ml-1.5 text-[10px] tabular-nums" style="color: #a8a8a8;">
{pomodoroCyclePosition + 1}/{pomodoroTotalInCycle}
</span>
{/if}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+376 -34
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";
// Day label -- show every Nth for 30-day
if (data.length <= 7 || i % 5 === 0) {
ctx.fillStyle = "#a8a8a8";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD"
const label = day.date.slice(5);
ctx.fillText(label, x + barWidth / 2, h - 5);
}
});
}
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,
@@ -156,8 +266,8 @@
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#8a8a8a] transition-colors hover:text-white"
class="mr-3 flex h-10 w-10 min-h-[44px] min-w-[44px] items-center justify-center rounded-full
text-text-sec transition-colors hover:text-white"
onclick={goBack}
>
<svg
@@ -183,23 +293,44 @@
</h1>
</div>
<!-- Tab navigation -->
<div class="flex gap-1 px-5 mb-3" role="tablist" aria-label="Statistics time range" use:fadeIn={{ duration: 0.3, y: 6 }}>
{#each [["today", "Today"], ["weekly", "Weekly"], ["monthly", "Monthly"]] as [tab, label]}
<button
use:pressable
role="tab"
id="tab-{tab}"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{tab}"
class="min-h-[44px] rounded-lg px-4 py-1.5 text-[11px] tracking-wider uppercase transition-all duration-200
{activeTab === tab
? 'bg-[#1a1a1a] text-white'
: 'text-text-sec 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"}
<div role="tabpanel" id="tabpanel-today" aria-labelledby="tab-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"
>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Today
</h3>
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todayCompleted ?? 0}
</div>
<div class="text-[11px] text-[#8a8a8a]">Breaks taken</div>
<div class="text-[11px] text-text-sec">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
@@ -207,61 +338,104 @@
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#8a8a8a]">Compliance</div>
<div class="text-[11px] text-text-sec">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#8a8a8a]">Break time</div>
<div class="text-[11px] text-text-sec">Break time</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{stats?.todaySkipped ?? 0}
</div>
<div class="text-[11px] text-[#8a8a8a]">Skipped</div>
<div class="text-[11px] text-text-sec">Skipped</div>
</div>
</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 }}>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Daily Goal
</h2>
<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-text-sec">
{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"
>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Streak
</h3>
</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#8a8a8a]">Consecutive days with breaks</div>
<div class="text-[11px] text-text-sec">Consecutive days with breaks</div>
</div>
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
{stats?.currentStreak ?? 0}
</div>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#8a8a8a]">All-time record</div>
<div class="text-[11px] text-text-sec">All-time record</div>
</div>
<div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
</div>
</div>
<!-- F10: Next milestone -->
{#if nextMilestone()}
<div class="my-4 h-px bg-border"></div>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Next milestone</div>
<div class="text-[11px] text-text-sec">{nextMilestone()} day streak</div>
</div>
<div class="text-[13px] text-text-sec 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"
>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 7 Days
</h3>
</h2>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role -->
<canvas
@@ -271,7 +445,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>
@@ -290,7 +463,7 @@
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#8a8a8a]">
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
<div class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
Completed
@@ -301,6 +474,175 @@
</div>
</div>
</section>
</div>
{:else if activeTab === "weekly"}
<div role="tabpanel" id="tabpanel-weekly" aria-labelledby="tab-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 }}>
<h2 class="mb-3 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Week of {week.weekStart}
</h2>
<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-text-sec">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-text-sec">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-text-sec">Compliance</div>
</div>
</div>
<div class="flex items-center justify-between text-[11px]">
<span class="text-text-sec">Avg {week.avgDailyCompleted.toFixed(1)} breaks/day</span>
{#if prevWeek}
<span class="flex items-center gap-1"
style="color: {trend > 0 ? '#3fb950' : trend < 0 ? '#ff6b6b' : '#a8a8a8'};"
>
{#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}
</div>
{:else}
<div role="tabpanel" id="tabpanel-monthly" aria-labelledby="tab-monthly">
<!-- 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 }}>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Last 30 Days
</h2>
<!-- 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>
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Break history for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Completed</th><th>Skipped</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td><td>{day.breaksSkipped}</td></tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-text-sec">
<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 }}>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Activity Heatmap
</h2>
<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>
{#if monthHistory.length > 0}
<table class="sr-only">
<caption>Activity heatmap for the last {monthHistory.length} days</caption>
<thead>
<tr><th>Date</th><th>Breaks completed</th></tr>
</thead>
<tbody>
{#each monthHistory as day}
<tr><td>{day.date}</td><td>{day.breaksCompleted}</td></tr>
{/each}
</tbody>
</table>
{/if}
<div class="mt-3 flex items-center justify-center gap-2 text-[10px] text-text-sec">
<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 }}>
<h2 class="mb-4 text-[11px] font-medium tracking-[0.15em] text-text-sec uppercase">
Monthly Summary
</h2>
<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-text-sec">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-text-sec">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-text-sec">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-text-sec">Avg daily breaks</div>
</div>
</div>
</section>
</div>
{/if}
</div>
</div>
</div>
+21 -6
View File
@@ -55,20 +55,34 @@
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
}
function handleKeydown(fn: () => void, e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
e.preventDefault();
startHold(fn);
}
}
function handleKeyup(e: KeyboardEvent) {
if (["Enter", " "].includes(e.key)) {
stopHold();
}
}
</script>
<div class="flex items-center gap-1.5" role="group" aria-label={label}>
<button
type="button"
aria-label="Decrease"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#999] transition-colors
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) decrement(); }}
onkeydown={(e) => handleKeydown(decrement, e)}
onkeyup={handleKeyup}
disabled={value <= min}
>
&minus;
@@ -79,14 +93,15 @@
<button
type="button"
aria-label="Increase"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#999] transition-colors
class="flex h-9 w-9 min-h-[44px] min-w-[44px] items-center justify-center rounded-lg
border border-[#3a3a3a] bg-[#1a1a1a] text-text-sec transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
onclick={(e) => { if (e.detail === 0) increment(); }}
onkeydown={(e) => handleKeydown(increment, e)}
onkeyup={handleKeyup}
disabled={value >= max}
>
+
+2 -2
View File
@@ -7,7 +7,7 @@
let { value, onchange, countdownFont = "" }: Props = $props();
// Local display values - driven by prop normally, overridden during drag/momentum
// Local display values -- driven by prop normally, overridden during drag/momentum
let displayHours = $state(0);
let displayMinutes = $state(0);
let isAnimating = $state(false); // true during drag OR momentum
@@ -373,7 +373,7 @@
border-color: rgba(255, 255, 255, 0.18);
}
/* Perspective container - looking into the cylinder from outside */
/* Perspective container -- looking into the cylinder from outside */
.wheel-viewport {
width: 100%;
height: 100%;
+2 -2
View File
@@ -55,7 +55,7 @@
aria-label={label}
aria-valuetext={valueText}
>
<!-- Glow SVG - drawn larger than the container so blur isn't clipped -->
<!-- Glow SVG -- drawn larger than the container so blur isn't clipped -->
<svg
aria-hidden="true"
width={viewSize}
@@ -145,7 +145,7 @@
{/if}
</svg>
<!-- Non-glow SVG - exact size, draws the track + crisp ring -->
<!-- Non-glow SVG -- exact size, draws the track + crisp ring -->
<svg
aria-hidden="true"
width={size}
+14 -14
View File
@@ -5,11 +5,11 @@
</script>
<!-- Invisible drag region - traffic lights on the right -->
<div
<header
data-tauri-drag-region
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
>
<!-- Centered app name (decorative - OS window title handles screen readers) -->
<!-- Centered app name (decorative -- OS window title handles screen readers) -->
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 flex items-center justify-center
@@ -20,15 +20,14 @@
</span>
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<div class="flex items-center gap-0 opacity-10 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100">
<!-- Maximize (green) -->
<button
aria-label="Maximize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#27C93F] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.toggleMaximize()}
>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#27C93F] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
@@ -40,16 +39,16 @@
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
</svg>
</span>
</button>
<!-- Minimize (yellow) -->
<button
aria-label="Minimize"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FFBD2E] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.minimize()}
>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FFBD2E] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
@@ -59,16 +58,16 @@
>
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg>
</span>
</button>
<!-- Close (red) - rightmost -->
<!-- Close (red) -- rightmost -->
<button
aria-label="Close"
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
rounded-full bg-[#FF5F57] transition-all duration-150
hover:brightness-110"
class="group/btn relative flex h-[44px] w-[44px] items-center justify-center"
onclick={() => appWindow.close()}
>
<span class="flex h-[20px] w-[20px] items-center justify-center rounded-full bg-[#FF5F57] transition-all duration-150 group-hover/btn:brightness-110">
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
@@ -79,6 +78,7 @@
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
</svg>
</span>
</button>
</div>
</div>
</header>
+10 -5
View File
@@ -15,19 +15,24 @@
}
</script>
<!-- 44px hit area wrapper (WCAG 2.5.8) with compact visual toggle inside -->
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
class="relative inline-flex min-h-[44px] min-w-[52px] shrink-0 cursor-pointer items-center justify-center
bg-transparent border-none p-0"
onclick={toggle}
>
<span
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full
class="inline-flex h-[28px] w-[52px] items-center rounded-full transition-colors duration-200 ease-in-out"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
>
<span
class="pointer-events-none inline-block h-[22px] w-[22px] rounded-full
shadow-sm transition-transform duration-200 ease-in-out
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]"
{checked ? 'translate-x-[27px] bg-white' : 'translate-x-[3px] bg-[#666]'}"
></span>
</span>
</button>
+93 -9
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);
+72
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);
+65 -20
View File
@@ -8,9 +8,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
{ category: "eyes", text: "Focus on a distant object, then a near one repeat 5 times" },
{ category: "eyes", text: "Focus on a distant object, then a near one -- repeat 5 times" },
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
{ category: "eyes", text: "Look up, down, left, right hold each for 2 seconds" },
{ category: "eyes", text: "Look up, down, left, right -- hold each for 2 seconds" },
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
@@ -19,9 +19,9 @@ export const breakActivities: BreakActivity[] = [
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
{ category: "eyes", text: "Look at something green plants reduce eye strain naturally" },
{ category: "eyes", text: "Look at something green -- plants reduce eye strain naturally" },
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide repeat 5 times" },
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide -- repeat 5 times" },
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
@@ -35,7 +35,7 @@ export const breakActivities: BreakActivity[] = [
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently switch sides" },
{ category: "stretch", text: "Place your right hand on your left knee and twist gently -- switch sides" },
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
@@ -46,20 +46,20 @@ export const breakActivities: BreakActivity[] = [
// Breathing
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
{ category: "breathing", text: "Take 5 deep belly breaths feel your diaphragm expand" },
{ category: "breathing", text: "Take 5 deep belly breaths -- feel your diaphragm expand" },
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth 5 times" },
{ category: "breathing", text: "Breathe in through your nose, out through your mouth -- 5 times" },
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
{ category: "breathing", text: "Breathe in calm, breathe out tension 5 rounds" },
{ category: "breathing", text: "Breathe in calm, breathe out tension -- 5 rounds" },
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air 5 rounds" },
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 -- gradually increase to 6 each" },
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths -- feel the vibration in your chest" },
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air -- 5 rounds" },
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
@@ -75,11 +75,11 @@ export const breakActivities: BreakActivity[] = [
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
{ category: "movement", text: "Walk to the farthest room in your home and back" },
{ category: "movement", text: "Do 5 wall push-ups hands on the wall, lean in and push back" },
{ category: "movement", text: "Do 5 wall push-ups -- hands on the wall, lean in and push back" },
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold let your arms hang loose" },
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly -- repeat 5 times" },
{ category: "movement", text: "Do a gentle standing forward fold -- let your arms hang loose" },
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
];
@@ -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)];
}
+26 -31
View File
@@ -1,6 +1,6 @@
import { animate } from "motion";
// Module-level reduced motion query shared across all actions
// Module-level reduced motion query -- shared across all actions
const reducedMotionQuery =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)")
@@ -10,9 +10,6 @@ function prefersReducedMotion(): boolean {
return reducedMotionQuery?.matches ?? false;
}
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
@@ -38,9 +35,6 @@ export function fadeIn(
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
@@ -66,9 +60,6 @@ export function scaleIn(
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
@@ -111,9 +102,6 @@ export function inView(
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
if (prefersReducedMotion()) {
return { destroy() {} };
@@ -140,28 +128,38 @@ export function pressable(node: HTMLElement) {
);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onDown();
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
onUp();
}
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
node.addEventListener("keydown", onKeyDown);
node.addEventListener("keyup", onKeyUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
node.removeEventListener("keydown", onKeyDown);
node.removeEventListener("keyup", onKeyUp);
active?.cancel();
},
};
}
/**
* Svelte action: animated glow band on hover.
* Creates a crisp ring "band" plus a soft atmospheric glow.
* Pass a hex color (e.g. "#ff4d00").
*
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
* interpolates through the correct color channel instead of through black.
*/
// Animated glow band on hover. Pass a hex color (e.g. "#ff4d00").
export function glowHover(
node: HTMLElement,
options?: { color?: string },
@@ -200,6 +198,8 @@ export function glowHover(
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
node.addEventListener("focusin", onEnter);
node.addEventListener("focusout", onLeave);
return {
update(newOptions?: { color?: string }) {
@@ -209,20 +209,15 @@ export function glowHover(
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", onLeave);
node.removeEventListener("focusin", onEnter);
node.removeEventListener("focusout", onLeave);
enterAnim?.cancel();
leaveAnim?.cancel();
},
};
}
/**
* Svelte action: momentum-based grab-and-drag scrolling
* with elastic overscroll and spring-back at boundaries.
*
* IMPORTANT: The node must have exactly one child element (a wrapper div).
* Overscroll transforms are applied to that child, NOT to the scroll
* container itself (which would break overflow clipping).
*/
// Momentum grab-and-drag scrolling. Node must have exactly one child wrapper div.
export function dragScroll(node: HTMLElement) {
if (prefersReducedMotion()) {
// Allow normal scrolling without the momentum/elastic physics
@@ -272,7 +267,7 @@ export function dragScroll(node: HTMLElement) {
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled onDown handles cleanup */ });
}).catch(() => { /* cancelled -- onDown handles cleanup */ });
}
function forceReset() {
@@ -349,7 +344,7 @@ export function dragScroll(node: HTMLElement) {
// Time-based exponential decay (iOS-style scroll physics).
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
const tau = 325; // time constant in ms iOS UIScrollView feel
const tau = 325; // time constant in ms -- iOS UIScrollView feel
const coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
+7 -18
View File
@@ -1,8 +1,3 @@
/**
* Synthesized notification sounds using the Web Audio API.
* No external audio files needed all sounds are generated programmatically.
*/
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
@@ -14,11 +9,6 @@ function getAudioContext(): AudioContext {
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
/**
* Play a notification sound with the given preset and volume.
* @param preset - One of: "bell", "chime", "soft", "digital"
* @param volume - 0 to 100
*/
export function playSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
@@ -53,7 +43,7 @@ export function playSound(preset: SoundPreset, volume: number): void {
}
}
/** Warm bell two sine tones with harmonics and slow decay */
/** Warm bell -- two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -103,7 +93,7 @@ function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Gentle soft ping filtered triangle wave */
/** Gentle soft ping -- filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -129,7 +119,7 @@ function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.2);
}
/** Digital blip short square wave burst */
/** Digital blip -- short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -149,7 +139,7 @@ function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
}
}
/** Harp cascading arpeggiated sine tones (C5-E5-G5-C6) */
/** Harp -- cascading arpeggiated sine tones (C5-E5-G5-C6) */
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
@@ -170,7 +160,7 @@ function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
});
}
/** Singing bowl low sine with slow beating from detuned pair */
/** Singing bowl -- low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -201,7 +191,7 @@ function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
osc3.stop(now + 1.5);
}
/** Rain filtered noise burst with gentle decay */
/** Rain -- filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
@@ -233,7 +223,7 @@ function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
noise.stop(now + 1.0);
}
/** Whistle gentle two-note sine glide */
/** Whistle -- gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
@@ -256,7 +246,6 @@ function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
osc.stop(now + 1.0);
}
/** Play a completion sound — slightly different from start (descending) */
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
const ctx = getAudioContext();
const gain = ctx.createGain();
+18 -2
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")!,