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:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
116
LICENSE
Normal 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
438
README.md
Normal 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 5–120 minutes and breaks from 1–60 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 (30–600s) and smart break threshold (2–15 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 0–100%.
|
||||
|
||||
### 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** | 50–200% 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** | 50–100% 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` | 1–60 min | Duration of each break |
|
||||
| `break_frequency` | `u32` | `25` | 5–120 min | Interval between breaks |
|
||||
| `auto_start` | `bool` | `true` | — | Start timer on launch |
|
||||
| `break_title` | `string` | `"Rest your eyes"` | max 100 chars | Title shown on break screen |
|
||||
| `break_message` | `string` | `"Look away 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` | Mon–Fri 09:00–18: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.5–1.0 | Break screen backdrop opacity |
|
||||
| `notification_enabled` | `bool` | `true` | — | Enable toast notifications |
|
||||
| `notification_before_break` | `u32` | `30` | 0–300 sec | Pre-break warning time |
|
||||
| `snooze_duration` | `u32` | `5` | 1–30 min | Snooze delay |
|
||||
| `snooze_limit` | `u32` | `3` | 0–5 (0=unlimited) | Max snoozes per cycle |
|
||||
| `skip_cooldown` | `u32` | `60` | 0–600 sec | Cooldown between skips |
|
||||
| `sound_enabled` | `bool` | `true` | — | Play notification sounds |
|
||||
| `sound_volume` | `u32` | `70` | 0–100 | 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` | 30–600 sec | Idle threshold |
|
||||
| `smart_breaks_enabled` | `bool` | `true` | — | Detect natural breaks |
|
||||
| `smart_break_threshold` | `u32` | `300` | 120–900 sec | Natural break threshold |
|
||||
| `smart_break_count_stats` | `bool` | `false` | — | Count natural breaks in stats |
|
||||
| `show_break_activities` | `bool` | `true` | — | Show activity suggestions |
|
||||
| `ui_zoom` | `u32` | `100` | 50–200% | Interface zoom level |
|
||||
| `accent_color` | `string` | `"#ff4d00"` | hex | Main accent color |
|
||||
| `break_color` | `string` | `"#7c6aef"` | hex | Break screen ring color |
|
||||
| `countdown_font` | `string` | `""` | font family | Google Font for countdown |
|
||||
| `background_blobs_enabled` | `bool` | `false` | — | Animated background blobs |
|
||||
| `mini_click_through` | `bool` | `true` | — | Mini mode click-through |
|
||||
| `mini_hover_threshold` | `f32` | `3.0` | 1.0–10.0 sec | Hover 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
15
index.html
Normal 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
2395
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
src-tauri/.cargo/config.toml
Normal file
2
src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[build]
|
||||
target = "x86_64-pc-windows-gnu"
|
||||
5443
src-tauri/Cargo.lock
generated
Normal file
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
25
src-tauri/Cargo.toml
Normal 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
11
src-tauri/build.rs
Normal 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()
|
||||
}
|
||||
35
src-tauri/capabilities/default.json
Normal file
35
src-tauri/capabilities/default.json
Normal 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
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
BIN
src-tauri/icons/32x32.png
Normal file
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
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
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
421
src-tauri/src/config.rs
Normal 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
688
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal 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
199
src-tauri/src/stats.rs
Normal 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
496
src-tauri/src/timer.rs
Normal 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
29
src-tauri/tauri.conf.json
Normal 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
118
src/App.svelte
Normal 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
57
src/app.css
Normal 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;
|
||||
}
|
||||
111
src/lib/components/BackgroundBlobs.svelte
Normal file
111
src/lib/components/BackgroundBlobs.svelte
Normal 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>
|
||||
417
src/lib/components/BreakScreen.svelte
Normal file
417
src/lib/components/BreakScreen.svelte
Normal 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>
|
||||
63
src/lib/components/BreakWindow.svelte
Normal file
63
src/lib/components/BreakWindow.svelte
Normal 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>
|
||||
301
src/lib/components/ColorPicker.svelte
Normal file
301
src/lib/components/ColorPicker.svelte
Normal 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>
|
||||
289
src/lib/components/Dashboard.svelte
Normal file
289
src/lib/components/Dashboard.svelte
Normal 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>
|
||||
98
src/lib/components/FontSelector.svelte
Normal file
98
src/lib/components/FontSelector.svelte
Normal 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>
|
||||
336
src/lib/components/MiniTimer.svelte
Normal file
336
src/lib/components/MiniTimer.svelte
Normal 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>
|
||||
801
src/lib/components/Settings.svelte
Normal file
801
src/lib/components/Settings.svelte
Normal 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>
|
||||
274
src/lib/components/StatsView.svelte
Normal file
274
src/lib/components/StatsView.svelte
Normal 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>
|
||||
88
src/lib/components/Stepper.svelte
Normal file
88
src/lib/components/Stepper.svelte
Normal 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}
|
||||
>
|
||||
−
|
||||
</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>
|
||||
399
src/lib/components/TimeSpinner.svelte
Normal file
399
src/lib/components/TimeSpinner.svelte
Normal 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>
|
||||
177
src/lib/components/TimerRing.svelte
Normal file
177
src/lib/components/TimerRing.svelte
Normal 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>
|
||||
83
src/lib/components/Titlebar.svelte
Normal file
83
src/lib/components/Titlebar.svelte
Normal 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>
|
||||
32
src/lib/components/ToggleSwitch.svelte
Normal file
32
src/lib/components/ToggleSwitch.svelte
Normal 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
149
src/lib/stores/config.ts
Normal 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
133
src/lib/stores/timer.ts
Normal 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
115
src/lib/utils/activities.ts
Normal 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
366
src/lib/utils/animate.ts
Normal 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
283
src/lib/utils/sounds.ts
Normal 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
23
src/main.ts
Normal 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
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
5
svelte.config.js
Normal file
5
svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal 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
25
vite.config.ts
Normal 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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user