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