Initial commit - Core Cooldown v0.1.0

Portable Windows break timer to prevent RSI and eye strain.
Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry,
no data leaves the machine. CC0 public domain.
This commit is contained in:
Your Name
2026-02-07 01:12:32 +02:00
commit 0cbd8abad4
48 changed files with 15133 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build output
dist/
# Rust build artifacts
src-tauri/target/
# Generated Tauri schemas (auto-regenerated on build)
src-tauri/gen/schemas/
# Runtime portable data (created next to exe)
config.json
stats.json
data/
# OS files
Thumbs.db
.DS_Store
# Editor
.vscode/
.idea/
*.swp
*.swo
*~

116
LICENSE Normal file
View File

@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescinding, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not invalidate
the remainder of the License, and in such case Affirmer hereby affirms that he
or she will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of action
with respect to the Work, in either case contrary to Affirmer's express
Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions or
other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this CC0
or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

438
README.md Normal file
View File

@@ -0,0 +1,438 @@
<p align="center">
<img src="src-tauri/icons/128x128@2x.png" alt="Core Cooldown" width="96" height="96" />
</p>
<h1 align="center">Core Cooldown</h1>
<p align="center">
<strong>A portable break timer for Windows that reminds you to rest.</strong><br />
Because your time and your body belong to you — not to your screen.
</p>
<p align="center">
<img src="https://img.shields.io/badge/license-CC0_1.0-blue?style=flat-square" alt="CC0 1.0" />
<img src="https://img.shields.io/badge/platform-Windows-0078D6?style=flat-square&logo=windows" alt="Windows" />
<img src="https://img.shields.io/badge/tauri-v2-24C8D8?style=flat-square&logo=tauri" alt="Tauri v2" />
<img src="https://img.shields.io/badge/svelte-5-FF3E00?style=flat-square&logo=svelte" alt="Svelte 5" />
<img src="https://img.shields.io/badge/rust-2021-000000?style=flat-square&logo=rust" alt="Rust" />
<img src="https://img.shields.io/badge/tailwind-v4-06B6D4?style=flat-square&logo=tailwindcss" alt="Tailwind v4" />
</p>
<br />
---
<br />
## Why
Repetitive strain injury and eye strain are not personal failings. They are the predictable consequence of systems that treat human attention as an extractable resource. Every worker 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.
Core Cooldown is a single portable `.exe` with no installer, no account, no telemetry, and no data leaving your machine. Drop it in a folder and run it. It will remind you to rest. That's it.
<br />
## Features
### Timer & Breaks
| Feature | Description |
|:--------|:------------|
| **Configurable intervals** | Set work sessions from 5120 minutes and breaks from 160 minutes |
| **Pre-break warnings** | Toast notification and optional sound alert before each break |
| **Break enforcement** | Always-on-top break window, optional fullscreen mode |
| **Strict mode** | When enabled, the skip and cancel buttons are removed entirely |
| **Early end** | Optionally allow ending a break after 50% completion |
| **Snooze** | Delay a break by a configurable number of minutes (with configurable limits) |
| **Skip cooldown** | Prevent rapid-fire skipping with a configurable cooldown timer |
| **Immediate breaks** | Skip the pre-break notification and go straight into the break |
| **Manual break** | Start a break at any time from the dashboard or the tray menu |
### Idle Detection & Smart Breaks
| Feature | Description |
|:--------|:------------|
| **Idle auto-pause** | Detects inactivity via Windows API (`GetLastInputInfo`) and pauses the timer |
| **Auto-resume** | Timer resumes automatically when you return |
| **Smart breaks** | Recognizes natural breaks (stepping away from the keyboard) and optionally counts them toward your daily goal |
| **Configurable thresholds** | Idle timeout (30600s) and smart break threshold (215 min) are independently adjustable |
### Break Activities
Each break screen shows a randomized activity suggestion from a curated library of **70 activities** across four categories:
- **Eyes** — palming, distance focusing, figure-eights, warm compress visualization, peripheral awareness drills
- **Stretch** — neck rolls, wrist flexion, shoulder blade squeezes, chest openers, spinal twists, hip flexor stretches
- **Breathing** — box breathing, 4-7-8 technique, alternate nostril, diaphragmatic breathing, resonance breathing
- **Movement** — standing calf raises, wall push-ups, balance exercises, gentle squats, toe touches, desk yoga
Activities cycle every 30 seconds and never repeat consecutively.
### Working Hours Schedule
A per-day schedule with support for multiple time ranges per day. The timer only runs during your configured working hours — outside of those hours, it pauses automatically.
- **Monday through Sunday** — each day independently togglable
- **Multiple ranges per day** — for split shifts or non-contiguous work blocks
- **Weekend defaults** — Saturday and Sunday are disabled by default
### Statistics & History
| Metric | Description |
|:-------|:------------|
| **Today's summary** | Breaks completed, skipped, snoozed, and total break time |
| **Natural breaks** | Separately tracked idle breaks (with optional stat inclusion) |
| **Compliance rate** | Ratio of completed breaks to total scheduled |
| **Streak tracking** | Current and best consecutive-day streaks |
| **7-day chart** | Canvas-rendered bar chart showing daily break history |
All statistics are stored locally in a plain JSON file next to the executable.
### Sound Effects
Synthesized notification sounds via the Web Audio API — no bundled audio files, no network requests.
**8 presets:** Bell, Chime, Soft, Digital, Harp, Bowl, Rain, Whistle
Each preset plays on break start, pre-break warning, and break completion. Volume is configurable from 0100%.
### Global Keyboard Shortcuts
| Shortcut | Action |
|:---------|:-------|
| `Ctrl + Shift + P` | Pause / resume timer |
| `Ctrl + Shift + B` | Start break now |
| `Ctrl + Shift + S` | Show / hide main window |
These work system-wide, even when Core Cooldown is not focused.
### System Tray
- **Dynamic icon** — a 32×32 progress arc rendered in real-time: orange during focus, purple during breaks, dimmed when paused
- **Countdown tooltip** — hover over the tray icon to see time remaining
- **Context menu** — pause/resume, start break, toggle mini mode, show/hide, quit
### Mini Mode
A compact floating timer (200×50px) that sits on top of your other windows.
- **Click-through** — the mini timer is completely transparent to mouse events by default, so it never blocks what's underneath
- **Hover to grab** — hover over it for a configurable number of seconds (default: 3) and it becomes draggable
- **Double-click** — opens the main window
- **Togglable** — enable/disable from the tray menu
### Appearance & Customization
| Setting | Range |
|:--------|:------|
| **UI zoom** | 50200% with live preview |
| **Accent color** | Hex color picker for the main UI accent |
| **Break color** | Separate hex color for the break screen ring |
| **Color schemes** | Ocean, Forest, Sunset, Midnight, Dawn |
| **Countdown font** | Google Fonts selector for the timer display |
| **Background blobs** | Animated gradient blobs with film grain overlay |
| **Backdrop opacity** | 50100% for the break screen overlay |
| **Break title & message** | Fully customizable text shown during breaks |
| **Dark mode** | Always on (it's the only civilized option) |
### Notifications
Native Windows toast notifications for:
- Pre-break warnings (configurable seconds before break)
- Break completion
### Window Behavior
- **Frameless window** with custom titlebar and drag region
- **Transparent background** with frosted glass effects
- **Window position persistence** — main and mini windows remember their position between launches
- **Animated view transitions** — directional fly/scale/fade transitions (700ms, cubicOut easing) between all views
<br />
## Portability
Core Cooldown is fully portable. The executable carries everything it needs and stores everything it creates right next to itself:
```
core-cooldown.exe ← the application
config.json ← your settings (created on first run)
stats.json ← your break history (created on first run)
data/ ← WebView2 runtime data (created on first run)
```
No installer. No registry entries. No writes to `%APPDATA%`, `%LOCALAPPDATA%`, or any other system directory. Move the folder anywhere — a USB stick, a shared drive, a different machine. It just works.
There is nothing to uninstall. Delete the folder and it's gone. No traces left behind.
<br />
## Installation
1. Download `core-cooldown.exe` from the [Releases](../../releases) page
2. Put it in any folder you like
3. Run it
That's it. No elevated permissions required. No runtime dependencies to install. The first launch may take a moment while Windows initializes the WebView2 runtime.
<br />
## Building from Source
### Prerequisites
- **Node.js** (v18+) and **npm**
- **Rust** toolchain (`rustup`) with the `x86_64-pc-windows-gnu` target
- **MinGW-w64** — the GNU toolchain for Windows (provides the linker, windres, and dlltool)
### Setup
```bash
# Clone the repository
git clone https://git.lashman.live/lashman/core-cooldown.git
cd core-cooldown
# Install JavaScript dependencies
npm install
```
### Linker Configuration
Create or edit `src-tauri/.cargo/config.toml` to point to your MinGW installation:
```toml
[build]
target = "x86_64-pc-windows-gnu"
[target.x86_64-pc-windows-gnu]
linker = "C:/path/to/mingw64/bin/gcc.exe"
ar = "C:/path/to/mingw64/bin/ar.exe"
```
### Development
```bash
# Launch the app with hot-reload
npm run tauri dev
# Check Rust code without building
cd src-tauri && cargo check
# Build frontend only (no Tauri shell)
npm run dev
```
### Release Build
```bash
# Build a release executable (no installer)
npm run tauri build
```
The compiled binary will be at:
```
src-tauri/target/x86_64-pc-windows-gnu/release/core-cooldown.exe
```
<br />
## Architecture
Core Cooldown is a split-architecture desktop application: a Rust backend for system integration and timer logic, and a Svelte frontend rendered in a native WebView.
```
┌──────────────────────────────────────────────────────────────┐
│ System Tray │
│ (dynamic icon · tooltip · menu) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Main Window │ │ Break Window│ │ Mini Window │ │
│ │ (WebView) │ │ (WebView) │ │ (WebView) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────┬───────┴──────────────────┘ │
│ │ Tauri IPC (commands + events) │
│ ┌─────────┴─────────┐ │
│ │ Rust Backend │ │
│ │ │ │
│ │ TimerManager │ ← state machine (tick/sec) │
│ │ Config │ ← JSON persistence │
│ │ Stats │ ← break history tracking │
│ │ IdleDetector │ ← GetLastInputInfo polling │
│ │ GlobalShortcuts │ ← Ctrl+Shift+P/B/S │
│ │ TrayIcon │ ← RGBA ring rendering │
│ │ Notifications │ ← Windows toast │
│ └───────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
```
### Backend (Rust)
| Module | Responsibility |
|:-------|:---------------|
| `lib.rs` | Tauri app builder, command registration, tray setup, timer tick thread, window management, global shortcuts |
| `config.rs` | Configuration struct with serde serialization, validation (clamping all values to safe ranges), and 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 |
| `main.rs` | Entry point |
### Frontend (Svelte 5 + TypeScript)
| Layer | Files |
|:------|:------|
| **Views** | `Dashboard.svelte`, `BreakScreen.svelte`, `Settings.svelte`, `StatsView.svelte` |
| **Windows** | `BreakWindow.svelte` (standalone break modal), `MiniTimer.svelte` (floating mini mode) |
| **Components** | `TimerRing.svelte`, `Titlebar.svelte`, `ToggleSwitch.svelte`, `Stepper.svelte`, `ColorPicker.svelte`, `FontSelector.svelte`, `TimeSpinner.svelte`, `BackgroundBlobs.svelte` |
| **Stores** | `timer.ts` (reactive timer state from IPC events), `config.ts` (config state with debounced auto-save) |
| **Utilities** | `sounds.ts` (Web Audio synthesis), `activities.ts` (70 break activities), `animate.ts` (motion library actions) |
### IPC Contract
**Commands** (frontend → backend):
`get_config`, `save_config`, `update_pending_config`, `reset_config`, `toggle_timer`, `start_break_now`, `cancel_break`, `snooze`, `get_timer_state`, `set_view`, `get_stats`, `get_daily_history`
**Events** (backend → frontend):
`timer-tick` (every second), `break-started`, `break-ended`, `prebreak-warning`, `config-changed`
<br />
## Configuration Reference
All settings are 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 if you prefer.
<details>
<summary><strong>Full configuration schema</strong></summary>
| Key | Type | Default | Range | Description |
|:----|:-----|:--------|:------|:------------|
| `break_duration` | `u32` | `5` | 160 min | Duration of each break |
| `break_frequency` | `u32` | `25` | 5120 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 from the screen..."` | 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` | MonFri 09:0018:00 | 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.51.0 | Break screen backdrop opacity |
| `notification_enabled` | `bool` | `true` | — | Enable toast notifications |
| `notification_before_break` | `u32` | `30` | 0300 sec | Pre-break warning time |
| `snooze_duration` | `u32` | `5` | 130 min | Snooze delay |
| `snooze_limit` | `u32` | `3` | 05 (0=unlimited) | Max snoozes per cycle |
| `skip_cooldown` | `u32` | `60` | 0600 sec | Cooldown between skips |
| `sound_enabled` | `bool` | `true` | — | Play notification sounds |
| `sound_volume` | `u32` | `70` | 0100 | Sound volume percentage |
| `sound_preset` | `string` | `"bell"` | 8 presets | Sound preset name |
| `idle_detection_enabled` | `bool` | `true` | — | Enable idle auto-pause |
| `idle_timeout` | `u32` | `120` | 30600 sec | Idle threshold |
| `smart_breaks_enabled` | `bool` | `true` | — | Detect natural breaks |
| `smart_break_threshold` | `u32` | `300` | 120900 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` | 50200% | 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.010.0 sec | Hover time before drag |
</details>
<br />
## Dependencies
### Rust
| Crate | Purpose |
|:------|:--------|
| `tauri 2` | Application shell, IPC, multi-window, system tray |
| `tauri-plugin-shell 2` | Shell integration |
| `tauri-plugin-notification 2` | Windows toast notifications |
| `tauri-plugin-global-shortcut 2` | System-wide keyboard shortcuts |
| `serde` / `serde_json` | Configuration and statistics serialization |
| `chrono` | Date/time handling for schedules and statistics |
| `dirs` | Platform directory resolution (unused — legacy dep) |
| `anyhow` | Error handling |
| `winapi` | Windows idle detection (`GetLastInputInfo`) |
### JavaScript
| Package | Purpose |
|:--------|:--------|
| `@tauri-apps/api` | Frontend IPC bindings |
| `svelte 5` | Reactive UI framework (runes: `$state`, `$derived`, `$effect`) |
| `tailwindcss 4` | Utility-first CSS |
| `vite 6` | Build tool and dev server |
| `motion` | Animation library |
| `typescript 5` | Type safety |
<br />
## Contributing
This project belongs to no one and everyone. If you find it useful and want to make it better, you are welcome.
There are no contribution agreements to sign, no corporate CLAs, no licensing traps. Everything here is in the public domain. Your contributions will be too — freely given, freely shared, freely built upon by anyone who needs them.
**Some ways to help:**
- Report bugs or rough edges
- Suggest new break activities (especially if you have physiotherapy or ergonomics knowledge)
- Improve accessibility
- 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.
<br />
## 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 be able to gatekeep behind a paywall or a subscription.
This application:
- **Collects nothing.** No analytics, no telemetry, no usage tracking, no crash reports phoned home. Your break habits are your own business.
- **Costs nothing.** Not "free tier with limitations." Not "free for personal use." Free. Unconditionally. For everyone.
- **Requires nothing.** No account, no email, no app store, no internet connection after download. It runs on your machine and answers to you alone.
- **Owns nothing.** Released under CC0 — the most complete relinquishment of rights possible under law. There is no owner. There are no restrictions. The code belongs to the commons.
We believe that tools for human wellbeing should never be enclosed, never be scarce, and never serve a master other than the person using them.
<br />
## License
<p align="center">
<a href="https://creativecommons.org/publicdomain/zero/1.0/">
<img src="https://licensebuttons.net/p/zero/1.0/88x31.png" alt="CC0" />
</a>
</p>
**CC0 1.0 Universal — Public Domain Dedication**
To the extent possible under law, the author has waived all copyright and related or neighboring rights to this work. This work is published from the commons, for the commons.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission. No permission is needed. No attribution is required (though it's always appreciated).
See [`LICENSE`](LICENSE) for the full legal text.
<br />
---
<p align="center">
<sub>
Built with care. Shared without conditions.<br />
Rest well.
</sub>
</p>

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@600&family=Space+Mono:wght@700&family=Roboto+Mono:wght@600&family=Fira+Code:wght@600&family=IBM+Plex+Mono:wght@600&family=Source+Code+Pro:wght@600&family=Share+Tech+Mono&family=Major+Mono+Display&family=Azeret+Mono:wght@600&family=DM+Mono:wght@500&family=Inconsolata:wght@600&family=Ubuntu+Mono:wght@700&family=Overpass+Mono:wght@600&family=Red+Hat+Mono:wght@600&family=Martian+Mono:wght@600&family=Noto+Sans+Mono:wght@600&family=Oxygen+Mono&family=Anonymous+Pro:wght@700&family=Courier+Prime:wght@700&display=swap" rel="stylesheet" />
<title>Core Cooldown</title>
</head>
<body style="background:#000">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2395
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "core-cooldown",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"motion": "^12.33.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5",
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"svelte": "^5",
"svelte-check": "^4",
"tailwindcss": "^4",
"typescript": "^5.5",
"vite": "^6"
}
}

View File

@@ -0,0 +1,2 @@
[build]
target = "x86_64-pc-windows-gnu"

5443
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "core-cooldown"
version = "0.1.0"
edition = "2021"
[lib]
name = "core_cooldown_lib"
crate-type = ["lib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
chrono = "0.4"
anyhow = "1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef"] }

11
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,11 @@
fn main() {
// Ensure MinGW tools are on PATH for build scripts (windres, dlltool, etc.)
let mingw_bin = "C:/Users/lashman/mingw-w64/mingw64/bin";
if let Ok(current_path) = std::env::var("PATH") {
std::env::set_var("PATH", format!("{};{}", mingw_bin, current_path));
} else {
std::env::set_var("PATH", mingw_bin);
}
tauri_build::build()
}

View File

@@ -0,0 +1,35 @@
{
"$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"],
"permissions": [
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-set-focus",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-always-on-top",
"core:window:allow-set-fullscreen",
"core:window:allow-set-size",
"core:window:allow-set-min-size",
"core:window:allow-set-position",
"core:window:allow-set-decorations",
"core:window:allow-set-skip-taskbar",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-is-visible",
"core:window:allow-unminimize",
"core:window:allow-outer-position",
"core:window:allow-outer-size",
"core:window:allow-inner-size",
"core:event:default",
"core:app:default",
"core:tray:default",
"core:webview:default",
"shell:default",
"notification:default",
"global-shortcut:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

421
src-tauri/src/config.rs Normal file
View File

@@ -0,0 +1,421 @@
use anyhow::{Context, Result};
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 TimeRange {
pub start: String, // Format: "HH:MM"
pub end: String, // Format: "HH:MM"
}
impl Default for TimeRange {
fn default() -> Self {
Self {
start: "09:00".to_string(),
end: "18:00".to_string(),
}
}
}
/// Schedule for a single day
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaySchedule {
pub enabled: bool,
pub ranges: Vec<TimeRange>,
}
impl Default for DaySchedule {
fn default() -> Self {
Self {
enabled: true,
ranges: vec![TimeRange::default()],
}
}
}
impl DaySchedule {
/// Create a default schedule for weekend days (disabled by default)
fn weekend_default() -> Self {
Self {
enabled: false,
ranges: vec![TimeRange::default()],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
// Timer settings
pub break_duration: u32, // Duration of each break in minutes (1-60)
pub break_frequency: u32, // How often breaks occur in minutes (5-120)
pub auto_start: bool, // Start timer automatically on app launch
// Break settings
pub break_title: String, // Title shown on break screen (e.g., "Rest your eyes")
pub break_message: String, // Custom message shown during breaks
pub fullscreen_mode: bool, // Use fullscreen break window vs notification
pub strict_mode: bool, // Prevent user from skipping breaks
pub allow_end_early: bool, // Allow ending break after 50% completion
pub immediately_start_breaks: bool, // Skip pre-break notification, go straight to break
// Working hours (per-day schedule with multiple ranges)
pub working_hours_enabled: bool,
pub working_hours_schedule: Vec<DaySchedule>, // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
// Appearance
pub dark_mode: bool,
pub color_scheme: String,
pub backdrop_opacity: f32, // Break screen backdrop opacity (0.5-1.0)
// Notifications
pub notification_enabled: bool,
pub notification_before_break: u32, // Notify X seconds before break (0 = no notification)
// Advanced
pub snooze_duration: u32, // Duration of snooze in minutes (1-30)
pub snooze_limit: u32, // Max snoozes per break cycle (0 = unlimited, 1-5)
pub skip_cooldown: u32, // Minimum time between skip operations in seconds
// Sound
pub sound_enabled: bool, // Play sounds on break events
pub sound_volume: u32, // Volume 0-100
pub sound_preset: String, // Sound preset name: "bell", "chime", "soft", "digital"
// Idle detection
pub idle_detection_enabled: bool,
pub idle_timeout: u32, // Seconds of inactivity before auto-pause (30-600)
// Smart breaks - natural break detection
pub smart_breaks_enabled: bool,
pub smart_break_threshold: u32, // Seconds of idle to count as natural break (120-900)
pub smart_break_count_stats: bool, // Include natural breaks in statistics
// Break activities
pub show_break_activities: bool,
// UI
pub ui_zoom: u32, // UI zoom percentage (50-200, default 100)
pub accent_color: String, // Main accent color hex (e.g., "#ff4d00")
pub break_color: String, // Break screen ring color hex (e.g., "#7c6aef")
pub countdown_font: String, // Google Font family for countdown display (empty = system default)
pub background_blobs_enabled: bool, // Animated gradient blobs background
// Mini mode
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)
// Window positions (persisted between launches)
pub main_window_x: Option<i32>,
pub main_window_y: Option<i32>,
pub main_window_width: Option<u32>,
pub main_window_height: Option<u32>,
pub mini_window_x: Option<i32>,
pub mini_window_y: Option<i32>,
}
impl Default for Config {
fn default() -> Self {
Self {
// Timer settings
break_duration: 5,
break_frequency: 25,
auto_start: true,
// Break settings
break_title: "Rest your eyes".to_string(),
break_message: "Look away from the screen. Stretch and relax.".to_string(),
fullscreen_mode: true,
strict_mode: false,
allow_end_early: true,
immediately_start_breaks: false,
// Working hours
working_hours_enabled: false,
working_hours_schedule: vec![
DaySchedule::default(), // Monday
DaySchedule::default(), // Tuesday
DaySchedule::default(), // Wednesday
DaySchedule::default(), // Thursday
DaySchedule::default(), // Friday
DaySchedule::weekend_default(), // Saturday
DaySchedule::weekend_default(), // Sunday
],
// Appearance
dark_mode: true,
color_scheme: "Ocean".to_string(),
backdrop_opacity: 0.92,
// Notifications
notification_enabled: true,
notification_before_break: 30,
// Advanced
snooze_duration: 5,
snooze_limit: 3,
skip_cooldown: 60,
// Sound
sound_enabled: true,
sound_volume: 70,
sound_preset: "bell".to_string(),
// Idle detection
idle_detection_enabled: true,
idle_timeout: 120,
// Smart breaks
smart_breaks_enabled: true,
smart_break_threshold: 300, // 5 minutes
smart_break_count_stats: false,
// Break activities
show_break_activities: true,
// UI
ui_zoom: 100,
accent_color: "#ff4d00".to_string(),
break_color: "#7c6aef".to_string(),
countdown_font: String::new(),
background_blobs_enabled: false,
// Mini mode
mini_click_through: true,
mini_hover_threshold: 3.0,
// Window positions
main_window_x: None,
main_window_y: None,
main_window_width: None,
main_window_height: None,
mini_window_x: None,
mini_window_y: None,
}
}
}
impl Config {
/// Get the path to the config file (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,
Err(e) => {
eprintln!("Failed to load config, using defaults: {}", e);
let default = Self::default();
// Try to save the default config
if let Err(save_err) = default.save() {
eprintln!("Failed to save default config: {}", save_err);
}
default
}
}
}
/// Load configuration from file
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Err(anyhow::anyhow!("Config file does not exist"));
}
let config_content =
fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: Config =
serde_json::from_str(&config_content).context("Failed to parse config file")?;
// Validate and sanitize the loaded config
Ok(config.validate())
}
/// Save configuration to file
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
let config_content =
serde_json::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, config_content).context("Failed to write config file")?;
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);
// Break frequency: 5-120 minutes
self.break_frequency = self.break_frequency.clamp(5, 120);
// Notification before break: 0-300 seconds (0 = disabled)
self.notification_before_break = self.notification_before_break.clamp(0, 300);
// Snooze duration: 1-30 minutes
self.snooze_duration = self.snooze_duration.clamp(1, 30);
// Snooze limit: 0-5 (0 = unlimited)
self.snooze_limit = self.snooze_limit.clamp(0, 5);
// Skip cooldown: 0-600 seconds (0 = disabled)
self.skip_cooldown = self.skip_cooldown.clamp(0, 600);
// Backdrop opacity: 0.5-1.0
self.backdrop_opacity = self.backdrop_opacity.clamp(0.5, 1.0);
// Validate break title
if self.break_title.is_empty() {
self.break_title = "Rest your eyes".to_string();
} else if self.break_title.len() > 100 {
self.break_title.truncate(100);
}
// Validate working hours schedule
if self.working_hours_schedule.len() != 7 {
// Reset to default if invalid
self.working_hours_schedule = vec![
DaySchedule::default(), // Monday
DaySchedule::default(), // Tuesday
DaySchedule::default(), // Wednesday
DaySchedule::default(), // Thursday
DaySchedule::default(), // Friday
DaySchedule::weekend_default(), // Saturday
DaySchedule::weekend_default(), // Sunday
];
}
// Validate each day's ranges
for day in &mut self.working_hours_schedule {
// Ensure at least one range
if day.ranges.is_empty() {
day.ranges.push(TimeRange::default());
}
// Validate each range
for range in &mut day.ranges {
if !Self::is_valid_time_format(&range.start) {
range.start = "09:00".to_string();
}
if !Self::is_valid_time_format(&range.end) {
range.end = "18:00".to_string();
}
// Ensure start < end
let start_mins = Self::time_to_minutes(&range.start);
let end_mins = Self::time_to_minutes(&range.end);
if start_mins >= end_mins {
range.end = "18:00".to_string();
}
}
}
// Validate color scheme
let valid_schemes = vec!["Ocean", "Forest", "Sunset", "Midnight", "Dawn"];
if !valid_schemes.contains(&self.color_scheme.as_str()) {
self.color_scheme = "Ocean".to_string();
}
// Sound volume: 0-100
self.sound_volume = self.sound_volume.clamp(0, 100);
// Validate sound preset
let valid_presets = vec![
"bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle",
];
if !valid_presets.contains(&self.sound_preset.as_str()) {
self.sound_preset = "bell".to_string();
}
// Idle timeout: 30-600 seconds
self.idle_timeout = self.idle_timeout.clamp(30, 600);
// Smart break threshold: 120-900 seconds (2-15 minutes)
self.smart_break_threshold = self.smart_break_threshold.clamp(120, 900);
// Mini hover threshold: 1.0-10.0 seconds
self.mini_hover_threshold = self.mini_hover_threshold.clamp(1.0, 10.0);
// UI zoom: 50-200%
self.ui_zoom = self.ui_zoom.clamp(50, 200);
// Validate color hex strings
if !Self::is_valid_hex_color(&self.accent_color) {
self.accent_color = "#ff4d00".to_string();
}
if !Self::is_valid_hex_color(&self.break_color) {
self.break_color = "#7c6aef".to_string();
}
// Validate break message length
if self.break_message.is_empty() {
self.break_message = "Time for a break! Stretch and relax your eyes.".to_string();
} else if self.break_message.len() > 500 {
self.break_message.truncate(500);
}
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 {
return false;
}
if let (Ok(hours), Ok(minutes)) = (parts[0].parse::<u8>(), parts[1].parse::<u8>()) {
hours < 24 && minutes < 60
} else {
false
}
}
/// Convert HH:MM time string to minutes since midnight
pub fn time_to_minutes(time: &str) -> u32 {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 2 {
return 0;
}
let hours = parts[0].parse::<u32>().unwrap_or(0);
let minutes = parts[1].parse::<u32>().unwrap_or(0);
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();
}
}

688
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,688 @@
mod config;
mod stats;
mod timer;
use config::Config;
use stats::Stats;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::{TrayIcon, TrayIconBuilder},
AppHandle, Emitter, Manager, State,
};
use tauri_plugin_notification::NotificationExt;
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
pub struct AppState {
pub timer: Arc<Mutex<TimerManager>>,
pub stats: Arc<Mutex<Stats>>,
}
// ── Tauri Commands ──────────────────────────────────────────────────────────
#[tauri::command]
fn get_config(state: State<AppState>) -> Config {
let timer = state.timer.lock().unwrap();
timer.pending_config.clone()
}
#[tauri::command]
fn save_config(state: State<AppState>, config: Config) -> Result<(), String> {
let mut timer = state.timer.lock().unwrap();
timer.update_config(config);
timer.save_config()
}
#[tauri::command]
fn update_pending_config(app: AppHandle, state: State<AppState>, config: Config) {
let mut timer = state.timer.lock().unwrap();
timer.update_config(config);
// Notify all windows (break, mini) so they can pick up live config changes
let _ = app.emit("config-changed", &());
}
#[tauri::command]
fn reset_config(state: State<AppState>) -> Config {
let mut timer = state.timer.lock().unwrap();
timer.reset_config();
timer.pending_config.clone()
}
#[tauri::command]
fn toggle_timer(state: State<AppState>) -> TimerSnapshot {
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
timer.snapshot()
}
#[tauri::command]
fn start_break_now(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, fullscreen_mode) = {
let mut timer = state.timer.lock().unwrap();
let payload = timer.start_break_now();
let fm = payload.as_ref().map(|p| p.fullscreen_mode);
(timer.snapshot(), fm)
};
// Window creation must happen after dropping the mutex
if let Some(fm) = fullscreen_mode {
handle_break_start(&app, fm);
}
snapshot
}
#[tauri::command]
fn cancel_break(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, should_end) = {
let mut timer = state.timer.lock().unwrap();
let was_break = timer.state == timer::TimerState::BreakActive;
timer.cancel_break();
let ended = was_break && timer.state != timer::TimerState::BreakActive;
if ended {
let mut s = state.stats.lock().unwrap();
s.record_break_skipped();
}
(timer.snapshot(), ended)
};
if should_end {
handle_break_end(&app);
}
snapshot
}
#[tauri::command]
fn snooze(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, should_end) = {
let mut timer = state.timer.lock().unwrap();
let was_break = timer.state == timer::TimerState::BreakActive;
timer.snooze();
let ended = was_break && timer.state != timer::TimerState::BreakActive;
if ended {
let mut s = state.stats.lock().unwrap();
s.record_break_snoozed();
}
(timer.snapshot(), ended)
};
if should_end {
handle_break_end(&app);
}
snapshot
}
#[tauri::command]
fn get_timer_state(state: State<AppState>) -> TimerSnapshot {
let timer = state.timer.lock().unwrap();
timer.snapshot()
}
#[tauri::command]
fn set_view(state: State<AppState>, view: AppView) {
let mut timer = state.timer.lock().unwrap();
timer.set_view(view);
}
#[tauri::command]
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
let s = state.stats.lock().unwrap();
s.snapshot()
}
#[tauri::command]
fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord> {
let s = state.stats.lock().unwrap();
s.recent_days(days)
}
// ── Cursor / Window Position Commands ────────────────────────────────────
#[tauri::command]
fn get_cursor_position() -> (i32, i32) {
#[cfg(windows)]
{
use winapi::shared::windef::POINT;
use winapi::um::winuser::GetCursorPos;
let mut point = POINT { x: 0, y: 0 };
unsafe {
GetCursorPos(&mut point);
}
(point.x, point.y)
}
#[cfg(not(windows))]
{
(0, 0)
}
}
#[tauri::command]
fn save_window_position(
state: State<AppState>,
label: String,
x: i32,
y: i32,
width: u32,
height: u32,
) {
let mut timer = state.timer.lock().unwrap();
match label.as_str() {
"main" => {
timer.pending_config.main_window_x = Some(x);
timer.pending_config.main_window_y = Some(y);
timer.pending_config.main_window_width = Some(width);
timer.pending_config.main_window_height = Some(height);
}
"mini" => {
timer.pending_config.mini_window_x = Some(x);
timer.pending_config.mini_window_y = Some(y);
}
_ => {}
}
let _ = timer.save_config();
}
// ── 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);
let g = u8::from_str_radix(&hex[3..5], 16).unwrap_or(fallback.1);
let b = u8::from_str_radix(&hex[5..7], 16).unwrap_or(fallback.2);
(r, g, b)
} else {
fallback
}
}
/// 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),
) -> Vec<u8> {
let size: usize = 32;
let mut rgba = vec![0u8; size * size * 4];
let center = size as f64 / 2.0;
let outer_r = center - 1.0;
let inner_r = outer_r - 4.0;
let arc_color = if is_break { break_color } else { accent };
for y in 0..size {
for x in 0..size {
let dx = x as f64 - center;
let dy = y as f64 - center;
let dist = (dx * dx + dy * dy).sqrt();
let idx = (y * size + x) * 4;
if dist >= inner_r && dist <= outer_r {
// Determine angle (0 at top, clockwise)
let angle = (dx.atan2(-dy) + std::f64::consts::PI) / (2.0 * std::f64::consts::PI);
let in_arc = angle <= progress;
if in_arc {
rgba[idx] = arc_color.0;
rgba[idx + 1] = arc_color.1;
rgba[idx + 2] = arc_color.2;
rgba[idx + 3] = 255;
} else {
// Background ring
rgba[idx] = 60;
rgba[idx + 1] = 60;
rgba[idx + 2] = 60;
rgba[idx + 3] = if is_paused { 100 } else { 180 };
}
}
}
}
rgba
}
fn update_tray(
tray: &TrayIcon,
snapshot: &TimerSnapshot,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
) {
// Update tooltip
let tooltip = match snapshot.state {
timer::TimerState::Running => {
let m = snapshot.time_remaining / 60;
let s = snapshot.time_remaining % 60;
format!("Core Cooldown — {:02}:{:02} until break", m, s)
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown — Paused (idle)".to_string()
} else {
"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)
}
};
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 = Image::new_owned(icon_data, 32, 32);
let _ = tray.set_icon(Some(icon));
}
// ── App Builder ─────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.setup(|app| {
// Portable data directory for WebView2 data (next to the exe)
let data_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("data")))
.unwrap_or_else(|| std::path::PathBuf::from("data"));
// Create main window (was previously in tauri.conf.json)
tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Core Cooldown")
.inner_size(480.0, 700.0)
.decorations(false)
.transparent(true)
.shadow(true)
.data_directory(data_dir.clone())
.build()?;
// Create break window (hidden, was previously in tauri.conf.json)
tauri::WebviewWindowBuilder::new(
app,
"break",
tauri::WebviewUrl::App("index.html?break=1".into()),
)
.title("Core Cooldown \u{2014} Break")
.inner_size(900.0, 540.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.center()
.data_directory(data_dir.clone())
.build()?;
let timer_manager = TimerManager::new();
let loaded_stats = Stats::load_or_default();
let state = AppState {
timer: Arc::new(Mutex::new(timer_manager)),
stats: Arc::new(Mutex::new(loaded_stats)),
};
app.manage(state);
// Restore saved window position
{
let st = app.state::<AppState>();
let timer = st.timer.lock().unwrap();
let cfg = &timer.pending_config;
if let (Some(x), Some(y)) = (cfg.main_window_x, cfg.main_window_y) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_position(tauri::Position::Physical(
tauri::PhysicalPosition::new(x, y),
));
}
}
if let (Some(w), Some(h)) = (cfg.main_window_width, cfg.main_window_height) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
}
}
}
// Set up system tray
let tray = setup_tray(app.handle())?;
// Set up global shortcuts
setup_shortcuts(app.handle());
// Start the timer tick thread
let handle = app.handle().clone();
let timer_ref = app.state::<AppState>().timer.clone();
let stats_ref = app.state::<AppState>().stats.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
let (tick_result, snapshot, accent_hex, break_hex) = {
let mut timer = timer_ref.lock().unwrap();
let result = timer.tick();
let snap = timer.snapshot();
let ac = timer.config.accent_color.clone();
let bc = timer.config.break_color.clone();
(result, snap, ac, bc)
};
// 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);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot);
// 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);
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// Record completed break in stats
{
let timer = timer_ref.lock().unwrap();
let mut s = stats_ref.lock().unwrap();
s.record_break_completed(timer.break_total_duration);
}
let _ = handle
.notification()
.builder()
.title("Break complete")
.body("Great job! Back to work.")
.show();
let _ = handle.emit("break-ended", &());
}
TickResult::PreBreakWarning {
seconds_until_break,
} => {
let secs = seconds_until_break;
let msg = if secs >= 60 {
format!(
"Break in {} minute{}",
secs / 60,
if secs / 60 == 1 { "" } else { "s" }
)
} else {
format!("Break in {} seconds", secs)
};
let _ = handle
.notification()
.builder()
.title("Core Cooldown")
.body(&msg)
.show();
let _ = handle.emit("prebreak-warning", &secs);
}
TickResult::NaturalBreakDetected { duration_seconds } => {
// Record natural break in stats if enabled
{
let timer = timer_ref.lock().unwrap();
if timer.config.smart_break_count_stats {
let mut s = stats_ref.lock().unwrap();
s.record_natural_break(duration_seconds);
}
}
let mins = duration_seconds / 60;
let msg = if mins >= 1 {
format!(
"You've been away for {} minute{}. Break timer has been reset.",
mins,
if mins == 1 { "" } else { "s" }
)
} else {
format!(
"You've been away for {} seconds. Break timer has been reset.",
duration_seconds
)
};
let _ = handle
.notification()
.builder()
.title("Natural break detected")
.body(&msg)
.show();
let _ = handle.emit("natural-break-detected", &duration_seconds);
}
TickResult::None => {}
}
}
});
Ok(())
})
// Handle window close events - only exit when the main window is closed
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if window.label() == "main" {
window.app_handle().exit(0);
}
// Mini and break windows just close normally without killing the app
}
})
.invoke_handler(tauri::generate_handler![
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,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn setup_tray(app: &AppHandle) -> Result<TrayIcon, Box<dyn std::error::Error>> {
let show_i = MenuItemBuilder::with_id("show", "Show").build(app)?;
let pause_i = MenuItemBuilder::with_id("pause", "Pause/Resume").build(app)?;
let mini_i = MenuItemBuilder::with_id("mini", "Mini Mode").build(app)?;
let quit_i = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show_i)
.item(&pause_i)
.item(&mini_i)
.separator()
.item(&quit_i)
.build()?;
let tray = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Core Cooldown")
.on_menu_event(move |app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"pause" => {
let state: State<AppState> = app.state();
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
}
"mini" => {
toggle_mini_window(app);
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(tray)
}
// ── Global Shortcuts ───────────────────────────────────────────────────────
fn setup_shortcuts(app: &AppHandle) {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+P", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
let state: State<AppState> = _app.state();
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
}
});
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+B", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
let state: State<AppState> = _app.state();
let mut timer = state.timer.lock().unwrap();
let payload = timer.start_break_now();
if let Some(ref p) = payload {
handle_break_start(_app, p.fullscreen_mode);
}
}
});
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+S", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
if let Some(window) = _app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
});
}
// ── Break Window ────────────────────────────────────────────────────────────
fn open_break_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("break") {
let _ = win.show();
let _ = win.set_focus();
}
}
fn close_break_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("break") {
let _ = win.hide();
}
}
/// 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
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(true);
let _ = window.show();
let _ = window.set_focus();
let _ = window.set_fullscreen(true);
}
} else {
// Modal: open a separate centered break window
open_break_window(app);
}
}
/// 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);
let _ = window.set_fullscreen(false);
}
close_break_window(app);
}
// ── Mini Mode ──────────────────────────────────────────────────────────────
fn toggle_mini_window(app: &AppHandle) {
if let Some(mini) = app.get_webview_window("mini") {
// Close existing mini window
let _ = mini.close();
} else {
// Read saved position from config
let (mx, my) = {
let st: State<AppState> = app.state();
let timer = st.timer.lock().unwrap();
(
timer.pending_config.mini_window_x,
timer.pending_config.mini_window_y,
)
};
// Portable data directory for WebView2
let data_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("data")))
.unwrap_or_else(|| std::path::PathBuf::from("data"));
// Create mini window
let mut builder = tauri::WebviewWindowBuilder::new(
app,
"mini",
tauri::WebviewUrl::App("index.html?mini=1".into()),
)
.title("Core Cooldown")
.inner_size(184.0, 92.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.data_directory(data_dir);
if let (Some(x), Some(y)) = (mx, my) {
builder = builder.position(x as f64, y as f64);
}
let _ = builder.build();
}
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
core_cooldown_lib::run()
}

199
src-tauri/src/stats.rs Normal file
View File

@@ -0,0 +1,199 @@
use serde::{Deserialize, Serialize};
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 {
pub date: String, // YYYY-MM-DD
pub breaks_completed: u32,
pub breaks_skipped: u32,
pub breaks_snoozed: u32,
pub total_break_time_secs: u64,
pub natural_breaks: u32,
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>,
pub current_streak: u32,
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 {
pub today_completed: u32,
pub today_skipped: u32,
pub today_snoozed: u32,
pub today_break_time_secs: u64,
pub today_natural_breaks: u32,
pub today_natural_break_time_secs: u64,
pub compliance_rate: f64,
pub current_streak: u32,
pub best_streak: u32,
}
impl Stats {
/// Portable: stats file lives next to the exe
fn stats_path() -> Option<PathBuf> {
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
Some(exe_dir.join("stats.json"))
}
pub fn load_or_default() -> Self {
let data = if let Some(path) = Self::stats_path() {
if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
} else {
StatsData::default()
}
} else {
StatsData::default()
};
Stats { data }
}
fn save(&self) {
if let Some(path) = Self::stats_path() {
if let Ok(json) = serde_json::to_string_pretty(&self.data) {
let _ = fs::write(path, json);
}
}
}
fn today_key() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}
fn today_mut(&mut self) -> &mut DayRecord {
let key = Self::today_key();
self.data
.days
.entry(key.clone())
.or_insert_with(|| DayRecord {
date: key,
..Default::default()
})
}
pub fn record_break_completed(&mut self, duration_secs: u64) {
let day = self.today_mut();
day.breaks_completed += 1;
day.total_break_time_secs += duration_secs;
self.update_streak();
self.save();
}
pub fn record_break_skipped(&mut self) {
let day = self.today_mut();
day.breaks_skipped += 1;
self.save();
}
pub fn record_break_snoozed(&mut self) {
let day = self.today_mut();
day.breaks_snoozed += 1;
self.save();
}
pub fn record_natural_break(&mut self, duration_secs: u64) {
let day = self.today_mut();
day.natural_breaks += 1;
day.natural_break_time_secs += duration_secs;
self.save();
}
fn update_streak(&mut self) {
// Calculate streak: consecutive days with at least 1 break completed
let mut streak = 0u32;
let today = chrono::Local::now().date_naive();
for i in 0.. {
let day = today - chrono::Duration::days(i);
let key = day.format("%Y-%m-%d").to_string();
if let Some(record) = self.data.days.get(&key) {
if record.breaks_completed > 0 {
streak += 1;
} else {
break;
}
} else {
if i == 0 {
// Today hasn't been recorded yet (but we just did), continue
streak += 1;
} else {
break;
}
}
}
self.data.current_streak = streak;
if streak > self.data.best_streak {
self.data.best_streak = streak;
}
}
pub fn snapshot(&self) -> StatsSnapshot {
let key = Self::today_key();
let today = self.data.days.get(&key);
let completed = today.map(|d| d.breaks_completed).unwrap_or(0);
let skipped = today.map(|d| d.breaks_skipped).unwrap_or(0);
let snoozed = today.map(|d| d.breaks_snoozed).unwrap_or(0);
let break_time = today.map(|d| d.total_break_time_secs).unwrap_or(0);
let natural_breaks = today.map(|d| d.natural_breaks).unwrap_or(0);
let natural_break_time = today.map(|d| d.natural_break_time_secs).unwrap_or(0);
let total = completed + skipped;
let compliance = if total > 0 {
completed as f64 / total as f64
} else {
1.0
};
StatsSnapshot {
today_completed: completed,
today_skipped: skipped,
today_snoozed: snoozed,
today_break_time_secs: break_time,
today_natural_breaks: natural_breaks,
today_natural_break_time_secs: natural_break_time,
compliance_rate: compliance,
current_streak: self.data.current_streak,
best_streak: self.data.best_streak,
}
}
/// 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();
for i in (0..n).rev() {
let day = today - chrono::Duration::days(i as i64);
let key = day.format("%Y-%m-%d").to_string();
let record = self.data.days.get(&key).cloned().unwrap_or(DayRecord {
date: key,
..Default::default()
});
records.push(record);
}
records
}
}

496
src-tauri/src/timer.rs Normal file
View File

@@ -0,0 +1,496 @@
use crate::config::Config;
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TimerState {
Running,
Paused,
BreakActive,
}
impl Default for TimerState {
fn default() -> Self {
Self::Paused
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AppView {
Dashboard,
BreakScreen,
Settings,
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 {
pub state: TimerState,
pub current_view: AppView,
pub time_remaining: u64,
pub total_duration: u64,
pub progress: f64,
pub has_had_break: bool,
pub seconds_since_last_break: u64,
pub prebreak_warning: bool,
pub snoozes_used: u32,
pub can_snooze: bool,
pub break_title: String,
pub break_message: String,
pub break_progress: f64,
pub break_time_remaining: u64,
pub break_total_duration: u64,
pub break_past_half: bool,
pub settings_modified: bool,
pub idle_paused: bool,
pub natural_break_occurred: bool,
pub smart_breaks_enabled: bool,
pub smart_break_threshold: u32,
}
/// Events emitted by the timer to the frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BreakStartedPayload {
pub title: String,
pub message: String,
pub duration: u64,
pub strict_mode: bool,
pub snooze_duration: u32,
pub fullscreen_mode: bool,
}
pub struct TimerManager {
pub state: TimerState,
pub current_view: AppView,
pub config: Config,
pub time_until_next_break: u64,
pub time_until_break_end: u64,
pub break_total_duration: u64,
pub snoozes_used: u32,
pub seconds_since_last_break: u64,
pub has_had_break: bool,
pub prebreak_notification_active: bool,
pub settings_modified: bool,
// Pending config: edited in settings but not yet saved
pub pending_config: Config,
// Idle detection: true when auto-paused due to inactivity
pub idle_paused: bool,
// Smart breaks: track when idle started for natural break detection
pub idle_start_time: Option<Instant>,
pub natural_break_occurred: bool,
}
impl TimerManager {
pub fn new() -> Self {
let config = Config::load_or_default();
let freq = config.break_frequency_seconds();
let pending = config.clone();
let auto_start = config.auto_start;
Self {
state: if auto_start {
TimerState::Running
} else {
TimerState::Paused
},
current_view: AppView::Dashboard,
config,
time_until_next_break: freq,
time_until_break_end: 0,
break_total_duration: 0,
snoozes_used: 0,
seconds_since_last_break: 0,
has_had_break: false,
prebreak_notification_active: false,
settings_modified: false,
pending_config: pending,
idle_paused: false,
idle_start_time: None,
natural_break_occurred: 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
if self.idle_paused {
self.idle_paused = false;
self.state = TimerState::Running;
}
return IdleCheckResult::None;
}
let idle_secs = get_idle_seconds();
let threshold = self.config.idle_timeout as u64;
if idle_secs >= threshold && self.state == TimerState::Running {
// Just became idle - record start time for smart breaks
if self.idle_start_time.is_none() {
self.idle_start_time = Some(Instant::now());
}
self.idle_paused = true;
self.state = TimerState::Paused;
IdleCheckResult::JustPaused
} else if idle_secs < threshold && self.idle_paused {
// Just returned from idle
self.idle_paused = false;
self.state = TimerState::Running;
// Check if this was a natural break
if let Some(start_time) = self.idle_start_time {
let idle_duration = start_time.elapsed().as_secs();
self.idle_start_time = None;
if self.config.smart_breaks_enabled
&& self.state != TimerState::BreakActive
&& idle_duration >= self.config.smart_break_threshold as u64
{
// Natural break detected!
return IdleCheckResult::NaturalBreakDetected(idle_duration);
}
}
IdleCheckResult::JustResumed
} else {
// Still idle - update idle_start_time if needed
if self.state == TimerState::Paused
&& self.idle_start_time.is_none()
&& idle_secs >= threshold
{
// We were already paused when idle detection kicked in
// Start tracking from now
self.idle_start_time = Some(Instant::now());
}
IdleCheckResult::None
}
}
/// Called every second. Returns what events should be emitted.
pub fn tick(&mut self) -> TickResult {
// Idle detection and natural break detection
let idle_result = self.check_idle();
// Handle natural break detection
if let IdleCheckResult::NaturalBreakDetected(duration) = idle_result {
// Reset the timer completely since they took a natural break
self.reset_timer();
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.natural_break_occurred = true;
return TickResult::NaturalBreakDetected {
duration_seconds: duration,
};
}
// Track time since last break (always, even when paused)
if self.has_had_break && self.state != TimerState::BreakActive {
self.seconds_since_last_break += 1;
}
match self.state {
TimerState::Running => {
if !self.is_within_working_hours() {
return TickResult::None;
}
// Pre-break notification
let threshold = self.config.notification_before_break as u64;
let fire_prebreak = self.config.notification_enabled
&& !self.config.immediately_start_breaks
&& threshold > 0
&& self.time_until_next_break <= threshold
&& !self.prebreak_notification_active;
if fire_prebreak {
self.prebreak_notification_active = true;
}
if self.time_until_next_break > 0 {
self.time_until_next_break -= 1;
if fire_prebreak {
TickResult::PreBreakWarning {
seconds_until_break: self.time_until_next_break,
}
} else {
TickResult::None
}
} else {
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,
})
}
}
TimerState::BreakActive => {
if self.time_until_break_end > 0 {
self.time_until_break_end -= 1;
TickResult::None
} else {
// Break completed naturally
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.reset_timer();
TickResult::BreakEnded
}
}
TimerState::Paused => TickResult::None,
}
}
pub fn start_break(&mut self) {
self.state = TimerState::BreakActive;
self.current_view = AppView::BreakScreen;
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;
}
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;
}
pub fn toggle_timer(&mut self) {
match self.state {
TimerState::Running => {
self.state = TimerState::Paused;
}
TimerState::Paused => {
self.state = TimerState::Running;
}
_ => {}
}
}
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,
})
} else {
None
}
}
pub fn cancel_break(&mut self) -> bool {
if self.state != TimerState::BreakActive {
return false;
}
if self.config.strict_mode {
return false;
}
let total = self.break_total_duration;
let elapsed = total.saturating_sub(self.time_until_break_end);
let past_half = total > 0 && elapsed * 2 >= total;
if past_half && self.config.allow_end_early {
// "End break" — counts as completed
self.has_had_break = true;
self.seconds_since_last_break = 0;
self.reset_timer();
true
} else if !past_half {
// "Cancel break" — doesn't count
self.reset_timer();
true
} else {
false
}
}
pub fn snooze(&mut self) -> bool {
if self.config.strict_mode || !self.can_snooze() {
return false;
}
self.snoozes_used += 1;
self.time_until_next_break = self.config.snooze_duration_seconds();
self.state = TimerState::Running;
self.current_view = AppView::Dashboard;
self.prebreak_notification_active = false;
true
}
pub fn can_snooze(&self) -> bool {
if self.config.snooze_limit == 0 {
return true; // unlimited
}
self.snoozes_used < self.config.snooze_limit
}
pub fn is_within_working_hours(&self) -> bool {
if !self.config.working_hours_enabled {
return true;
}
let now = chrono::Local::now();
let day_of_week = now.weekday().num_days_from_monday() as usize; // 0 = Monday, 6 = Sunday
let current_minutes = now.hour() * 60 + now.minute();
// Get the schedule for today
if let Some(day_schedule) = self.config.working_hours_schedule.get(day_of_week) {
if !day_schedule.enabled {
return false; // Day is disabled, timer doesn't run
}
// Check if current time falls within any of the time ranges
for range in &day_schedule.ranges {
let start_mins = Config::time_to_minutes(&range.start);
let end_mins = Config::time_to_minutes(&range.end);
if current_minutes >= start_mins && current_minutes < end_mins {
return true;
}
}
}
false // Not within any time range
}
pub fn set_view(&mut self, view: AppView) {
self.current_view = view;
}
pub fn update_config(&mut self, config: Config) {
self.pending_config = config;
self.settings_modified = true;
}
pub fn save_config(&mut self) -> Result<(), String> {
let validated = self.pending_config.clone().validate();
validated.save().map_err(|e| e.to_string())?;
self.config = validated.clone();
self.pending_config = validated;
self.settings_modified = false;
Ok(())
}
pub fn reset_config(&mut self) {
self.pending_config = Config::default();
self.settings_modified = true;
}
pub fn snapshot(&self) -> TimerSnapshot {
let total_freq = self.config.break_frequency_seconds();
let timer_progress = if total_freq > 0 {
self.time_until_next_break as f64 / total_freq as f64
} else {
0.0
};
let break_elapsed = self
.break_total_duration
.saturating_sub(self.time_until_break_end);
let break_progress = if self.break_total_duration > 0 {
break_elapsed as f64 / self.break_total_duration as f64
} else {
0.0
};
let break_past_half =
self.break_total_duration > 0 && break_elapsed * 2 >= self.break_total_duration;
// Use pending_config for settings display, active config for timer
let display_config = if self.current_view == AppView::Settings {
&self.pending_config
} else {
&self.config
};
TimerSnapshot {
state: self.state,
current_view: self.current_view,
time_remaining: self.time_until_next_break,
total_duration: total_freq,
progress: timer_progress,
has_had_break: self.has_had_break,
seconds_since_last_break: self.seconds_since_last_break,
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_progress,
break_time_remaining: self.time_until_break_end,
break_total_duration: self.break_total_duration,
break_past_half,
settings_modified: self.settings_modified,
idle_paused: self.idle_paused,
natural_break_occurred: self.natural_break_occurred,
smart_breaks_enabled: display_config.smart_breaks_enabled,
smart_break_threshold: display_config.smart_break_threshold,
}
}
}
pub enum TickResult {
None,
BreakStarted(BreakStartedPayload),
BreakEnded,
PreBreakWarning { seconds_until_break: u64 },
NaturalBreakDetected { duration_seconds: u64 },
}
/// Result of checking idle state
pub enum IdleCheckResult {
None,
JustPaused,
JustResumed,
NaturalBreakDetected(u64), // duration in seconds
}
fn parse_hour(time_str: &str) -> u32 {
time_str
.split(':')
.next()
.and_then(|h| h.parse().ok())
.unwrap_or(9)
}
/// Returns the number of seconds since last user input (mouse/keyboard).
#[cfg(windows)]
pub fn get_idle_seconds() -> u64 {
use std::mem;
use winapi::um::sysinfoapi::GetTickCount;
use winapi::um::winuser::{GetLastInputInfo, LASTINPUTINFO};
unsafe {
let mut lii: LASTINPUTINFO = mem::zeroed();
lii.cbSize = mem::size_of::<LASTINPUTINFO>() as u32;
if GetLastInputInfo(&mut lii) != 0 {
let tick = GetTickCount();
let idle_ms = tick.wrapping_sub(lii.dwTime);
(idle_ms / 1000) as u64
} else {
0
}
}
}
#[cfg(not(windows))]
pub fn get_idle_seconds() -> u64 {
0
}

29
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,29 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "Core Cooldown",
"version": "0.1.0",
"identifier": "com.corecooldown.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

118
src/App.svelte Normal file
View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from "svelte";
import { fly, scale, fade } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { initTimerStore, currentView } from "./lib/stores/timer";
import { loadConfig, config } from "./lib/stores/config";
import Titlebar from "./lib/components/Titlebar.svelte";
import Dashboard from "./lib/components/Dashboard.svelte";
import BreakScreen from "./lib/components/BreakScreen.svelte";
import Settings from "./lib/components/Settings.svelte";
import StatsView from "./lib/components/StatsView.svelte";
import BackgroundBlobs from "./lib/components/BackgroundBlobs.svelte";
const appWindow = getCurrentWebviewWindow();
onMount(async () => {
await loadConfig();
await initTimerStore();
// Save window position on move/resize (debounced)
let posTimer: ReturnType<typeof setTimeout>;
const savePos = () => {
clearTimeout(posTimer);
posTimer = setTimeout(async () => {
try {
const pos = await appWindow.outerPosition();
const size = await appWindow.outerSize();
await invoke("save_window_position", {
label: "main", x: pos.x, y: pos.y,
width: size.width, height: size.height,
});
} catch {}
}, 500);
};
appWindow.onMoved(savePos);
appWindow.onResized(savePos);
});
// Apply UI zoom from config
const zoomScale = $derived($config.ui_zoom / 100);
// Track previous view for directional transitions
let previousView = $state<string>("dashboard");
$effect(() => {
const view = $currentView;
// Store previous for determining transition direction
return () => {
previousView = view;
};
});
// Transition parameters
const DURATION = 700;
const easing = cubicOut;
// 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(
$currentView === "breakScreen" && !$config.fullscreen_mode
? "dashboard"
: $currentView
);
</script>
<div class="relative h-full bg-black">
{#if $config.background_blobs_enabled}
<BackgroundBlobs accentColor={$config.accent_color} breakColor={$config.break_color} />
{/if}
<Titlebar />
<div
class="relative h-full overflow-hidden origin-top-left"
style="
transform: scale({zoomScale});
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
"
>
{#if effectiveView === "dashboard"}
<div
class="absolute inset-0"
in:fly={{ x: previousView === "settings" ? -200 : 0, duration: DURATION, easing, opacity: 0 }}
out:fly={{ x: previousView === "dashboard" ? -200 : 0, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<Dashboard />
</div>
{/if}
{#if effectiveView === "breakScreen"}
<div
class="absolute inset-0"
in:scale={{ start: 1.08, duration: DURATION, easing, opacity: 0 }}
out:scale={{ start: 0.95, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<BreakScreen />
</div>
{/if}
{#if effectiveView === "settings"}
<div
class="absolute inset-0"
in:fly={{ x: 200, duration: DURATION, easing, opacity: 0 }}
out:fly={{ x: 200, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<Settings />
</div>
{/if}
{#if effectiveView === "stats"}
<div
class="absolute inset-0"
in:fly={{ y: 200, duration: DURATION, easing, opacity: 0 }}
out:fly={{ y: 200, duration: DURATION * 0.6, easing, opacity: 0 }}
>
<StatsView />
</div>
{/if}
</div>
</div>

57
src/app.css Normal file
View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
@theme {
--color-bg: #000000;
--color-surface: #0e0e0e;
--color-card: #141414;
--color-card-lt: #1c1c1c;
--color-border: #222222;
--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-text-pri: #ffffff;
--color-text-sec: #777777;
--color-text-dim: #3a3a3a;
--color-caption-bg: #050505;
}
html,
body {
margin: 0;
padding: 0;
background: #000000;
color: var(--color-text-pri);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
overflow: hidden;
height: 100%;
user-select: none;
-webkit-user-select: none;
}
#app {
height: 100%;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #222;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #333;
}

View File

@@ -0,0 +1,111 @@
<script lang="ts">
interface Props {
accentColor: string;
breakColor: string;
}
let { accentColor, breakColor }: Props = $props();
</script>
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<!-- Gradient blobs -->
<div
class="blob blob-1"
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-2"
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-3"
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
></div>
<div
class="blob blob-4"
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
></div>
<!-- Film grain overlay -->
<svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;">
<filter id="grain-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch">
<animate
attributeName="seed"
from="0"
to="100"
dur="2s"
repeatCount="indefinite"
/>
</feTurbulence>
</filter>
<rect width="100%" height="100%" filter="url(#grain-filter)" />
</svg>
</div>
<style>
.blob {
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
will-change: transform;
}
.blob-1 {
top: -15%;
left: -10%;
animation: drift-1 30s ease-in-out infinite;
}
.blob-2 {
bottom: -15%;
right: -10%;
animation: drift-2 35s ease-in-out infinite;
}
.blob-3 {
top: 40%;
right: -15%;
width: 250px;
height: 250px;
animation: drift-3 28s ease-in-out infinite;
}
.blob-4 {
bottom: 20%;
left: -15%;
width: 200px;
height: 200px;
animation: drift-4 32s ease-in-out infinite;
}
@keyframes drift-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
20% { transform: translate(300px, 200px) scale(1.15); }
40% { transform: translate(150px, 450px) scale(0.9); }
60% { transform: translate(350px, 350px) scale(1.1); }
80% { transform: translate(100px, 100px) scale(0.95); }
}
@keyframes drift-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
20% { transform: translate(-250px, -150px) scale(1.1); }
40% { transform: translate(-100px, -400px) scale(0.95); }
60% { transform: translate(-300px, -200px) scale(1.15); }
80% { transform: translate(-50px, -300px) scale(0.9); }
}
@keyframes drift-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(-300px, -200px) scale(1.1); }
50% { transform: translate(-150px, 150px) scale(0.9); }
75% { transform: translate(-350px, -50px) scale(1.15); }
}
@keyframes drift-4 {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(280px, -180px) scale(1.15); }
50% { transform: translate(100px, 200px) scale(0.9); }
75% { transform: translate(320px, 50px) scale(1.1); }
}
</style>

View File

@@ -0,0 +1,417 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable } from "../utils/animate";
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
interface Props {
standalone?: boolean;
}
let { standalone = false }: Props = $props();
const appWindow = standalone ? getCurrentWebviewWindow() : null;
let currentActivity = $state<BreakActivity>(pickRandomActivity());
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);
}, 30_000);
}
return () => {
if (activityCycleTimer) {
clearInterval(activityCycleTimer);
activityCycleTimer = null;
}
};
});
async function cancelBreak() {
const snap = await invoke<TimerSnapshot>("cancel_break");
timer.set(snap);
if (standalone) {
appWindow?.hide();
} else {
currentView.set(snap.currentView);
}
}
async function snoozeBreak() {
const snap = await invoke<TimerSnapshot>("snooze");
timer.set(snap);
// If snooze ended the break, hide standalone window
if (standalone && snap.state !== "breakActive") {
appWindow?.hide();
}
}
const breakRingProgress = $derived(
$timer.breakTotalDuration > 0
? $timer.breakTimeRemaining / $timer.breakTotalDuration
: 0,
);
const cancelBtnText = $derived(
$timer.breakPastHalf && $config.allow_end_early ? "End break" : "Skip",
);
const showButtons = $derived(!$config.strict_mode);
// Bottom progress bar uses a gradient from break color to accent
const barGradient = $derived(
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
);
const isModal = $derived(!$config.fullscreen_mode && !standalone);
</script>
{#if standalone}
<!-- ── Standalone break window: horizontal card, transparent background ── -->
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
<!-- Ripples emanate from the ring, visible outside the card -->
<div class="standalone-ring-area">
<div class="ripple-container">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
</div>
<div class="break-breathe">
<TimerRing
progress={breakRingProgress}
size={140}
strokeWidth={5}
accentColor={$config.break_color}
>
<div class="break-breathe-counter">
<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>
</div>
</TimerRing>
</div>
</div>
<!-- Right side: text + buttons -->
<div class="standalone-content">
<h2 class="text-[17px] font-medium text-white mb-1.5">
{$timer.breakTitle}
</h2>
<p class="text-[12px] leading-relaxed text-[#666] 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-[#555] uppercase mb-1">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[12px] leading-relaxed text-[#999]">
{currentActivity.text}
</p>
</div>
{/if}
{#if showButtons}
<div class="flex items-center gap-2.5">
<button
use:pressable
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
tracking-wider text-[#666] uppercase
transition-colors duration-200
hover:border-[#444] hover:text-[#aaa]"
onclick={cancelBreak}
>
{cancelBtnText}
</button>
{#if $timer.canSnooze}
<button
use:pressable
class="rounded-full px-5 py-2 text-[11px]
tracking-wider text-white uppercase
transition-colors duration-200"
style="background: rgba(255,255,255,0.08);"
onclick={snoozeBreak}
>
Snooze
</button>
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-2 text-[9px] text-[#333]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{/if}
</div>
<!-- Bottom progress bar with clip-path -->
<div class="standalone-progress-container">
<div class="standalone-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
></div>
</div>
</div>
</div>
{:else}
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
<div
class="relative h-full"
class:flex={isModal}
class:items-center={isModal}
class:justify-center={isModal}
style={isModal ? `background: #000;` : ""}
>
<div
class="relative flex flex-col"
class:h-full={!isModal}
class:break-modal={isModal}
>
<!-- Break ring with breathing pulse + ripples -->
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
</div>
<div class="break-breathe relative">
<TimerRing
progress={breakRingProgress}
size={isModal ? 160 : 200}
strokeWidth={isModal ? 5 : 6}
accentColor={$config.break_color}
>
<div class="break-breathe-counter">
<span
class="font-semibold leading-none tabular-nums text-white"
class:text-[30px]={isModal}
class:text-[38px]={!isModal}
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.breakTimeRemaining)}
</span>
</div>
</TimerRing>
</div>
</div>
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
{$timer.breakTitle}
</h2>
<p
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
class:mb-4={$config.show_break_activities}
class:mb-8={!$config.show_break_activities}
use:fadeIn={{ delay: 0.35, y: 10 }}
>
{$timer.breakMessage}
</p>
{#if $config.show_break_activities}
<div
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-[#444] uppercase">
{getCategoryLabel(currentActivity.category)}
</div>
<p class="text-[13px] leading-relaxed text-[#999]">
{currentActivity.text}
</p>
</div>
{/if}
{#if showButtons}
<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-[#555] uppercase
transition-colors duration-200
hover:border-[#333] hover:text-[#999]"
onclick={cancelBreak}
>
{cancelBtnText}
</button>
{#if $timer.canSnooze}
<button
use:pressable
class="rounded-full px-6 py-2.5 text-[12px]
tracking-wider text-white uppercase backdrop-blur-xl
transition-colors duration-200"
style="background: rgba(20,20,20,0.7);"
onclick={snoozeBreak}
>
Snooze
</button>
{/if}
</div>
{#if $config.snooze_limit > 0}
<p class="mt-3 text-[10px] text-[#2a2a2a]">
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
</p>
{/if}
{/if}
<!-- Bottom progress bar for modal - uses clip-path to respect border radius -->
<div class="break-modal-progress-container">
<div class="break-modal-progress-track">
<div
class="h-full transition-[width] duration-1000 ease-linear"
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
></div>
</div>
</div>
</div>
</div>
{/if}
<style>
/* ── Standalone horizontal card ── */
.standalone-card {
position: relative;
display: flex;
align-items: center;
gap: 28px;
background: rgba(12, 12, 12, 0.97);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 24px;
padding: 32px 36px 32px 32px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.3),
0 20px 60px rgba(0, 0, 0, 0.5),
0 0 80px rgba(0, 0, 0, 0.3);
overflow: visible;
}
.standalone-ring-area {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 140px;
height: 140px;
}
.standalone-content {
flex: 1;
min-width: 0;
}
/* Standalone progress bar - positioned inside the padding area */
.standalone-progress-container {
position: absolute;
bottom: 28px;
left: 28px;
right: 28px;
height: 3px;
border-radius: 2px;
overflow: hidden;
}
.standalone-progress-track {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.03);
}
/* Ripple container — sits behind the ring, overflows the card */
.ripple-container {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* ── Modal card when fullscreen is off (in-app) ── */
.break-modal {
position: relative;
background: #0a0a0a;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 28px;
padding: 40px 32px 32px;
max-width: 400px;
width: 90%;
box-shadow: 0 24px 100px rgba(0, 0, 0, 0.7);
overflow: hidden;
isolation: isolate;
}
/* Progress bar - positioned inside padding to avoid rounded corner overflow */
.break-modal-progress-container {
position: absolute;
bottom: 32px;
left: 32px;
right: 32px;
height: 3px;
border-radius: 2px;
overflow: hidden;
}
.break-modal-progress-track {
width: 100%;
height: 100%;
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;
width: 140px;
height: 140px;
border-radius: 50%;
border: 1.5px solid var(--ripple-color);
opacity: 0;
animation: ripple-expand 4s ease-out infinite;
}
.ripple-1 { animation-delay: 0s; }
.ripple-2 { animation-delay: 1.33s; }
.ripple-3 { animation-delay: 2.66s; }
@keyframes ripple-expand {
0% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import { initTimerStore } from "../stores/timer";
import { loadConfig, config } from "../stores/config";
import BreakScreen from "./BreakScreen.svelte";
const appWindow = getCurrentWebviewWindow();
// Base window dimensions (from tauri.conf.json)
const BASE_W = 900;
const BASE_H = 540;
let ready = $state(false);
onMount(async () => {
await loadConfig();
await initTimerStore();
ready = true;
// Auto-hide when break ends (window is persistent, just hide it)
await listen("break-ended", () => {
appWindow.hide();
});
// Live-reload config when main window changes settings (zoom, colors, etc.)
await listen("config-changed", () => {
loadConfig();
});
});
const zoomScale = $derived($config.ui_zoom / 100);
// Resize the actual Tauri window when zoom changes
$effect(() => {
const scale = $config.ui_zoom / 100;
const w = Math.round(BASE_W * scale);
const h = Math.round(BASE_H * scale);
appWindow.setSize(new LogicalSize(w, h));
appWindow.center();
});
</script>
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div
style="
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
transform: scale({zoomScale});
transform-origin: center center;
display: flex;
align-items: center;
justify-content: center;
"
>
{#if ready}
<BreakScreen standalone={true} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
value: string;
label: string;
presets?: string[];
onchange?: (color: string) => void;
}
let {
value = $bindable(),
label,
presets = [
"#ff4d00", "#ff6b35", "#e63946", "#d62828",
"#f77f00", "#fcbf49", "#2ec4b6", "#3fb950",
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
"#f72585", "#ff006e", "#ffffff", "#888888",
],
onchange,
}: Props = $props();
let showCustom = $state(false);
let hue = $state(0);
let sat = $state(100);
let light = $state(50);
let hexInput = $state(value);
let draggingSL = $state(false);
let draggingHue = $state(false);
// Pointer ID tracking for proper cleanup
let slPointerId = $state<number | null>(null);
let huePointerId = $state<number | null>(null);
// Parse current value into HSL on mount
$effect(() => {
const hsl = hexToHsl(value);
if (hsl) {
hue = hsl.h;
sat = hsl.s;
light = hsl.l;
}
hexInput = value;
});
function selectPreset(color: string) {
value = color;
hexInput = color;
showCustom = false;
const hsl = hexToHsl(color);
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
onchange?.(color);
}
function updateFromHSL() {
const hex = hslToHex(hue, sat, light);
value = hex;
hexInput = hex;
onchange?.(hex);
}
function onHexInput(e: Event) {
const input = (e.target as HTMLInputElement).value;
hexInput = input;
if (/^#[0-9a-fA-F]{6}$/.test(input)) {
value = input;
const hsl = hexToHsl(input);
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
onchange?.(input);
}
}
// --- SL pad interaction (Pointer Events) ---
let slPad = $state<HTMLDivElement>(undefined!);
function handleSLPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
draggingSL = true;
slPointerId = e.pointerId;
// Capture pointer to keep receiving events even outside element
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
// Update immediately on down
updateSLFromPointer(e);
}
function handleSLPointerMove(e: PointerEvent) {
if (!draggingSL || !slPad || e.pointerId !== slPointerId) return;
e.preventDefault();
updateSLFromPointer(e);
}
function updateSLFromPointer(e: PointerEvent) {
if (!slPad) return;
const rect = slPad.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
sat = Math.round(x * 100);
light = Math.round((1 - y) * 100);
updateFromHSL();
}
function handleSLPointerUp(e: PointerEvent) {
if (!draggingSL || e.pointerId !== slPointerId) return;
e.preventDefault();
draggingSL = false;
slPointerId = null;
}
// --- Hue bar interaction (Pointer Events) ---
let hueBar = $state<HTMLDivElement>(undefined!);
function handleHuePointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
draggingHue = true;
huePointerId = e.pointerId;
// Capture pointer to keep receiving events even outside element
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
// Update immediately on down
updateHueFromPointer(e);
}
function handleHuePointerMove(e: PointerEvent) {
if (!draggingHue || !hueBar || e.pointerId !== huePointerId) return;
e.preventDefault();
updateHueFromPointer(e);
}
function updateHueFromPointer(e: PointerEvent) {
if (!hueBar) return;
const rect = hueBar.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
hue = Math.round(x * 360);
updateFromHSL();
}
function handleHuePointerUp(e: PointerEvent) {
if (!draggingHue || e.pointerId !== huePointerId) return;
e.preventDefault();
draggingHue = false;
huePointerId = null;
}
// --- Color conversion helpers ---
function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return null;
let r = parseInt(hex.slice(1, 3), 16) / 255;
let g = parseInt(hex.slice(3, 5), 16) / 255;
let b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToHex(h: number, s: number, l: number): string {
const sl = s / 100, ll = l / 100;
const a = sl * Math.min(ll, 1 - ll);
function f(n: number) {
const k = (n + h / 30) % 12;
const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
}
return `#${f(0)}${f(8)}${f(4)}`;
}
const isPreset = $derived(presets.includes(value));
// SL cursor position
const slX = $derived(sat);
const slY = $derived(100 - light);
</script>
<div class="flex flex-col gap-3">
<!-- Header row: label + current color preview -->
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">{label}</div>
<div class="font-mono text-[11px] text-[#444]">{value}</div>
</div>
<div
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
style="background: {value}; --tw-shadow-color: {value}40;"
></div>
</div>
<!-- Preset swatches -->
<div class="flex flex-wrap gap-[6px]">
{#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};"
onclick={() => selectPreset(color)}
aria-label="Select {color}"
></button>
{/each}
<!-- 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);"
onclick={() => { showCustom = !showCustom; }}
aria-label="Custom color"
></button>
</div>
<!-- Inline custom picker — slides open/closed -->
{#if showCustom}
<div
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
transition:slide={{ duration: 280, easing: cubicOut }}
>
<!-- Saturation / Lightness pad -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={slPad}
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
style="background: linear-gradient(to right, hsl({hue}, 0%, 50%), hsl({hue}, 100%, 50%));"
onpointerdown={handleSLPointerDown}
onpointermove={handleSLPointerMove}
onpointerup={handleSLPointerUp}
onpointercancel={handleSLPointerUp}
role="application"
aria-label="Saturation and lightness"
tabindex="-1"
>
<!-- Lightness overlay: white at top, black at bottom -->
<div
class="absolute inset-0"
style="background: linear-gradient(to bottom, hsl({hue}, 100%, 100%), transparent, hsl({hue}, 100%, 0%));"
></div>
<!-- Cursor -->
<div
class="pointer-events-none absolute h-[14px] w-[14px] -translate-x-1/2 -translate-y-1/2 rounded-full
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style="left: {slX}%; top: {slY}%; background: {value};"
></div>
</div>
<!-- Hue bar -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={hueBar}
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
style="background: linear-gradient(to right,
hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%));"
onpointerdown={handleHuePointerDown}
onpointermove={handleHuePointerMove}
onpointerup={handleHuePointerUp}
onpointercancel={handleHuePointerUp}
role="application"
aria-label="Hue"
tabindex="-1"
>
<!-- Hue cursor -->
<div
class="pointer-events-none absolute top-1/2 h-[12px] w-[12px] -translate-x-1/2 -translate-y-1/2 rounded-full
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"
style="left: {(hue / 360) * 100}%; background: hsl({hue}, 100%, 50%);"
></div>
</div>
<!-- Hex input -->
<input
type="text"
class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px]
font-mono text-white outline-none
placeholder:text-[#333] focus:border-[#333]"
placeholder="#ff4d00"
value={hexInput}
oninput={onHexInput}
maxlength={7}
/>
</div>
{/if}
</div>

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
timer,
currentView,
formatTime,
formatDurationAgo,
type TimerSnapshot,
} from "../stores/timer";
import { config } from "../stores/config";
import TimerRing from "./TimerRing.svelte";
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
async function toggleTimer() {
const snap = await invoke<TimerSnapshot>("toggle_timer");
timer.set(snap);
}
async function startBreakNow() {
const snap = await invoke<TimerSnapshot>("start_break_now");
timer.set(snap);
currentView.set(snap.currentView);
}
function openSettings() {
invoke("set_view", { view: "settings" });
currentView.set("settings");
}
const statusText = $derived(
$timer.idlePaused
? "IDLE"
: $timer.prebreakWarning
? "BREAK SOON"
: $timer.state === "running"
? "FOCUS"
: "PAUSED",
);
const toggleBtnText = $derived(
$timer.state === "running" ? "PAUSE" : "START",
);
// Responsive ring scaling computed from window dimensions
let windowW = $state(window.innerWidth);
let windowH = $state(window.innerHeight);
// Ring scales 1.0 → 0.6 based on both dimensions
const ringScale = $derived(
Math.min(1, Math.max(0.6, Math.min(
(windowH - 300) / 400,
(windowW - 200) / 300,
))),
);
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
const textCounterScale = $derived(
ringScale < 1
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
: 1,
);
// 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.
const ringSize = 280;
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
const ringMargin = $derived(targetGap - phantomBelow);
// Natural break notification
let showNaturalBreakToast = $state(false);
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for natural break detection
$effect(() => {
if ($timer.naturalBreakOccurred) {
showNaturalBreakToast = true;
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
naturalBreakToastTimeout = setTimeout(() => {
showNaturalBreakToast = false;
}, 5000);
}
});
</script>
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
<div class="relative flex h-full flex-col items-center justify-center">
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
<TimerRing
progress={$timer.progress}
size={280}
strokeWidth={8}
accentColor={$config.accent_color}
>
<!-- Counter-scale wrapper: text shrinks less than ring -->
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
<!-- Eye icon -->
<svg
class="mx-auto mb-3 eye-blink"
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="#444"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<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>
<!-- Time display -->
<span
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
>
{formatTime($timer.timeRemaining)}
</span>
<div class="h-3"></div>
<!-- Status label -->
<span
class="block text-center text-[11px] font-medium tracking-[0.25em]"
class:text-[#444]={!$timer.prebreakWarning &&
$timer.state === "running"}
class:text-[#333]={!$timer.prebreakWarning &&
$timer.state === "paused"}
class:text-warning={$timer.prebreakWarning}
>
{statusText}
</span>
</div>
</TimerRing>
</div>
</div>
<!-- Last break info -->
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
{#if $timer.hasHadBreak}
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
</p>
{:else}
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
{/if}
</div>
<!-- Natural break notification toast -->
{#if showNaturalBreakToast}
<div
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 }}
>
<div class="flex items-center gap-2">
<svg 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>
</div>
</div>
{/if}
<!-- Pause / Start button -->
<button
use:fadeIn={{ delay: 0.3, y: 12 }}
use:pressable
use:glowHover={{ color: $config.accent_color }}
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
tracking-[0.2em] text-white uppercase backdrop-blur-xl
transition-colors duration-200"
style="background: rgba(20,20,20,0.7);"
onclick={toggleTimer}
>
{toggleBtnText}
</button>
<!-- 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-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={startBreakNow}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</button>
</div>
<!-- Bottom center: stats -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
<button
aria-label="Statistics"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={() => {
invoke("set_view", { view: "stats" });
currentView.set("stats");
}}
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="7" width="4" height="14" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
</button>
</div>
<!-- Bottom right: settings -->
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
<button
aria-label="Settings"
use:pressable
class="flex h-11 w-11 items-center justify-center rounded-full
border border-[#222] text-[#383838]
transition-colors duration-200
hover:border-[#333] hover:text-[#666]"
onclick={openSettings}
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<style>
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
.eye-blink {
transform-origin: center center;
animation: blink 4s infinite ease-in-out;
}
@keyframes blink {
0%, 45%, 55%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.1);
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
value: string;
onchange?: (font: string) => void;
}
let {
value = $bindable(),
onchange,
}: Props = $props();
const fonts = [
{ family: "", label: "System Default" },
{ family: "JetBrains Mono", label: "JetBrains Mono" },
{ family: "Space Mono", label: "Space Mono" },
{ family: "Roboto Mono", label: "Roboto Mono" },
{ family: "Fira Code", label: "Fira Code" },
{ family: "IBM Plex Mono", label: "IBM Plex Mono" },
{ family: "Source Code Pro", label: "Source Code Pro" },
{ family: "Share Tech Mono", label: "Share Tech Mono" },
{ family: "Major Mono Display", label: "Major Mono" },
{ family: "Azeret Mono", label: "Azeret Mono" },
{ family: "DM Mono", label: "DM Mono" },
{ family: "Inconsolata", label: "Inconsolata" },
{ family: "Ubuntu Mono", label: "Ubuntu Mono" },
{ family: "Overpass Mono", label: "Overpass Mono" },
{ family: "Red Hat Mono", label: "Red Hat Mono" },
{ family: "Martian Mono", label: "Martian Mono" },
{ family: "Noto Sans Mono", label: "Noto Sans Mono" },
{ family: "Oxygen Mono", label: "Oxygen Mono" },
{ family: "Anonymous Pro", label: "Anonymous Pro" },
{ family: "Courier Prime", label: "Courier Prime" },
];
function selectFont(family: string) {
value = family;
onchange?.(family);
}
function fontStyle(family: string): string {
return family ? `font-family: '${family}', monospace;` : "";
}
let expanded = $state(false);
</script>
<div class="flex flex-col gap-3">
<!-- Header row -->
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Countdown font</div>
<div class="text-[11px] text-[#555]">
{value || "System default"}
</div>
</div>
<button
type="button"
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#666]
transition-colors hover:border-[#333] hover:text-white"
onclick={() => { expanded = !expanded; }}
>
{expanded ? "Close" : "Browse"}
</button>
</div>
<!-- Font preview grid -->
{#if expanded}
<div
class="grid grid-cols-2 gap-2"
transition:slide={{ duration: 280, easing: cubicOut }}
>
{#each fonts as font}
<button
type="button"
class="flex flex-col items-center gap-1.5 rounded-xl border p-3
transition-all duration-150
{value === font.family
? 'border-white/30 bg-[#141414]'
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}"
onclick={() => selectFont(font.family)}
>
<span
class="text-[28px] leading-none tabular-nums text-white"
style={fontStyle(font.family)}
>
25:00
</span>
<span class="text-[9px] tracking-wider text-[#555] uppercase">
{font.label}
</span>
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import type { TimerSnapshot } from "../stores/timer";
import { config, loadConfig } from "../stores/config";
const appWindow = getCurrentWebviewWindow();
let timeText = $state("--:--");
let state = $state<"running" | "paused" | "breakActive">("paused");
let progress = $state(0);
let accentColor = $state("#ff4d00");
let breakColor = $state("#7c6aef");
let countdownFont = $state("");
let draggable = $state(false);
// Use config store directly for live updates
const uiZoom = $derived($config.ui_zoom);
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")}`;
}
// Sync local color/font state from the config store
function applyConfigLocals() {
accentColor = $config.accent_color || "#ff4d00";
breakColor = $config.break_color || "#7c6aef";
countdownFont = $config.countdown_font || "";
}
onMount(async () => {
// Get initial state
try {
const snap = await invoke<TimerSnapshot>("get_timer_state");
updateFromSnapshot(snap);
} catch (e) {
console.error("Mini: Failed to get state", e);
}
// Load config into the shared config store
await loadConfig();
applyConfigLocals();
// Live-reload config when main window changes settings (zoom, colors, etc.)
await listen("config-changed", async () => {
await loadConfig();
applyConfigLocals();
});
// Listen for ticks
await listen<TimerSnapshot>("timer-tick", (event) => {
updateFromSnapshot(event.payload);
});
// Save position on move (debounced)
let posTimer: ReturnType<typeof setTimeout>;
appWindow.onMoved(() => {
clearTimeout(posTimer);
posTimer = setTimeout(async () => {
try {
const pos = await appWindow.outerPosition();
await invoke("save_window_position", {
label: "mini", x: pos.x, y: pos.y, width: 184, height: 92,
});
} catch {}
}, 500);
});
});
// Click-through mode: reactive to config changes
$effect(() => {
const isClickThrough = $config.mini_click_through;
const threshold = $config.mini_hover_threshold;
if (!isClickThrough) {
appWindow.setIgnoreCursorEvents(false);
draggable = true;
return;
}
appWindow.setIgnoreCursorEvents(true);
draggable = false;
let hoverTime = 0;
let localDraggable = false;
const POLL_MS = 200;
const interval = setInterval(async () => {
try {
const [cx, cy] = await invoke<[number, number]>("get_cursor_position");
const pos = await appWindow.outerPosition();
const size = await appWindow.outerSize();
const inside =
cx >= pos.x && cx <= pos.x + size.width &&
cy >= pos.y && cy <= pos.y + size.height;
if (inside) {
hoverTime += POLL_MS / 1000;
if (hoverTime >= threshold && !localDraggable) {
localDraggable = true;
draggable = true;
await appWindow.setIgnoreCursorEvents(false);
}
} else {
if (localDraggable) {
localDraggable = false;
draggable = false;
await appWindow.setIgnoreCursorEvents(true);
}
hoverTime = 0;
}
} catch {}
}, POLL_MS);
return () => clearInterval(interval);
});
function updateFromSnapshot(snap: TimerSnapshot) {
state = snap.state;
if (snap.state === "breakActive") {
timeText = formatTime(snap.breakTimeRemaining);
progress = snap.breakTotalDuration > 0
? snap.breakTimeRemaining / snap.breakTotalDuration
: 0;
} else {
timeText = formatTime(snap.timeRemaining);
progress = snap.progress;
}
}
// Click opens main window
async function openMain() {
try {
await invoke("set_view", { view: "dashboard" });
} catch {}
}
// Drag support
function startDrag(e: MouseEvent) {
if (e.button === 0) {
appWindow.startDragging();
}
}
// Ring SVG computations
const ringSize = 34;
const strokeW = 3;
const pad = 16;
const viewSize = ringSize + pad * 2;
const radius = (ringSize - strokeW) / 2;
const circumference = 2 * Math.PI * radius;
const dashOffset = $derived(circumference * (1 - progress));
const ctr = viewSize / 2;
const activeColor = $derived(state === "breakActive" ? breakColor : accentColor);
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, "0")}${Math.round(lg).toString(16).padStart(2, "0")}${Math.round(lb).toString(16).padStart(2, "0")}`;
}
const gradA = $derived(activeColor);
const gradB = $derived(lighten(activeColor, 0.25));
const fontStyle = $derived(
countdownFont ? `font-family: '${countdownFont}', monospace;` : ""
);
const zoomScale = $derived(uiZoom / 100);
// Base window dimensions (matches lib.rs toggle_mini_window)
const MINI_BASE_W = 184;
const MINI_BASE_H = 92;
// Resize the actual Tauri window when zoom changes
$effect(() => {
const scale = uiZoom / 100;
const w = Math.round(MINI_BASE_W * scale);
const h = Math.round(MINI_BASE_H * scale);
appWindow.setSize(new LogicalSize(w, h));
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="w-full h-full flex items-center justify-center overflow-hidden">
<div
style="
width: {100 / zoomScale}%;
height: {100 / zoomScale}%;
transform: scale({zoomScale});
transform-origin: center center;
"
>
<div
class="flex items-center justify-center w-full h-full"
style="padding: 22px 14px 22px 24px;"
>
<div
class="mini-pill flex h-full w-full items-center select-none"
class:mini-draggable={draggable}
style="
background: rgba(0, 0, 0, 0.85);
border-radius: 9999px;
border: 1px solid {draggable ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'};
backdrop-filter: blur(12px);
cursor: {draggable ? 'grab' : 'default'};
padding: 0 12px 0 5px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
{draggable ? `box-shadow: 0 0 12px rgba(255, 255, 255, 0.08);` : ''}
"
onmousedown={draggable ? startDrag : undefined}
ondblclick={draggable ? openMain : undefined}
>
<!-- Mini ring with glow -->
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
<!-- Glow SVG (larger for blur room) -->
<svg
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="left: {-pad}px; top: {-pad}px; transform: rotate(-90deg); overflow: visible;"
>
<defs>
<linearGradient id="mini-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="100%" stop-color={gradB} />
</linearGradient>
<filter id="mini-glow-wide" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="8" />
</filter>
<filter id="mini-glow-mid" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" />
</filter>
<filter id="mini-glow-core" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-wide)"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-mid)"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core tight glow -->
<circle
cx={ctr} cy={ctr} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#mini-glow-core)"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG: track + crisp ring -->
<svg
width={ringSize}
height={ringSize}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke={state === "paused" ? "#1a1a1a" : "#161616"}
stroke-width={strokeW}
/>
{#if progress > 0.002}
<!-- Foreground arc -->
<circle
cx={ringSize / 2} cy={ringSize / 2} r={radius}
fill="none"
stroke="url(#mini-grad)"
stroke-width={strokeW}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
</div>
<!-- Countdown text -->
<span
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}"
>
{timeText}
</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,801 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { timer, currentView } from "../stores/timer";
import { config, autoSave, loadConfig, resetConfig } from "../stores/config";
import ToggleSwitch from "./ToggleSwitch.svelte";
import Stepper from "./Stepper.svelte";
import ColorPicker from "./ColorPicker.svelte";
import FontSelector from "./FontSelector.svelte";
import TimeSpinner from "./TimeSpinner.svelte";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
import { playSound } from "../utils/sounds";
import type { TimeRange } from "../stores/config";
const soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const;
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
function goBack() {
invoke("set_view", { view: "dashboard" });
currentView.set("dashboard");
}
function markChanged() {
autoSave();
}
// Working hours functions
function toggleDayEnabled(dayIndex: number) {
$config.working_hours_schedule[dayIndex].enabled = !$config.working_hours_schedule[dayIndex].enabled;
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function addTimeRange(dayIndex: number) {
$config.working_hours_schedule[dayIndex].ranges = [
...$config.working_hours_schedule[dayIndex].ranges,
{ start: "09:00", end: "18:00" }
];
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function removeTimeRange(dayIndex: number, rangeIndex: number) {
if ($config.working_hours_schedule[dayIndex].ranges.length > 1) {
$config.working_hours_schedule[dayIndex].ranges = $config.working_hours_schedule[dayIndex].ranges.filter((_, i) => i !== rangeIndex);
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
}
function cloneTimeRange(dayIndex: number, rangeIndex: number) {
const range = $config.working_hours_schedule[dayIndex].ranges[rangeIndex];
$config.working_hours_schedule[dayIndex].ranges = [
...$config.working_hours_schedule[dayIndex].ranges,
{ ...range }
];
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
function updateTimeRange(dayIndex: number, rangeIndex: number, field: "start" | "end", value: string) {
$config.working_hours_schedule[dayIndex].ranges[rangeIndex][field] = value;
$config.working_hours_schedule = $config.working_hours_schedule;
markChanged();
}
// Reset button two-click confirmation
let resetConfirming = $state(false);
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
function handleReset() {
if (!resetConfirming) {
resetConfirming = true;
// Auto-cancel after 3 seconds
resetTimeout = setTimeout(() => {
resetConfirming = false;
}, 3000);
} else {
resetConfirming = false;
if (resetTimeout) clearTimeout(resetTimeout);
resetConfig();
autoSave();
}
}
// Reload config when entering settings
$effect(() => {
loadConfig();
});
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div
data-tauri-drag-region
class="flex items-center px-5 pt-5 pb-4"
use:fadeIn={{ duration: 0.4, y: 8 }}
>
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white"
onclick={goBack}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<h1
data-tauri-drag-region
class="flex-1 text-lg font-medium text-white"
>
Settings
</h1>
</div>
<!-- Scrollable content with drag scroll -->
<div
class="settings-scroll-container flex-1 overflow-y-auto px-5 pb-6"
use:dragScroll
>
<div class="space-y-3">
<!-- Timer -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Timer
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break frequency</div>
<div class="text-[11px] text-[#777]">
Every {$config.break_frequency} min
</div>
</div>
<Stepper
bind:value={$config.break_frequency}
min={5}
max={120}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Break duration</div>
<div class="text-[11px] text-[#777]">
{$config.break_duration} min
</div>
</div>
<Stepper
bind:value={$config.break_duration}
min={1}
max={60}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-start</div>
<div class="text-[11px] text-[#777]">Start timer on launch</div>
</div>
<ToggleSwitch
bind:checked={$config.auto_start}
onchange={markChanged}
/>
</div>
</section>
<!-- Break Screen -->
<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-[#666] uppercase"
>
Break Screen
</h3>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-title">
Break title
</label>
<input
id="break-title"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder="Enter break title..."
bind:value={$config.break_title}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex flex-col gap-1.5">
<label class="text-[13px] text-white" for="break-message">
Break message
</label>
<input
id="break-message"
type="text"
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
text-white outline-none transition-colors
placeholder:text-[#2a2a2a] focus:border-[#333]"
placeholder="Enter break message..."
bind:value={$config.break_message}
oninput={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Fullscreen break</div>
<div class="text-[11px] text-[#777]">
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
</div>
</div>
<ToggleSwitch
bind:checked={$config.fullscreen_mode}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Activity suggestions</div>
<div class="text-[11px] text-[#777]">
Exercise ideas during breaks
</div>
</div>
<ToggleSwitch
bind:checked={$config.show_break_activities}
onchange={markChanged}
/>
</div>
</section>
<!-- Behavior -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Behavior
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Strict mode</div>
<div class="text-[11px] text-[#777]">
Disable skip and snooze
</div>
</div>
<ToggleSwitch
bind:checked={$config.strict_mode}
onchange={markChanged}
/>
</div>
{#if !$config.strict_mode}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Allow end early</div>
<div class="text-[11px] text-[#777]">After 50% of break</div>
</div>
<ToggleSwitch
bind:checked={$config.allow_end_early}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze duration</div>
<div class="text-[11px] text-[#777]">
{$config.snooze_duration} min
</div>
</div>
<Stepper
bind:value={$config.snooze_duration}
min={1}
max={30}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Snooze limit</div>
<div class="text-[11px] text-[#777]">
{$config.snooze_limit === 0
? "Unlimited"
: `${$config.snooze_limit} per break`}
</div>
</div>
<Stepper
bind:value={$config.snooze_limit}
min={0}
max={5}
formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
onchange={markChanged}
/>
</div>
{/if}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Immediate breaks</div>
<div class="text-[11px] text-[#777]">
Skip pre-break warning
</div>
</div>
<ToggleSwitch
bind:checked={$config.immediately_start_breaks}
onchange={markChanged}
/>
</div>
</section>
<!-- Working Hours -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Working hours</div>
<div class="text-[11px] text-[#777]">
Only show breaks during your configured work schedule
</div>
</div>
<ToggleSwitch
bind:checked={$config.working_hours_enabled}
onchange={markChanged}
/>
</div>
{#if $config.working_hours_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
{#each $config.working_hours_schedule as daySchedule, dayIndex}
{@const dayName = daysOfWeek[dayIndex]}
<div class="mb-4">
<!-- Day header with toggle -->
<div class="flex items-center gap-3 mb-3">
<ToggleSwitch
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
onchange={markChanged}
/>
<span class="text-[13px] text-white w-20">{dayName}</span>
</div>
{#if daySchedule.enabled}
<!-- Time ranges for this day -->
<div class="space-y-2">
{#each daySchedule.ranges as range, rangeIndex}
<div class="flex items-center gap-2">
<TimeSpinner
value={range.start}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
/>
<span class="text-[#555] text-[13px]">to</span>
<TimeSpinner
value={range.end}
countdownFont={$config.countdown_font}
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
/>
<!-- Add range button -->
{#if rangeIndex === daySchedule.ranges.length - 1}
<button
use:pressable
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => addTimeRange(dayIndex)}
aria-label="Add time range"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
{/if}
<!-- Clone button -->
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
aria-label="Clone time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<!-- Delete button (never show for first range) -->
{#if rangeIndex > 0}
<button
use:pressable
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
aria-label="Remove time range"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if dayIndex < 6}
<div class="my-4 h-px bg-[#161616]"></div>
{/if}
{/each}
{/if}
</section>
<!-- Idle Detection -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Idle Detection
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Auto-pause when idle</div>
<div class="text-[11px] text-[#777]">Pause timer when away</div>
</div>
<ToggleSwitch
bind:checked={$config.idle_detection_enabled}
onchange={markChanged}
/>
</div>
{#if $config.idle_detection_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Idle timeout</div>
<div class="text-[11px] text-[#777]">
{$config.idle_timeout}s of inactivity
</div>
</div>
<Stepper
bind:value={$config.idle_timeout}
min={30}
max={600}
step={30}
formatValue={(v) => `${v}s`}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Smart Breaks -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Smart Breaks
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Enable smart breaks</div>
<div class="text-[11px] text-[#777]">Auto-reset timer when you step away</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_breaks_enabled}
onchange={markChanged}
/>
</div>
{#if $config.smart_breaks_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Minimum away time</div>
<div class="text-[11px] text-[#777]">
{$config.smart_break_threshold >= 60
? `${Math.floor($config.smart_break_threshold / 60)} min`
: `${$config.smart_break_threshold}s`} to count as break
</div>
</div>
<Stepper
bind:value={$config.smart_break_threshold}
min={120}
max={900}
step={60}
formatValue={(v) => `${Math.floor(v / 60)}m`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Count in statistics</div>
<div class="text-[11px] text-[#777]">Track natural breaks in stats</div>
</div>
<ToggleSwitch
bind:checked={$config.smart_break_count_stats}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Notifications -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Notifications
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Pre-break alert</div>
<div class="text-[11px] text-[#777]">Warn before breaks</div>
</div>
<ToggleSwitch
bind:checked={$config.notification_enabled}
onchange={markChanged}
/>
</div>
{#if $config.notification_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Alert timing</div>
<div class="text-[11px] text-[#777]">
{$config.notification_before_break}s before
</div>
</div>
<Stepper
bind:value={$config.notification_before_break}
min={0}
max={300}
step={10}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Sound -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Sound
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Sound effects</div>
<div class="text-[11px] text-[#777]">Play sounds on break events</div>
</div>
<ToggleSwitch
bind:checked={$config.sound_enabled}
onchange={markChanged}
/>
</div>
{#if $config.sound_enabled}
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Volume</div>
<div class="text-[11px] text-[#777]">{$config.sound_volume}%</div>
</div>
<Stepper
bind:value={$config.sound_volume}
min={0}
max={100}
step={10}
formatValue={(v) => `${v}%`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<div>
<div class="mb-3 text-[13px] text-white">Sound preset</div>
<div class="grid grid-cols-4 gap-2">
{#each soundPresets as preset}
<button
use:pressable
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
transition-all duration-200
{$config.sound_preset === preset
? 'bg-[#1a1a1a] text-white border border-[#333]'
: 'bg-[#0a0a0a] text-[#555] border border-[#161616] hover:border-[#333] hover:text-[#999]'}"
onclick={() => {
$config.sound_preset = preset;
markChanged();
playSound(preset, $config.sound_volume);
}}
>
{preset}
</button>
{/each}
</div>
</div>
{/if}
</section>
<!-- Appearance -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Appearance
</h3>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">UI zoom</div>
<div class="text-[11px] text-[#777]">
{$config.ui_zoom}%
</div>
</div>
<Stepper
bind:value={$config.ui_zoom}
min={50}
max={200}
step={5}
formatValue={(v) => `${v}%`}
onchange={markChanged}
/>
</div>
<div class="my-4 h-px bg-[#161616]"></div>
<ColorPicker
label="Accent color"
bind:value={$config.accent_color}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<ColorPicker
label="Break screen color"
bind:value={$config.break_color}
presets={[
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
"#2ec4b6", "#06d6a0", "#3fb950", "#80ed99",
"#f72585", "#ff006e", "#e63946", "#ff4d00",
"#fca311", "#ffbe0b", "#ffffff", "#888888",
]}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<FontSelector
bind:value={$config.countdown_font}
onchange={markChanged}
/>
<div class="my-4 h-px bg-[#161616]"></div>
<div class="flex items-center">
<div class="flex-1">
<div class="text-[13px] text-white">Animated background</div>
<div class="text-[11px] text-[#777]">
Gradient blobs with film grain
</div>
</div>
<ToggleSwitch
bind:checked={$config.background_blobs_enabled}
onchange={markChanged}
/>
</div>
</section>
<!-- Mini Mode -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Mini Mode
</h3>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Click-through</div>
<div class="text-[11px] text-[#555]">
Mini timer ignores clicks until you hover over it
</div>
</div>
<ToggleSwitch
bind:checked={$config.mini_click_through}
onchange={markChanged}
/>
</div>
{#if $config.mini_click_through}
<div class="mt-4 flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Hover delay</div>
<div class="text-[11px] text-[#555]">
Seconds to hover before it becomes draggable
</div>
</div>
<Stepper
bind:value={$config.mini_hover_threshold}
min={1}
max={10}
step={0.5}
formatValue={(v) => `${v}s`}
onchange={markChanged}
/>
</div>
{/if}
</section>
<!-- Shortcuts -->
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
<h3
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
>
Keyboard Shortcuts
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Pause / Resume</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+P</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Start break now</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+B</kbd>
</div>
<div class="flex items-center justify-between">
<span class="text-[13px] text-white">Show / Hide window</span>
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+S</kbd>
</div>
</div>
</section>
<!-- Reset -->
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}>
<button
use:pressable
class="w-full rounded-full border py-3 text-[12px]
tracking-wider uppercase
transition-all duration-200
{resetConfirming
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
: 'border-[#1a1a1a] text-[#444] hover:border-[#333] hover:text-white'}"
onclick={handleReset}
>
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { currentView } from "../stores/timer";
import { config } from "../stores/config";
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
interface StatsSnapshot {
todayCompleted: number;
todaySkipped: number;
todaySnoozed: number;
todayBreakTimeSecs: number;
complianceRate: number;
currentStreak: number;
bestStreak: number;
}
interface DayRecord {
date: string;
breaksCompleted: number;
breaksSkipped: number;
breaksSnoozed: number;
totalBreakTimeSecs: number;
}
let stats = $state<StatsSnapshot | null>(null);
let history = $state<DayRecord[]>([]);
async function loadStats() {
try {
stats = await invoke<StatsSnapshot>("get_stats");
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
} catch (e) {
console.error("Failed to load stats:", e);
}
}
$effect(() => {
loadStats();
});
function goBack() {
invoke("set_view", { view: "dashboard" });
currentView.set("dashboard");
}
const compliancePercent = $derived(
stats ? Math.round(stats.complianceRate * 100) : 100,
);
const breakTimeFormatted = $derived(() => {
if (!stats) return "0 min";
const mins = Math.floor(stats.todayBreakTimeSecs / 60);
if (mins < 60) return `${mins} min`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs}h ${rem}m`;
});
// Chart rendering
let chartCanvas: HTMLCanvasElement | undefined = $state();
$effect(() => {
if (!chartCanvas || history.length === 0) return;
drawChart(chartCanvas, history);
});
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
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 chartHeight = h - 30;
const accentColor = $config.accent_color || "#ff4d00";
data.forEach((day, i) => {
const x = 20 + i * (barWidth + barGap);
const total = day.breaksCompleted + day.breaksSkipped;
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);
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);
ctx.fill();
}
// Day label
ctx.fillStyle = "#444";
ctx.font = "10px -apple-system, sans-serif";
ctx.textAlign = "center";
const label = day.date.slice(5); // "MM-DD"
ctx.fillText(label, x + barWidth / 2, h - 5);
});
}
function roundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
r = Math.min(r, h / 2, w / 2);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
</script>
<div class="flex h-full flex-col">
<!-- Header -->
<div
data-tauri-drag-region
class="flex items-center px-5 pt-5 pb-4"
use:fadeIn={{ duration: 0.4, y: 8 }}
>
<button
aria-label="Back to dashboard"
use:pressable
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
text-[#444] transition-colors hover:text-white"
onclick={goBack}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<h1
data-tauri-drag-region
class="flex-1 text-lg font-medium text-white"
>
Statistics
</h1>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
<div class="space-y-3">
<!-- 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-[#666] uppercase"
>
Today
</h3>
<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-[#777]">Breaks taken</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold tabular-nums"
style="color: {$config.accent_color}"
>
{compliancePercent}%
</div>
<div class="text-[11px] text-[#777]">Compliance</div>
</div>
<div class="text-center">
<div class="text-[28px] font-semibold text-white tabular-nums">
{breakTimeFormatted()}
</div>
<div class="text-[11px] text-[#777]">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-[#777]">Skipped</div>
</div>
</div>
</section>
<!-- 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-[#666] uppercase"
>
Streak
</h3>
<div class="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Current streak</div>
<div class="text-[11px] text-[#777]">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="flex items-center justify-between">
<div>
<div class="text-[13px] text-white">Best streak</div>
<div class="text-[11px] text-[#777]">All-time record</div>
</div>
<div class="text-[24px] font-semibold text-white tabular-nums">
{stats?.bestStreak ?? 0}
</div>
</div>
</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-[#666] uppercase"
>
Last 7 Days
</h3>
<canvas
bind:this={chartCanvas}
class="h-[140px] w-full"
></canvas>
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
<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>
</div>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
formatValue?: (v: number) => string;
onchange?: (value: number) => void;
}
let {
value = $bindable(),
min = 0,
max = 100,
step = 1,
formatValue = (v: number) => String(v),
onchange,
}: Props = $props();
let holdTimer: ReturnType<typeof setTimeout> | null = null;
let holdInterval: ReturnType<typeof setInterval> | null = null;
function decrement() {
if (value > min) {
value = Math.max(min, value - step);
onchange?.(value);
}
}
function increment() {
if (value < max) {
value = Math.min(max, value + step);
onchange?.(value);
}
}
function startHold(fn: () => void) {
fn();
// Initial delay before repeating
holdTimer = setTimeout(() => {
// Start repeating, accelerate over time
let delay = 150;
function tick() {
fn();
delay = Math.max(40, delay * 0.85);
holdInterval = setTimeout(tick, delay) as unknown as ReturnType<typeof setInterval>;
}
tick();
}, 400);
}
function stopHold() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
}
</script>
<div class="flex items-center gap-1.5">
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(decrement)}
onmouseup={stopHold}
onmouseleave={stopHold}
disabled={value <= min}
>
&minus;
</button>
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white">
{formatValue(value)}
</span>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-lg
bg-[#141414] text-[#444] transition-colors
hover:bg-[#1c1c1c] hover:text-white
disabled:opacity-20"
onmousedown={() => startHold(increment)}
onmouseup={stopHold}
onmouseleave={stopHold}
disabled={value >= max}
>
+
</button>
</div>

View File

@@ -0,0 +1,399 @@
<script lang="ts">
interface Props {
value: string;
onchange?: (value: string) => void;
countdownFont?: string;
}
let { value, onchange, countdownFont = "" }: Props = $props();
// 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
// Fractional offset for smooth wheel rotation (0 to <1)
let hoursFraction = $state(0);
let minutesFraction = $state(0);
// Sync display from prop when NOT dragging/animating
$effect(() => {
if (!isAnimating) {
displayHours = parseInt(value.split(":")[0]) || 0;
displayMinutes = parseInt(value.split(":")[1]) || 0;
hoursFraction = 0;
minutesFraction = 0;
}
});
// ── Drag state ──
let hoursDragging = $state(false);
let hoursLastY = $state(0);
let hoursAccumPx = $state(0);
let hoursBaseValue = $state(0);
let hoursVelocity = $state(0);
let hoursLastMoveTime = $state(0);
let minutesDragging = $state(false);
let minutesLastY = $state(0);
let minutesAccumPx = $state(0);
let minutesBaseValue = $state(0);
let minutesVelocity = $state(0);
let minutesLastMoveTime = $state(0);
// Momentum animation handle
let momentumRaf: number | null = null;
const SENSITIVITY = 20; // pixels per value step
const ITEM_ANGLE = 30; // degrees between items on the cylinder
const WHEEL_RADIUS = 26; // cylinder radius in px
const FRICTION = 0.93; // momentum decay per frame
const MIN_VELOCITY = 0.3; // px/frame threshold to stop momentum
function wrapValue(v: number, max: number): number {
return ((v % max) + max) % max;
}
function emitValue(h: number, m: number) {
onchange?.(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
}
function stopMomentum() {
if (momentumRaf !== null) {
cancelAnimationFrame(momentumRaf);
momentumRaf = null;
}
}
// Build the visible items for the 3D wheel
function getWheelItems(current: number, fraction: number, maxVal: number) {
const items = [];
for (let i = -2; i <= 2; i++) {
const val = wrapValue(current + i, maxVal);
const angle = (i - fraction) * ITEM_ANGLE;
const absAngle = Math.abs(angle);
// Center item bright, off-center very dim
const isCenter = absAngle < ITEM_ANGLE * 0.5;
const opacity = isCenter
? Math.max(0.7, 1 - absAngle / 60)
: Math.max(0.05, 0.35 - absAngle / 120);
items.push({ value: val, angle, opacity });
}
return items;
}
const hoursItems = $derived(getWheelItems(displayHours, hoursFraction, 24));
const minutesItems = $derived(getWheelItems(displayMinutes, minutesFraction, 60));
// ── Shared step logic (used by both drag and momentum) ──
function applyAccum(
field: "hours" | "minutes",
accumPx: number,
baseValue: number,
): { newVal: number; fraction: number } {
const totalSteps = accumPx / SENSITIVITY;
const wholeSteps = Math.round(totalSteps);
const fraction = totalSteps - wholeSteps;
const maxVal = field === "hours" ? 24 : 60;
const newVal = wrapValue(baseValue + wholeSteps, maxVal);
return { newVal, fraction };
}
// ── Hours pointer handlers ──
function handleHoursPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
stopMomentum();
hoursDragging = true;
isAnimating = true;
hoursLastY = e.clientY;
hoursAccumPx = 0;
hoursBaseValue = displayHours;
hoursVelocity = 0;
hoursLastMoveTime = performance.now();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handleHoursPointerMove(e: PointerEvent) {
if (!hoursDragging) return;
e.preventDefault();
const dy = hoursLastY - e.clientY;
hoursAccumPx += dy;
hoursLastY = e.clientY;
// Track velocity (px/ms)
const now = performance.now();
const dt = now - hoursLastMoveTime;
if (dt > 0) hoursVelocity = dy / dt;
hoursLastMoveTime = now;
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
hoursFraction = fraction;
if (newVal !== displayHours) {
displayHours = newVal;
emitValue(newVal, displayMinutes);
}
}
function handleHoursPointerUp(e: PointerEvent) {
if (!hoursDragging) return;
e.preventDefault();
hoursDragging = false;
// Launch momentum if velocity is significant
const velocityPxPerFrame = hoursVelocity * 16; // convert px/ms to px/frame (~16ms)
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
startMomentum("hours", velocityPxPerFrame);
} else {
hoursFraction = 0;
isAnimating = false;
}
}
// ── Minutes pointer handlers ──
function handleMinutesPointerDown(e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
stopMomentum();
minutesDragging = true;
isAnimating = true;
minutesLastY = e.clientY;
minutesAccumPx = 0;
minutesBaseValue = displayMinutes;
minutesVelocity = 0;
minutesLastMoveTime = performance.now();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function handleMinutesPointerMove(e: PointerEvent) {
if (!minutesDragging) return;
e.preventDefault();
const dy = minutesLastY - e.clientY;
minutesAccumPx += dy;
minutesLastY = e.clientY;
const now = performance.now();
const dt = now - minutesLastMoveTime;
if (dt > 0) minutesVelocity = dy / dt;
minutesLastMoveTime = now;
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
minutesFraction = fraction;
if (newVal !== displayMinutes) {
displayMinutes = newVal;
emitValue(displayHours, newVal);
}
}
function handleMinutesPointerUp(e: PointerEvent) {
if (!minutesDragging) return;
e.preventDefault();
minutesDragging = false;
const velocityPxPerFrame = minutesVelocity * 16;
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
startMomentum("minutes", velocityPxPerFrame);
} else {
minutesFraction = 0;
isAnimating = false;
}
}
// ── Momentum animation ──
function startMomentum(field: "hours" | "minutes", velocity: number) {
stopMomentum();
function tick() {
velocity *= FRICTION;
if (Math.abs(velocity) < MIN_VELOCITY) {
// Snap to nearest value
if (field === "hours") hoursFraction = 0;
else minutesFraction = 0;
isAnimating = false;
momentumRaf = null;
return;
}
if (field === "hours") {
hoursAccumPx += velocity;
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
hoursFraction = fraction;
if (newVal !== displayHours) {
displayHours = newVal;
emitValue(newVal, displayMinutes);
}
} else {
minutesAccumPx += velocity;
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
minutesFraction = fraction;
if (newVal !== displayMinutes) {
displayMinutes = newVal;
emitValue(displayHours, newVal);
}
}
momentumRaf = requestAnimationFrame(tick);
}
momentumRaf = requestAnimationFrame(tick);
}
function format(n: number): string {
return String(n).padStart(2, "0");
}
</script>
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
<!-- Hours wheel -->
<div
class="wheel-field"
class:dragging={hoursDragging}
role="slider"
aria-label="Hours"
aria-valuemin={0}
aria-valuemax={23}
aria-valuenow={displayHours}
tabindex={0}
onpointerdown={handleHoursPointerDown}
onpointermove={handleHoursPointerMove}
onpointerup={handleHoursPointerUp}
onpointercancel={handleHoursPointerUp}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
{#each hoursItems as item}
<div
class="wheel-item"
style="
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
opacity: {item.opacity};
"
>
{format(item.value)}
</div>
{/each}
</div>
</div>
<span class="unit-badge">h</span>
</div>
<span class="separator">:</span>
<!-- Minutes wheel -->
<div
class="wheel-field"
class:dragging={minutesDragging}
role="slider"
aria-label="Minutes"
aria-valuemin={0}
aria-valuemax={59}
aria-valuenow={displayMinutes}
tabindex={0}
onpointerdown={handleMinutesPointerDown}
onpointermove={handleMinutesPointerMove}
onpointerup={handleMinutesPointerUp}
onpointercancel={handleMinutesPointerUp}
>
<div class="wheel-viewport">
<div class="wheel-cylinder">
{#each minutesItems as item}
<div
class="wheel-item"
style="
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
opacity: {item.opacity};
"
>
{format(item.value)}
</div>
{/each}
</div>
</div>
<span class="unit-badge">m</span>
</div>
</div>
<style>
.time-spinner {
display: inline-flex;
align-items: center;
gap: 4px;
user-select: none;
touch-action: none;
}
.wheel-field {
position: relative;
width: 50px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: ns-resize;
touch-action: none;
overflow: hidden;
}
.wheel-field.dragging {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
}
/* Perspective container — looking into the cylinder from outside */
.wheel-viewport {
width: 100%;
height: 100%;
perspective: 90px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* The 3D cylinder that holds number items */
.wheel-cylinder {
position: relative;
width: 100%;
height: 0;
transform-style: preserve-3d;
}
/* Individual number on the cylinder surface */
.wheel-item {
position: absolute;
width: 100%;
text-align: center;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
line-height: 1;
backface-visibility: hidden;
pointer-events: none;
padding-right: 12px;
}
/* Unit label pinned to the right of the field */
.unit-badge {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
}
.separator {
color: rgba(255, 255, 255, 0.25);
font-weight: 500;
user-select: none;
}
</style>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
interface Props {
progress: number;
size?: number;
strokeWidth?: number;
accentColor?: string;
children?: import("svelte").Snippet;
}
let {
progress,
size = 280,
strokeWidth = 8,
accentColor = "#ff4d00",
children,
}: Props = $props();
const padding = 40;
const viewSize = $derived(size + padding * 2);
const radius = $derived((size - strokeWidth) / 2);
const circumference = $derived(2 * Math.PI * radius);
const dashOffset = $derived(circumference * (1 - progress));
const center = $derived(viewSize / 2);
// Unique filter IDs based on color to avoid SVG collisions
const fid = $derived(`ring-${size}-${accentColor.replace('#', '')}`);
// Derive lighter shade for gradient end
function lighten(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lr = Math.min(255, r + (255 - r) * amount);
const lg = Math.min(255, g + (255 - g) * amount);
const lb = Math.min(255, b + (255 - b) * amount);
return `#${Math.round(lr).toString(16).padStart(2, '0')}${Math.round(lg).toString(16).padStart(2, '0')}${Math.round(lb).toString(16).padStart(2, '0')}`;
}
const gradA = $derived(accentColor);
const gradB = $derived(lighten(accentColor, 0.2));
const gradC = $derived(lighten(accentColor, 0.4));
</script>
<div
class="relative flex items-center justify-center"
style="width: {size}px; height: {size}px;"
>
<!-- Glow SVG — drawn larger than the container so blur isn't clipped -->
<svg
width={viewSize}
height={viewSize}
class="pointer-events-none absolute"
style="
left: {-padding}px;
top: {-padding}px;
transform: rotate(-90deg);
overflow: visible;
"
>
<defs>
<linearGradient id="ring-grad-{fid}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color={gradA} />
<stop offset="50%" stop-color={gradB} />
<stop offset="100%" stop-color={gradC} />
</linearGradient>
<!-- Wide ambient glow filter -->
<filter id="glow-wide-{fid}" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="28" />
</filter>
<!-- Medium glow filter -->
<filter id="glow-mid-{fid}" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="12" />
</filter>
<!-- Core tight glow filter -->
<filter id="glow-core-{fid}" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{#if progress > 0.002}
<!-- Layer 1: Wide ambient bloom -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 4}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-wide-{fid})"
opacity="0.35"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 2: Medium glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth * 2}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-mid-{fid})"
opacity="0.5"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
<!-- Layer 3: Core ring with tight glow -->
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
filter="url(#glow-core-{fid})"
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Non-glow SVG — exact size, draws the track + crisp ring -->
<svg
width={size}
height={size}
class="absolute"
style="transform: rotate(-90deg);"
>
<!-- Background track -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#161616"
stroke-width={strokeWidth}
/>
{#if progress > 0.002}
<!-- Sharp foreground ring (no filter) -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="url(#ring-grad-{fid})"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
/>
{/if}
</svg>
<!-- Content overlay -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
{#if children}
{@render children()}
{/if}
</div>
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
const appWindow = getCurrentWebviewWindow();
</script>
<!-- Invisible drag region traffic lights on the right -->
<div
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 -->
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center
text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase"
style="font-family: 'Space Mono', monospace;"
>
Core Cooldown
</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">
<!-- 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"
onclick={() => appWindow.toggleMaximize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<polyline points="1,5 1,7 3,7" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<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>
</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"
onclick={() => appWindow.minimize()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="2"
viewBox="0 0 8 2"
fill="none"
>
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<!-- 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"
onclick={() => appWindow.close()}
>
<svg
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
>
<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>
</button>
</div>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { config } from "../stores/config";
interface Props {
checked: boolean;
onchange?: (value: boolean) => void;
}
let { checked = $bindable(), onchange }: Props = $props();
function toggle() {
checked = !checked;
onchange?.(checked);
}
</script>
<button
type="button"
role="switch"
aria-label="Toggle"
aria-checked={checked}
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
transition-colors duration-200 ease-in-out focus:outline-none"
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
onclick={toggle}
>
<span
class="pointer-events-none inline-block h-[19px] w-[19px] 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]"
></span>
</button>

149
src/lib/stores/config.ts Normal file
View File

@@ -0,0 +1,149 @@
import { writable, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export interface TimeRange {
start: string; // Format: "HH:MM"
end: string; // Format: "HH:MM"
}
export interface DaySchedule {
enabled: boolean;
ranges: TimeRange[];
}
export type { TimeRange, DaySchedule };
export interface Config {
break_duration: number;
break_frequency: number;
auto_start: boolean;
break_title: string;
break_message: string;
fullscreen_mode: boolean;
strict_mode: boolean;
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
dark_mode: boolean;
color_scheme: string;
backdrop_opacity: number;
notification_enabled: boolean;
notification_before_break: number;
snooze_duration: number;
snooze_limit: number;
skip_cooldown: number;
sound_enabled: boolean;
sound_volume: number;
sound_preset: string;
idle_detection_enabled: boolean;
idle_timeout: number;
smart_breaks_enabled: boolean;
smart_break_threshold: number;
smart_break_count_stats: boolean;
show_break_activities: boolean;
ui_zoom: number;
accent_color: string;
break_color: string;
countdown_font: string;
background_blobs_enabled: boolean;
mini_click_through: boolean;
mini_hover_threshold: number;
}
const defaultConfig: Config = {
break_duration: 5,
break_frequency: 25,
auto_start: true,
break_title: "Rest your eyes",
break_message: "Look away from the screen. Stretch and relax.",
fullscreen_mode: true,
strict_mode: false,
allow_end_early: true,
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
],
dark_mode: true,
color_scheme: "Ocean",
backdrop_opacity: 0.92,
notification_enabled: true,
notification_before_break: 30,
snooze_duration: 5,
snooze_limit: 3,
skip_cooldown: 60,
sound_enabled: true,
sound_volume: 70,
sound_preset: "bell",
idle_detection_enabled: true,
idle_timeout: 120,
smart_breaks_enabled: true,
smart_break_threshold: 300,
smart_break_count_stats: false,
show_break_activities: true,
ui_zoom: 100,
accent_color: "#ff4d00",
break_color: "#7c6aef",
countdown_font: "",
background_blobs_enabled: true,
mini_click_through: true,
mini_hover_threshold: 3.0,
};
export const config = writable<Config>(defaultConfig);
export async function loadConfig() {
try {
const cfg = await invoke<Config>("get_config");
config.set(cfg);
} catch (e) {
console.error("Failed to load config:", e);
}
}
export async function saveConfig(): Promise<boolean> {
try {
const cfg = get(config);
await invoke("save_config", { config: cfg });
return true;
} catch (e) {
console.error("Failed to save config:", e);
return false;
}
}
export async function notifyConfigChanged() {
try {
const cfg = get(config);
await invoke("update_pending_config", { config: cfg });
} catch (e) {
console.error("Failed to update pending config:", e);
}
}
export async function resetConfig() {
try {
const cfg = await invoke<Config>("reset_config");
config.set(cfg);
} catch (e) {
console.error("Failed to reset config:", e);
}
}
// Debounced auto-save: updates backend immediately, persists to disk after 400ms idle
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export function autoSave() {
notifyConfigChanged();
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveConfig();
}, 400);
}

133
src/lib/stores/timer.ts Normal file
View File

@@ -0,0 +1,133 @@
import { writable, get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { playSound, playBreakEndSound } from "../utils/sounds";
import { config } from "./config";
export interface TimerSnapshot {
state: "running" | "paused" | "breakActive";
currentView: "dashboard" | "breakScreen" | "settings" | "stats";
timeRemaining: number;
totalDuration: number;
progress: number;
hasHadBreak: boolean;
secondsSinceLastBreak: number;
prebreakWarning: boolean;
snoozesUsed: number;
canSnooze: boolean;
breakTitle: string;
breakMessage: string;
breakProgress: number;
breakTimeRemaining: number;
breakTotalDuration: number;
breakPastHalf: boolean;
settingsModified: boolean;
idlePaused: boolean;
naturalBreakOccurred: boolean;
smartBreaksEnabled: boolean;
smartBreakThreshold: number;
}
const defaultSnapshot: TimerSnapshot = {
state: "paused",
currentView: "dashboard",
timeRemaining: 1500,
totalDuration: 1500,
progress: 1.0,
hasHadBreak: false,
secondsSinceLastBreak: 0,
prebreakWarning: false,
snoozesUsed: 0,
canSnooze: true,
breakTitle: "Rest your eyes",
breakMessage: "Look away from the screen. Stretch and relax.",
breakProgress: 0,
breakTimeRemaining: 0,
breakTotalDuration: 0,
breakPastHalf: false,
settingsModified: false,
idlePaused: false,
naturalBreakOccurred: false,
smartBreaksEnabled: true,
smartBreakThreshold: 300,
};
export const timer = writable<TimerSnapshot>(defaultSnapshot);
// Track the current view separately so UI can switch views optimistically
export const currentView = writable<
"dashboard" | "breakScreen" | "settings" | "stats"
>("dashboard");
let initialized = false;
export async function initTimerStore() {
if (initialized) return;
initialized = true;
// Get initial state
try {
const snapshot = await invoke<TimerSnapshot>("get_timer_state");
timer.set(snapshot);
currentView.set(snapshot.currentView);
} catch (e) {
console.error("Failed to get initial timer state:", e);
}
// Listen for tick events
await listen<TimerSnapshot>("timer-tick", (event) => {
timer.set(event.payload);
// Sync view from backend (backend is authoritative for break transitions)
currentView.set(event.payload.currentView);
});
// Listen for pre-break warning
await listen("prebreak-warning", () => {
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.6);
}
});
// Listen for break started
await listen("break-started", () => {
currentView.set("breakScreen");
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume);
}
});
// Listen for break ended
await listen("break-ended", () => {
currentView.set("dashboard");
const cfg = get(config);
if (cfg.sound_enabled) {
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume);
}
});
// Listen for natural break detected
await listen<number>("natural-break-detected", (event) => {
const cfg = get(config);
if (cfg.sound_enabled) {
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
}
});
}
// Helper: format seconds as MM:SS
export 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")}`;
}
// Helper: format "X ago" string
export function formatDurationAgo(secs: number): string {
if (secs < 60) return `${secs} sec ago`;
const m = Math.floor(secs / 60);
if (m < 60) return m === 1 ? "1 min ago" : `${m} min ago`;
const h = Math.floor(secs / 3600);
return h === 1 ? "1 hr ago" : `${h} hrs ago`;
}

115
src/lib/utils/activities.ts Normal file
View File

@@ -0,0 +1,115 @@
export interface BreakActivity {
category: "eyes" | "stretch" | "breathing" | "movement";
text: string;
}
export const breakActivities: BreakActivity[] = [
// Eyes
{ 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: "Blink rapidly 20 times to refresh your eyes" },
{ 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" },
{ category: "eyes", text: "Gently massage your temples in small circles" },
{ category: "eyes", text: "Close your eyes and visualize a calm, dark space for 20 seconds" },
{ 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: "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: "Trace the outline of a window frame with your eyes, slowly" },
// Stretches
{ category: "stretch", text: "Roll your shoulders backward slowly, 5 times" },
{ category: "stretch", text: "Interlace fingers behind your back and open your chest" },
{ category: "stretch", text: "Tilt your head to each side, holding for 10 seconds" },
{ category: "stretch", text: "Stretch your arms overhead and reach for the ceiling" },
{ category: "stretch", text: "Rotate your wrists in circles, 10 times each direction" },
{ category: "stretch", text: "Clasp hands together and push palms away from you" },
{ category: "stretch", text: "Gently twist your torso left and right from your chair" },
{ 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: "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" },
{ category: "stretch", text: "Press your palms together at chest height and push for 10 seconds" },
{ category: "stretch", text: "Sit tall, reach behind to grab the back of your chair, and open your chest" },
{ category: "stretch", text: "Cross one ankle over the opposite knee and lean forward gently" },
{ category: "stretch", text: "Shrug your shoulders up to your ears, hold 5 seconds, release slowly" },
// 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: "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: "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: "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: "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" },
// Movement
{ category: "movement", text: "Stand up and walk to the nearest window" },
{ category: "movement", text: "Do 10 standing calf raises" },
{ category: "movement", text: "Walk to get a glass of water" },
{ category: "movement", text: "Stand and do 5 gentle squats" },
{ category: "movement", text: "Take a short walk around your room" },
{ category: "movement", text: "Stand on one foot for 15 seconds, then switch" },
{ category: "movement", text: "Do 10 arm circles, forward then backward" },
{ category: "movement", text: "March in place for 30 seconds" },
{ 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: "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: "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" },
];
const categoryIcons: Record<BreakActivity["category"], string> = {
eyes: "👁",
stretch: "🤸",
breathing: "🌬",
movement: "🚶",
};
const categoryLabels: Record<BreakActivity["category"], string> = {
eyes: "Eye Exercise",
stretch: "Stretch",
breathing: "Breathing",
movement: "Movement",
};
export function getCategoryIcon(cat: BreakActivity["category"]): string {
return categoryIcons[cat];
}
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)];
}

366
src/lib/utils/animate.ts Normal file
View File

@@ -0,0 +1,366 @@
import { animate } from "motion";
/**
* Svelte action: fade in + slide up on mount
*/
export function fadeIn(
node: HTMLElement,
options?: { duration?: number; delay?: number; y?: number },
) {
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: scale in + fade on mount
*/
export function scaleIn(
node: HTMLElement,
options?: { duration?: number; delay?: number },
) {
const { duration = 0.6, delay = 0 } = options ?? {};
node.style.opacity = "0";
const controls = animate(
node,
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
);
return {
destroy() {
controls.cancel();
},
};
}
/**
* Svelte action: animate when scrolled into view (IntersectionObserver)
*/
export function inView(
node: HTMLElement,
options?: { delay?: number; y?: number; threshold?: number },
) {
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
node.style.opacity = "0";
node.style.transform = `translateY(${y}px)`;
let controls: ReturnType<typeof animate> | null = null;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
controls = animate(
node,
{
opacity: [0, 1],
transform: [`translateY(${y}px)`, "translateY(0px)"],
},
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
controls?.cancel();
},
};
}
/**
* Svelte action: spring-scale press feedback on buttons
*/
export function pressable(node: HTMLElement) {
let active: ReturnType<typeof animate> | null = null;
function onDown() {
active?.cancel();
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
active = animate(
node,
{ transform: ["scale(1)", "scale(0.95)"] },
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onUp() {
active?.cancel();
active = animate(
node,
{ transform: ["scale(0.95)", "scale(1)"] },
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
);
}
node.addEventListener("mousedown", onDown);
node.addEventListener("mouseup", onUp);
node.addEventListener("mouseleave", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
node.removeEventListener("mouseup", onUp);
node.removeEventListener("mouseleave", onUp);
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.
*/
export function glowHover(
node: HTMLElement,
options?: { color?: string },
) {
let color = options?.color ?? "#ff4d00";
let enterAnim: ReturnType<typeof animate> | null = null;
let leaveAnim: ReturnType<typeof animate> | null = null;
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
node.style.boxShadow = off();
function onEnter() {
leaveAnim?.cancel();
enterAnim = animate(
node,
{ boxShadow: [off(), on()] },
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
);
}
function onLeave() {
enterAnim?.cancel();
leaveAnim = animate(
node,
{ boxShadow: [on(), off()] },
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
);
}
node.addEventListener("mouseenter", onEnter);
node.addEventListener("mouseleave", onLeave);
return {
update(newOptions?: { color?: string }) {
color = newOptions?.color ?? "#ff4d00";
node.style.boxShadow = off();
},
destroy() {
node.removeEventListener("mouseenter", onEnter);
node.removeEventListener("mouseleave", 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).
*/
export function dragScroll(node: HTMLElement) {
const content = node.children[0] as HTMLElement | null;
let isDown = false;
let startY = 0;
let scrollStart = 0;
let lastY = 0;
let lastTime = 0;
let animFrame = 0;
let velocitySamples: number[] = [];
let overscrollAmount = 0;
let springAnim: ReturnType<typeof animate> | null = null;
function getMaxScroll() {
return node.scrollHeight - node.clientHeight;
}
function setOverscroll(amount: number) {
if (!content) return;
overscrollAmount = amount;
if (Math.abs(amount) < 0.5) {
content.style.transform = "";
overscrollAmount = 0;
} else {
content.style.transform = `translateY(${-amount}px)`;
}
}
function springBack() {
if (!content) return;
const from = overscrollAmount;
if (Math.abs(from) < 0.5) {
setOverscroll(0);
return;
}
springAnim = animate(
content,
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
);
overscrollAmount = 0;
// Ensure DOM is clean after animation completes
springAnim.finished.then(() => {
if (content) content.style.transform = "";
}).catch(() => { /* cancelled — onDown handles cleanup */ });
}
function forceReset() {
springAnim?.cancel();
cancelAnimationFrame(animFrame);
overscrollAmount = 0;
if (content) content.style.transform = "";
}
function onDown(e: MouseEvent) {
if (e.button !== 0) return;
const tag = (e.target as HTMLElement).tagName;
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
// Force-reset any lingering animation/transform state
forceReset();
isDown = true;
startY = e.pageY;
scrollStart = node.scrollTop;
lastY = e.pageY;
lastTime = Date.now();
velocitySamples = [];
node.style.cursor = "grabbing";
}
function onMove(e: MouseEvent) {
if (!isDown) return;
e.preventDefault();
const y = e.pageY;
const now = Date.now();
const dt = now - lastTime;
if (dt > 0) {
velocitySamples.push((lastY - y) / dt);
if (velocitySamples.length > 5) velocitySamples.shift();
}
lastY = y;
lastTime = now;
const desiredScroll = scrollStart - (y - startY);
const maxScroll = getMaxScroll();
if (desiredScroll < 0) {
node.scrollTop = 0;
setOverscroll(desiredScroll * 0.3);
} else if (desiredScroll > maxScroll) {
node.scrollTop = maxScroll;
setOverscroll((desiredScroll - maxScroll) * 0.3);
} else {
node.scrollTop = desiredScroll;
if (overscrollAmount !== 0) setOverscroll(0);
}
}
function onUp() {
if (!isDown) return;
isDown = false;
node.style.cursor = "";
if (overscrollAmount !== 0) {
springBack();
return;
}
const avgVelocity = velocitySamples.length > 0
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
: 0;
// Clamp velocity (px/ms) and bail if negligible
const maxV = 4;
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
if (Math.abs(v0) < 0.005) return;
// 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 coastStart = performance.now();
const scrollStart2 = node.scrollTop;
const totalDist = v0 * tau;
function coast() {
const t = performance.now() - coastStart;
const decay = Math.exp(-t / tau);
const offset = totalDist * (1 - decay);
const targetScroll = scrollStart2 + offset;
const maxScroll = getMaxScroll();
if (targetScroll < 0) {
node.scrollTop = 0;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(-bounce);
springBack();
return;
}
if (targetScroll > maxScroll) {
node.scrollTop = maxScroll;
const currentV = Math.abs(v0 * decay);
const bounce = Math.min(40, currentV * 50);
setOverscroll(bounce);
springBack();
return;
}
node.scrollTop = targetScroll;
// Stop when velocity < 0.5 px/sec (completely imperceptible)
if (Math.abs(v0 * decay) > 0.0005) {
animFrame = requestAnimationFrame(coast);
}
}
coast();
}
node.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return {
destroy() {
node.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
forceReset();
},
};
}

283
src/lib/utils/sounds.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* 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 {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
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();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3; // Scale to reasonable max
switch (preset) {
case "bell":
playBell(ctx, gain, vol);
break;
case "chime":
playChime(ctx, gain, vol);
break;
case "soft":
playSoft(ctx, gain, vol);
break;
case "digital":
playDigital(ctx, gain, vol);
break;
case "harp":
playHarp(ctx, gain, vol);
break;
case "bowl":
playBowl(ctx, gain, vol);
break;
case "rain":
playRain(ctx, gain, vol);
break;
case "whistle":
playWhistle(ctx, gain, vol);
break;
}
}
/** Warm bell — two sine tones with harmonics and slow decay */
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Fundamental
const osc1 = ctx.createOscillator();
const g1 = ctx.createGain();
osc1.type = "sine";
osc1.frequency.setValueAtTime(830, now);
g1.gain.setValueAtTime(vol, now);
g1.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc1.connect(g1);
g1.connect(destination);
osc1.start(now);
osc1.stop(now + 1.5);
// Harmonic
const osc2 = ctx.createOscillator();
const g2 = ctx.createGain();
osc2.type = "sine";
osc2.frequency.setValueAtTime(1245, now);
g2.gain.setValueAtTime(vol * 0.4, now);
g2.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc2.connect(g2);
g2.connect(destination);
osc2.start(now);
osc2.stop(now + 1.0);
}
/** Two-note ascending chime */
function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.15;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.8);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.8);
});
}
/** Gentle soft ping — filtered triangle wave */
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const g = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(600, now);
osc.frequency.exponentialRampToValueAtTime(400, now + 0.5);
filter.type = "lowpass";
filter.frequency.setValueAtTime(2000, now);
filter.frequency.exponentialRampToValueAtTime(400, now + 0.8);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.2);
osc.connect(filter);
filter.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 1.2);
}
/** Digital blip — short square wave burst */
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
for (let i = 0; i < 2; i++) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(880, now);
const start = now + i * 0.12;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.5, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 0.15);
}
}
/** 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
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.09;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol * 0.8, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.001, start + 1.2);
osc.connect(g);
g.connect(destination);
osc.start(start);
osc.stop(start + 1.2);
});
}
/** Singing bowl — low sine with slow beating from detuned pair */
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
// Two slightly detuned sines create a beating/shimmering effect
for (const freq of [293.66, 295.5]) { // ~D4 with 2Hz beat
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
g.gain.setValueAtTime(vol, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 2.5);
osc.connect(g);
g.connect(destination);
osc.start(now);
osc.stop(now + 2.5);
}
// Upper harmonic shimmer
const osc3 = ctx.createOscillator();
const g3 = ctx.createGain();
osc3.type = "sine";
osc3.frequency.setValueAtTime(880, now);
g3.gain.setValueAtTime(vol * 0.15, now);
g3.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
osc3.connect(g3);
g3.connect(destination);
osc3.start(now);
osc3.stop(now + 1.5);
}
/** Rain — filtered noise burst with gentle decay */
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const bufferSize = ctx.sampleRate * 1;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
// White noise
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.setValueAtTime(3000, now);
filter.frequency.exponentialRampToValueAtTime(800, now + 0.8);
filter.Q.setValueAtTime(0.5, now);
const g = ctx.createGain();
g.gain.setValueAtTime(vol * 0.6, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
noise.connect(filter);
filter.connect(g);
g.connect(destination);
noise.start(now);
noise.stop(now + 1.0);
}
/** Whistle — gentle two-note sine glide */
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(1318.5, now + 0.3); // A5 → E6 glide up
osc.frequency.setValueAtTime(1318.5, now + 0.5);
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.8); // E6 → C6 settle
g.gain.setValueAtTime(0, now);
g.gain.linearRampToValueAtTime(vol * 0.5, now + 0.05);
g.gain.setValueAtTime(vol * 0.5, now + 0.6);
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
osc.connect(g);
g.connect(destination);
osc.start(now);
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();
gain.connect(ctx.destination);
const vol = (volume / 100) * 0.3;
const now = ctx.currentTime;
// Always a gentle descending two-note resolution
const notes = [659.25, 523.25]; // E5, C5 (descending = "done" feeling)
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = preset === "digital" ? "square" : "sine";
osc.frequency.setValueAtTime(freq, now);
const start = now + i * 0.2;
g.gain.setValueAtTime(0, start);
g.gain.linearRampToValueAtTime(vol, start + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, start + 0.6);
osc.connect(g);
g.connect(gain);
osc.start(start);
osc.stop(start + 0.6);
});
}

23
src/main.ts Normal file
View File

@@ -0,0 +1,23 @@
import "./app.css";
import App from "./App.svelte";
import MiniTimer from "./lib/components/MiniTimer.svelte";
import BreakWindow from "./lib/components/BreakWindow.svelte";
import { mount } from "svelte";
const params = new URLSearchParams(window.location.search);
const isMini = params.has("mini");
const isBreak = params.has("break");
if (isMini || isBreak) {
// 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 app = mount(component, {
target: document.getElementById("app")!,
});
export default app;

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

5
svelte.config.js Normal file
View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

25
vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [tailwindcss(), svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));