diff --git a/.github/images/better-stats-consistency.png b/.github/images/better-stats-consistency.png new file mode 100644 index 00000000..9350d1a1 Binary files /dev/null and b/.github/images/better-stats-consistency.png differ diff --git a/.github/images/better-stats-momentum.png b/.github/images/better-stats-momentum.png new file mode 100644 index 00000000..b3202b42 Binary files /dev/null and b/.github/images/better-stats-momentum.png differ diff --git a/.github/images/better-stats-rhythm.png b/.github/images/better-stats-rhythm.png new file mode 100644 index 00000000..1063e35b Binary files /dev/null and b/.github/images/better-stats-rhythm.png differ diff --git a/.github/images/incremental-focus-settings.png b/.github/images/incremental-focus-settings.png new file mode 100644 index 00000000..91127c22 Binary files /dev/null and b/.github/images/incremental-focus-settings.png differ diff --git a/.gitignore b/.gitignore index 4575a5c1..9a67dc12 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ thumbs.db *.cert .svelte-kit/ build/ +.tmp/ diff --git a/README.md b/README.md index d1506534..471124a0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,27 @@ --- +> ## 🍴 This is a fork +> +> This repository is a fork of **[Splode/pomotroid](https://github.com/Splode/pomotroid)** by +> [Christopher Murphy](https://github.com/Splode), maintained by +> **[gluansinha-star](https://github.com/gluansinha-star)**. +> +> All of the original application — the timer engine, themes, localization, tray integration, +> WebSocket API, and the entire Tauri/Rust/Svelte architecture — is upstream work. This fork adds +> two user-facing features on top of it: **Incremental Focus** and a much deeper **Better Stats** +> window. See [Fork additions](#fork-additions) for exactly what changed and how to get it. +> +> Want the upstream project? Use **[Splode/pomotroid](https://github.com/Splode/pomotroid)**. Bug +> reports about the features below belong here; anything about the original app belongs upstream. + +--- + - [Overview](#overview) +- [Fork additions](#fork-additions) + - [Incremental Focus](#incremental-focus) + - [Better Stats](#better-stats) +- [Screenshots](#screenshots) - [Features](#features) - [Statistics](#statistics) - [Themes](#themes) @@ -24,8 +44,121 @@ Pomotroid is a simple and configurable Pomodoro timer. It aims to provide a visu Built with [Tauri 2](https://tauri.app), [Rust](https://www.rust-lang.org), and [Svelte 5](https://svelte.dev). +## Fork additions + +Everything in this section is new in this fork. Each feature is opt-in and preserves the original +behaviour when switched off, so the app still works exactly like upstream by default. + +### Incremental Focus + +A work ladder that grows as you go: **each completed focus round makes the next one longer**, with a +configurable ceiling. + +Set it up in **Settings → Timer → Incremental Focus**: + +| Setting | Meaning | Default | +| -------------------- | -------------------------------------------------------------- | ------- | +| **Incremental Focus** | Master on/off switch | off | +| **Add per Round** | Minutes added after each completed focus round | 5 min | +| **Maximum Focus** | Ceiling for the escalated duration | 90 min | + +
+ Pomotroid Timer settings showing the Incremental Focus toggle, Add per Round and Maximum Focus sliders, and the live focus ladder preview +
+ +With the default 25-minute focus and a 5-minute increment capped at 45 minutes, the ladder runs +**25 → 30 → 35 → 40 → 45 → 45 → …**. The settings panel renders a live preview of the ladder so you +can see the shape before you commit to it. + +How it behaves: + +- The **first** round uses your configured Focus duration as-is; the increment applies from the + second round onward. +- The ladder **resets after a long break**, and on a manual Reset, so every cycle starts fresh from + the base duration. +- **Breaks are never escalated** — only work rounds grow. +- A misconfigured cap below the base duration is clamped up, so the work round can never get + *shorter* than your configured Focus time. +- While a work round is running, a small caption under the round label shows the current position, + e.g. `25m +10m · step 3`, and `· capped` once the ceiling is reached. + +Implementation notes: the ladder lives in `SequenceState` (`src-tauri/src/timer/sequence.rs`) and +reaches the frontend through new `TimerSnapshot` fields (`incremental_work_enabled`, `base_work_secs`, +`work_increment_secs`, `work_max_secs`, `increment_steps`, `at_increment_cap`). Three new settings are +persisted via **MIGRATION_7**, so existing databases upgrade without losing anything. + +### Better Stats + +The original Statistics window is still there and untouched. This fork adds a second, separate +**Better Stats** window (`better-stats`), opened from the new trend-line button in the titlebar, next +to the original chart button. + +It is built around answering "how am I actually doing?" rather than just listing totals: + +
+ Better Stats — momentum: today's ring against the 7-day average, streak, and week-over-week comparisons, plus 28-day and 12-week progress charts +
+ +**Momentum** +- A progress ring comparing today against your trailing 7-day average, with a plain-language verdict + ("3 rounds to match your average", "Average matched — keep going"). +- Week-over-week comparison table for rounds, focus time, active days, and average per active day, + each with a signed percentage change. +- Rolling 7-day, 28-day, and 90-day window cards. +- A streak strip that warns when a live streak has nothing logged yet today. + +**Progress over time** +- A 28-day area chart with the 7-day moving average overlaid as a dashed line, and a day/week toggle. +- A 12-week rollup shown as both focus-minutes and rounds bar charts. + +**Consistency** +- An interactive heatmap with three scopes: + - **Recent** — the trailing 120 days as a rolling contribution grid. + - **Month** — a real calendar month: weekday columns, day numbers, rounds count per cell, today + outlined, future days dashed, with `‹ ›` navigation. + - **Year** — a whole calendar year in the weeks-as-columns layout. +- Habit strength: a donut splitting the last 30 days into strong / light / rest days, plus active-day + ratio, daily average, steadiness (standard deviation of daily rounds), and best run. +- Your focus window (earliest and latest start hour) and how concentrated your focus is in your + busiest weekday. +- A round-length histogram bucketed into under 15 / 15–25 / 25–40 / 40–60 / 60+ minutes. + +
+ Better Stats — consistency: 120-day heatmap with month/year scopes, habit strength donut, focus window, and round-length mix +
+ +**Rhythm** +- A weekday × hour grid showing exactly when you focus, with the peak slot called out. +- Ranked focus-by-weekday and focus-by-hour breakdowns. + +**Personal bests** +- Longest round, best day (by rounds and by focus time), best week, average round length, total + tracked days, and longest streak. + +
+ Better Stats — weekday by hour rhythm grid, focus by weekday and hour breakdowns, and personal bests +
+ +Everything arrives in a single IPC call (`stats_get_insights`), so the window has no staggered +loading. Durations are formatted the way people read them: `45m`, then `1h 30m`, and whole hours once +you pass ten (`12h`) — minute precision stops being meaningful at that scale. + +**A note on correctness.** While building this I found and fixed a real bug in the fork's date +arithmetic: the day-number-to-date inverse was off by one day *and* the weekday index was misaligned, +which made the 28-day trend and the weekly chart render entirely as zeros. Both are now verified +against known calendar facts (`2026-09-25` is a Friday, `1970-01-01` a Thursday, `2024-02-29` a valid +leap day) and pinned by tests in `src-tauri/src/db/queries.rs`. + +## Screenshots + +
+ Pomotroid statistics window +
+ ## Features +- **Incremental Focus** — a work ladder that lengthens each round, with a configurable ceiling *(this fork)* +- **Better Stats** — momentum, consistency heatmaps, rhythm grids, and personal bests *(this fork)* - **Configurable timer** — customise work duration, break durations, and the number of rounds per long break - **Statistics** — daily, weekly, and all-time session history with charts and a 52-week heatmap - **38 bundled themes** — including Dracula, Nord, Tokyo Night, Catppuccin, Gruvbox, Rose Piné, and more; auto-switches with your OS light/dark preference @@ -46,9 +179,7 @@ Built with [Tauri 2](https://tauri.app), [Rust](https://www.rust-lang.org), and Pomotroid tracks every completed session and surfaces the data across three views: a daily summary with an hourly breakdown, a weekly bar chart with streak tracking, and an all-time 52-week heatmap. -
- Pomotroid statistics window -
+This fork keeps that window and adds the richer [Better Stats](#better-stats) window alongside it. ## Themes @@ -62,7 +193,7 @@ See [THEMES.md](./THEMES.md) for the full theme list and instructions on creatin ### Download -Download the latest release from the [releases](https://github.com/Splode/pomotroid/releases) page. +Download the latest release from the [upstream releases](https://github.com/Splode/pomotroid/releases) page — this fork does not publish its own releases. Available for **Windows** (installer + standalone exe), **macOS** (universal DMG), and **Linux** (`.deb` + AppImage). @@ -139,6 +270,41 @@ npm run paraglide:compile This is also run automatically as part of `npm run check`. +### Development helpers + +Three small Node scripts were added in this fork to make working on the new features easier: + +```bash +# Fill the local database with ~200 days of realistic history so the stats +# windows have something to show. Clears existing sessions first. +# Run with --clear to wipe sessions without seeding. +node scripts/seed-demo-data.mjs + +# Add any missing message keys to every locale file, falling back to the +# English string. Existing translations are never overwritten. +node scripts/sync-messages.mjs + +# Publish a Windows release to this fork, attaching the NSIS installer, the MSI +# and a portable zip. Reuses the credential Git Credential Manager already +# stores for github.com; reads the release body from ../release-notes.md. +node scripts/publish-release.mjs +``` + +To preview the UI in a plain browser without the Tauri shell, run `npm run dev` and open +. A dev-only mock (`src/lib/dev/mockTauri.js`, imported from the root layout) +stands in for the Rust backend so the windows render and the timer ticks. It is guarded by +`import.meta.env.DEV` and is never part of a production build. + +### Checking your changes + +```bash +# Type-check Svelte + TypeScript (also regenerates Paraglide messages) +npm run check + +# Rust unit tests +cd src-tauri && cargo test +``` + ## License MIT © [Christopher Murphy](https://github.com/Splode) diff --git a/package-lock.json b/package-lock.json index 322303d4..5319b525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pomotroid", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pomotroid", - "version": "1.7.1", + "version": "1.7.2", "license": "MIT", "dependencies": { "@inlang/paraglide-js": "^2.19.0", diff --git a/package.json b/package.json index b56f72d2..1d8190f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pomotroid", - "version": "1.7.1", + "version": "1.7.2", "description": "A simple and visually-pleasing Pomodoro timer", "author": { "email": "flyweight@pm.me", diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100644 index 00000000..05a553bd --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,155 @@ +/** + * Publish the Windows release to the fork and attach the installers. + * + * Done in Node rather than PowerShell: ConvertTo-Json was serialising the + * FileInfo object instead of the release-notes string, and there is no way to + * express `body` as a plain string without fighting the pipeline. + * + * Usage: node scripts/publish-release.mjs + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); +const parent = join(root, '..'); + +const REPO = 'gluansinha-star/pomotroid'; +/** App version — single source of truth, read from package.json. */ +const VERSION = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; +/** Fork releases are tagged `-fork.`. */ +const FORK = 1; +const TAG = `v${VERSION}-fork.${FORK}`; +const RELEASE_NAME = `Pomotroid fork ${VERSION} — Configurable Incremental Focus resets`; +const NOTES = join(parent, 'release-notes.md'); +const BUNDLE = join(root, 'src-tauri', 'target', 'release', 'bundle'); + +// --- Credential: reuse whatever Git Credential Manager already stores -------- +let token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; +if (!token) { + const out = execFileSync('git', ['credential', 'fill'], { + input: 'protocol=https\nhost=github.com\n\n', + encoding: 'utf8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }); + const line = out.split(/\r?\n/).find((l) => l.startsWith('password=')); + token = line ? line.slice('password='.length) : ''; +} +if (!token) throw new Error('No GitHub token available'); + +const headers = { + Authorization: `token ${token}`, + 'User-Agent': 'pomotroid-release', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', +}; + +async function api(path, init = {}) { + const res = await fetch(`https://api.github.com${path}`, { ...init, headers: { ...headers, ...(init.headers ?? {}) } }); + const text = await res.text(); + if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${path} -> ${res.status}\n${text.slice(0, 800)}`); + return text ? JSON.parse(text) : null; +} + +// --- Create the release ------------------------------------------------------ +const body = readFileSync(NOTES, 'utf8'); +console.log(`release notes: ${body.length} chars`); + +let release; +try { + release = await api(`/repos/${REPO}/releases`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tag_name: TAG, + target_commitish: 'main', + name: RELEASE_NAME, + body, + draft: false, + prerelease: false, + }), + }); + console.log(`created: ${release.html_url}`); +} catch (err) { + if (!String(err.message).includes('already_exists')) throw err; + console.log('release already exists — reusing it'); + release = await api(`/repos/${REPO}/releases/tags/${TAG}`); + // Replace the body so a re-run picks up edited notes. + release = await api(`/repos/${REPO}/releases/${release.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }); +} + +// --- Collect assets ---------------------------------------------------------- +// Match on the version explicitly: the bundle directory still holds the +// installers of earlier releases, and picking the wrong file would upload a +// stale build without any error. +const assets = []; + +const nsis = join(BUNDLE, 'nsis', `Pomotroid_${VERSION}_x64-setup.exe`); +if (existsSync(nsis)) assets.push(nsis); +else console.warn(`warning: NSIS installer not found: ${nsis}`); + +const msi = join(BUNDLE, 'msi', `Pomotroid_${VERSION}_x64_en-US.msi`); +if (existsSync(msi)) assets.push(msi); +else console.warn(`warning: MSI not found: ${msi}`); + +if (assets.length === 0) { + throw new Error(`No installers for ${VERSION} under ${BUNDLE} - run "npm run tauri build" first.`); +} + +const zip = join(BUNDLE, 'portable', `Pomotroid_${VERSION}_x64-portable.zip`); + +// Build the portable zip from the release exe if it is missing. +if (!existsSync(zip)) { + const exe = join(root, 'src-tauri', 'target', 'release', 'pomotroid.exe'); + if (existsSync(exe)) { + const stage = join(BUNDLE, 'portable'); + execFileSync('powershell', ['-NoProfile', '-Command', `New-Item -ItemType Directory -Force -Path '${stage}' | Out-Null`]); + console.log('building portable zip ...'); + execFileSync('tar', ['-a', '-c', '-f', zip, '-C', join(root, 'src-tauri', 'target', 'release'), 'pomotroid.exe'], { stdio: 'inherit' }); + } +} +assets.push(zip); // may not exist; handled below + +// --- Upload ----------------------------------------------------------------- +const existing = new Set((release.assets ?? []).map((a) => a.name)); +const uploadBase = `https://uploads.github.com/repos/${REPO}/releases/${release.id}/assets`; + +for (const path of assets) { + if (!existsSync(path)) { + console.log(`skip (missing): ${path}`); + continue; + } + const name = path.split(/[\\/]/).pop(); + if (existing.has(name)) { + console.log(`skip (already uploaded): ${name}`); + continue; + } + const sizeMb = (statSync(path).size / 1024 / 1024).toFixed(1); + console.log(`uploading ${name} (${sizeMb} MB) ...`); + const bytes = readFileSync(path); + const res = await fetch(`${uploadBase}?name=${encodeURIComponent(name)}`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/octet-stream' }, + body: bytes, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`upload failed for ${name}: ${res.status}\n${text.slice(0, 500)}`); + console.log(` -> ${JSON.parse(text).browser_download_url}`); +} + +// --- Report ----------------------------------------------------------------- +const final = await api(`/repos/${REPO}/releases/tags/${TAG}`); +console.log('\n=== release ==='); +console.log(`name: ${final.name}`); +console.log(`tag: ${final.tag_name}`); +console.log(`url: ${final.html_url}`); +console.log(`body: ${final.body.length} chars`); +for (const a of final.assets) { + console.log(` ${a.name.padEnd(38)} ${(a.size / 1024 / 1024).toFixed(1)} MB`); +} diff --git a/scripts/sync-messages.mjs b/scripts/sync-messages.mjs new file mode 100644 index 00000000..d256c17a --- /dev/null +++ b/scripts/sync-messages.mjs @@ -0,0 +1,61 @@ +/** + * Keeps every locale file in sync with the base locale. + * + * `src/messages/en.json` is the source of truth for the key set. Any key that + * exists there but is missing from another locale is added, falling back to the + * English string so the UI degrades gracefully until a translation lands. + * + * Existing translations are never overwritten, and `en.json` itself is left + * untouched apart from key ordering it already defines. + * + * Usage: node scripts/sync-messages.mjs + */ +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const messagesDir = join(here, '..', 'src', 'messages'); + +const BASE = 'en'; +const base = JSON.parse(readFileSync(join(messagesDir, `${BASE}.json`), 'utf8')); +const baseKeys = Object.keys(base); + +const locales = readdirSync(messagesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')); + +let totalAdded = 0; + +for (const locale of locales.sort()) { + const path = join(messagesDir, `${locale}.json`); + const raw = JSON.parse(readFileSync(path, 'utf8')); + + // Preserve the locale's existing key order, then append anything new. + const out = {}; + for (const key of Object.keys(raw)) out[key] = raw[key]; + + let added = 0; + for (const key of baseKeys) { + if (!(key in out)) { + out[key] = base[key]; + added += 1; + } + } + + // Report keys the locale has that the base no longer defines (stale entries + // are kept so translations are not silently destroyed, but they are surfaced). + const stale = Object.keys(out).filter((k) => !(k in base)); + + if (added > 0 || stale.length > 0) { + writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, 'utf8'); + } + + totalAdded += added; + const notes = []; + if (added) notes.push(`+${added} added`); + if (stale.length) notes.push(`${stale.length} stale: ${stale.join(', ')}`); + console.log(`${locale}: ${notes.length ? notes.join('; ') : 'already in sync'}`); +} + +console.log(`\nDone — ${totalAdded} key(s) added across ${locales.length} locale(s).`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6836989b..ea856f5e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "pomotroid" -version = "1.7.1" +version = "1.7.2" dependencies = [ "axum", "futures-util", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 026bb5ec..9e6a860a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pomotroid" -version = "1.7.1" +version = "1.7.2" description = "A simple and visually-pleasing Pomodoro timer" authors = ["Christopher Murphy "] edition = "2021" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f7ccf026..f2b03e7b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "settings", "stats"], + "windows": ["main", "settings", "stats", "better-stats"], "permissions": [ "core:default", "log:default", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 56993361..f1fac269 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -34,6 +34,19 @@ pub fn timer_reset(timer: State<'_, TimerController>) { timer.reset(); } +/// Restart the incremental-focus ladder from the base work duration. +/// +/// This is the manual reset trigger for the ladder: the round, cycle and session +/// counters are untouched, so it can be used mid-session when the ladder has +/// climbed past a comfortable focus length. +#[tauri::command] +pub fn timer_reset_increment(timer: State<'_, TimerController>, app: AppHandle) { + timer.reset_increment_ladder(); + // Broadcast the new snapshot so every window re-renders the ladder caption + // without waiting for the next tick or round change. + app.emit("timer:reset", &timer.get_snapshot()).ok(); +} + /// Skip the current round: fires Complete immediately and advances to the next. #[tauri::command] pub fn timer_skip(timer: State<'_, TimerController>) { @@ -251,6 +264,9 @@ pub fn settings_reset_defaults( }; timer.apply_settings(new_settings.clone()); + // A factory reset also drops the incremental ladder back to the base + // duration, matching the slate the reseeded settings describe. + timer.reset_increment_ladder(); *tray_state.countdown_mode.lock().unwrap() = new_settings.dial_countdown; // Broadcast a reset snapshot so the frontend dial and display reflect the @@ -378,6 +394,20 @@ pub fn stats_get_heatmap(db: State<'_, DbState>) -> Result }) } +/// Full analytics payload for the "Better Stats" window. +/// +/// Returns today's activity, rolling 7/28-day summaries, week-over-week and +/// day-over-day momentum, a 28-day trend, a 12-week rollup, all-time totals, +/// streaks, and hourly/weekday focus profiles — all in one round-trip. +#[tauri::command] +pub fn stats_get_insights(db: State<'_, DbState>) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + queries::get_insights(&conn).map_err(|e| { + log::error!("[stats] failed to build insights: {e}"); + e.to_string() + }) +} + // --------------------------------------------------------------------------- // CMD-05 — Window commands // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 113c3ac0..a6ec3d06 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -90,6 +90,26 @@ const MIGRATION_6: &str = " INSERT INTO schema_version VALUES (6); "; +/// Seeds the incremental-focus settings for users upgrading from a version that +/// did not have this feature. Disabled by default so existing behaviour is +/// preserved; the defaults match `settings::defaults::DEFAULTS`. +const MIGRATION_7: &str = " + INSERT OR IGNORE INTO settings (key, value) VALUES ('incremental_work_enabled', 'false'); + INSERT OR IGNORE INTO settings (key, value) VALUES ('time_work_increment_secs', '300'); + INSERT OR IGNORE INTO settings (key, value) VALUES ('time_work_max_secs', '5400'); + INSERT INTO schema_version VALUES (7); +"; + +/// Seeds the incremental-focus reset triggers for users upgrading from a build +/// that reset the ladder at every long break unconditionally. Both default to +/// 'true', which preserves that behaviour; turning either off lets the ladder +/// keep climbing across cycles and/or days. +const MIGRATION_8: &str = " + INSERT OR IGNORE INTO settings (key, value) VALUES ('incremental_reset_on_long_break', 'true'); + INSERT OR IGNORE INTO settings (key, value) VALUES ('incremental_reset_daily', 'true'); + INSERT INTO schema_version VALUES (8); +"; + /// Apply any pending migrations. Each migration is wrapped in a transaction /// so a partial failure leaves the database unchanged. pub fn run(conn: &Connection) -> Result<()> { @@ -131,6 +151,18 @@ pub fn run(conn: &Connection) -> Result<()> { log::info!("[db/migrations] MIGRATION_6 complete"); } + if version < 7 { + log::info!("[db/migrations] applying MIGRATION_7: seed incremental focus settings"); + conn.execute_batch(&format!("BEGIN; {MIGRATION_7} COMMIT;"))?; + log::info!("[db/migrations] MIGRATION_7 complete"); + } + + if version < 8 { + log::info!("[db/migrations] applying MIGRATION_8: seed incremental focus reset triggers"); + conn.execute_batch(&format!("BEGIN; {MIGRATION_8} COMMIT;"))?; + log::info!("[db/migrations] MIGRATION_8 complete"); + } + Ok(()) } @@ -166,7 +198,7 @@ mod tests { let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(v, 6); + assert_eq!(v, 8); } #[test] diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index b707b591..0d66b279 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -285,6 +285,885 @@ pub fn compute_streak(days: &[String], today: &str) -> StreakInfo { StreakInfo { current, longest } } +// --------------------------------------------------------------------------- +// Insights queries — richer analytics powering the "Better Stats" window +// --------------------------------------------------------------------------- + +/// A completed work round reduced to the local calendar day it started on. +struct WorkDay { + date: String, + /// Start-of-round hour (0–23) in local time. + hour: u32, + duration_secs: i64, +} + +/// Per-day rollup used for the trend chart. +#[derive(Debug, Clone, Serialize)] +pub struct TrendPoint { + /// Local calendar date in "YYYY-MM-DD" format. + pub date: String, + pub rounds: u32, + pub focus_mins: u32, +} + +/// Activity snapshot for one rolling window (e.g. the last 7 or 28 days). +#[derive(Debug, Serialize)] +pub struct PeriodSummary { + pub label: String, + pub days: u32, + pub rounds: u32, + pub focus_mins: u32, + pub active_days: u32, + /// Mean rounds per *active* day; `None` when nothing was recorded. + pub avg_rounds_per_active_day: Option, + /// Mean focus minutes per *active* day; `None` when nothing was recorded. + pub avg_focus_mins_per_active_day: Option, + /// Completed / started work rounds in the window; `None` if nothing started. + pub completion_rate: Option, + /// The single best day in the window (by rounds, focus time as tie-break). + pub best_day: Option, +} + +/// Aggregate weekday profile across all history. +#[derive(Debug, Serialize)] +pub struct WeekdayStat { + /// 0 = Monday … 6 = Sunday. + pub weekday: u32, + pub rounds: u32, + pub focus_mins: u32, +} + +/// Focus distribution by hour of day across all history. +#[derive(Debug, Serialize)] +pub struct HourStat { + pub hour: u32, + pub rounds: u32, + pub focus_mins: u32, +} + +/// One cell of the day-of-week × hour-of-day activity grid. +#[derive(Debug, Serialize)] +pub struct RhythmCell { + /// 0 = Monday … 6 = Sunday. + pub weekday: u32, + pub hour: u32, + pub rounds: u32, + pub focus_mins: u32, +} + +/// How a set of rounds splits across session lengths. +#[derive(Debug, Serialize)] +pub struct SessionLengthBucket { + /// Inclusive lower bound in minutes. + pub min_mins: u32, + /// Exclusive upper bound in minutes; `None` for the open-ended top bucket. + pub max_mins: Option, + pub rounds: u32, + pub focus_mins: u32, +} + +/// Turns a two-point series into a readable story. +#[derive(Debug, Serialize)] +pub struct Comparison { + pub label: String, + pub current: f32, + pub previous: f32, + /// `(current - previous) / previous`; `None` when the baseline is zero. + pub delta_pct: Option, +} + +/// Consecutive-day activity, both current and the all-time best. +#[derive(Debug, Serialize)] +pub struct StreakSummary { + pub current: u32, + pub longest: u32, + /// The most recent day with any completed work round ("YYYY-MM-DD"). + pub last_active_date: Option, + /// True when a live streak has nothing logged yet today. + pub at_risk: bool, +} + +/// Rolling-average and consistency metrics. +#[derive(Debug, Serialize)] +pub struct ConsistencyStats { + /// Days considered (trailing window, ending today). + pub window_days: u32, + /// Days in the window with at least one completed work round. + pub active_days: u32, + /// `active_days / window_days`. + pub active_ratio: f32, + /// Mean rounds logged per calendar day in the window (active or not). + pub avg_rounds_per_day: f32, + /// Mean focus minutes per calendar day in the window (active or not). + pub avg_focus_mins_per_day: f32, + /// Sample standard deviation of daily rounds — lower means steadier habits. + pub stddev_rounds: f32, + /// Days at or above 75% of the window's best day. + pub strong_days: u32, + /// Days with at least one round but at or below 25% of the best day. + pub light_days: u32, + /// Days in the window with nothing logged. + pub rest_days: u32, + /// Longest run of consecutive days with a session, within the window. + pub best_streak_in_window: u32, +} + +/// Personal bests and totals. +#[derive(Debug, Serialize)] +pub struct RecordStats { + pub total_rounds: u32, + pub total_focus_mins: u32, + /// Distinct calendar days with at least one completed work round. + pub tracked_days: u32, + pub avg_session_mins: Option, + /// Longest single completed work round, in minutes. + pub longest_session_mins: Option, + pub avg_rounds_per_active_day: Option, + pub best_day_rounds: Option, + pub best_day_focus: Option, + pub best_week: Option, +} + +/// Everything the "Better Stats" window needs, in a single IPC round-trip. +#[derive(Debug, Serialize)] +pub struct Insights { + /// Local calendar date the payload was computed for ("YYYY-MM-DD"). + pub today: String, + + // --- Today ------------------------------------------------------------- + pub today_rounds: u32, + pub today_focus_mins: u32, + /// 24 entries: completed work rounds started in each local hour today. + pub today_by_hour: Vec, + /// Rounds left to match the trailing daily average; negative when ahead. + pub rounds_to_beat_average: Option, + pub ahead_of_average: bool, + + // --- Momentum ---------------------------------------------------------- + /// Rolling 7 / 28-day windows anchored on today. + pub week: PeriodSummary, + pub month: PeriodSummary, + /// The last 7 and 90 calendar days (zero-filled windows). + pub last_7_days: PeriodSummary, + pub last_90_days: PeriodSummary, + /// The 7 calendar days immediately before `last_7_days`. + pub previous_7_days: PeriodSummary, + /// Head-to-head series for the momentum cards. + pub comparisons: Vec, + + // --- Trends ------------------------------------------------------------ + /// Trailing 28 days, zero-filled, oldest → newest. + pub trend: Vec, + /// Trailing 120 days, zero-filled, oldest → newest (default heatmap view). + pub heatmap: Vec, + /// Every day with recorded focus, oldest → newest. Lets the heatmap render + /// any month or year without shipping a separate query per range. + pub daily_all: Vec, + /// Trailing 12 Monday-start weeks. + pub weeks: Vec, + /// 7-day moving average aligned index-for-index with `trend`. + pub moving_avg_7: Vec, + /// Minutes of focus per day for the trailing 28 days. + pub focus_trend: Vec, + /// The 7 calendar days ending today (alias of `trend` tail, zero-filled). + pub daily_28: Vec, + + // --- Rhythm ------------------------------------------------------------ + /// All-time hourly focus distribution (24 entries). + pub hour_profile: Vec, + /// All-time weekday profile (7 entries, Monday first). + pub weekday_profile: Vec, + /// Weekday × hour activity grid (7 × 24 = 168 cells, row-major from Monday). + pub rhythm_grid: Vec, + /// Start hour of the earliest session ever logged. + pub active_from_hour: Option, + /// Start hour of the latest session ever logged. + pub active_to_hour: Option, + + // --- Shape of the work ------------------------------------------------- + /// Histogram of round lengths. + pub session_buckets: Vec, + /// Share of focus time contributed by the busiest day of the week. + pub top_weekday_share: Option, + /// Share of focus time contributed by the busiest hour of the day. + pub top_hour_share: Option, + + // --- Consistency / records -------------------------------------------- + pub consistency: ConsistencyStats, + pub records: RecordStats, + pub streak: StreakSummary, +} + + +/// Convenience accessors for pulling typed columns out of a `rusqlite` row. +trait RowExt { + fn get_string(&self, idx: usize) -> String; + fn get_i64(&self, idx: usize) -> i64; +} + +impl RowExt for rusqlite::Row<'_> { + fn get_string(&self, idx: usize) -> String { + self.get::<_, String>(idx).unwrap_or_default() + } + + fn get_i64(&self, idx: usize) -> i64 { + self.get::<_, i64>(idx).unwrap_or(0) + } +} + +/// Load every completed work round as (local date, local hour, duration). +/// +/// Volumes are small for a personal timer app, so the whole history is pulled +/// once and every derived metric is computed in Rust. That keeps the SQL +/// simple and makes `get_insights` a single consistent snapshot. +fn load_work_days(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT date(started_at, 'unixepoch', 'localtime') AS day, + CAST(strftime('%H', datetime(started_at, 'unixepoch', 'localtime')) AS INTEGER) AS hour, + duration_secs + FROM sessions + WHERE round_type = 'work' AND completed = 1 + ORDER BY started_at", + )?; + + let rows = stmt + .query_map([], |r| { + Ok(WorkDay { + date: r.get_string(0), + hour: r.get::<_, i64>(1).unwrap_or(0).clamp(0, 23) as u32, + duration_secs: r.get_i64(2), + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(rows) +} + +/// Seconds → whole minutes, rounding half up (matches `get_daily_stats`). +fn secs_to_mins(secs: i64) -> u32 { + ((secs + 30) / 60).max(0) as u32 +} + +/// Collapse work rounds into per-day totals, ordered oldest → newest. +fn roll_up_by_day(days: &[WorkDay]) -> Vec { + let mut out: Vec = Vec::new(); + for d in days { + match out.last_mut() { + Some(last) if last.date == d.date => { + last.rounds += 1; + last.focus_mins += secs_to_mins(d.duration_secs); + } + _ => out.push(TrendPoint { + date: d.date.clone(), + rounds: 1, + focus_mins: secs_to_mins(d.duration_secs), + }), + } + } + out +} + +/// Build a summary for the window `[start_day, today]` (both inclusive). +/// +/// `started` supplies the Started-work-round counts for the same window so the +/// completion rate can be derived without a second query. +fn summarise( + label: &str, + per_day: &[TrendPoint], + started_by_day: &[(String, u32)], + today: &str, + start_day: &str, + window_days: u32, +) -> PeriodSummary { + let in_window = |date: &str| date >= start_day && date <= today; + + let rows: Vec<&TrendPoint> = per_day.iter().filter(|p| in_window(&p.date)).collect(); + let rounds: u32 = rows.iter().map(|p| p.rounds).sum(); + let focus_mins: u32 = rows.iter().map(|p| p.focus_mins).sum(); + let active_days = rows.len() as u32; + + let started: u32 = started_by_day + .iter() + .filter(|(date, _)| in_window(date)) + .map(|(_, n)| *n) + .sum(); + + let best_day = rows + .iter() + .max_by_key(|p| (p.rounds, p.focus_mins)) + .map(|p| TrendPoint { + date: p.date.clone(), + rounds: p.rounds, + focus_mins: p.focus_mins, + }); + + PeriodSummary { + label: label.to_string(), + days: window_days, + rounds, + focus_mins, + active_days, + avg_rounds_per_active_day: (active_days > 0) + .then(|| rounds as f32 / active_days as f32), + avg_focus_mins_per_active_day: (active_days > 0) + .then(|| focus_mins as f32 / active_days as f32), + completion_rate: (started > 0).then(|| rounds as f32 / started as f32), + best_day, + } +} + +/// Count *started* (not necessarily completed) work rounds per local day. +fn load_started_by_day(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT date(started_at, 'unixepoch', 'localtime') AS day, COUNT(*) + FROM sessions + WHERE round_type = 'work' + GROUP BY day", + )?; + let rows = stmt + .query_map([], |r| { + Ok((r.get_string(0), r.get::<_, i64>(1).unwrap_or(0).max(0) as u32)) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + +/// Zero-fill the `count` calendar days ending at `today` (oldest → newest). +fn fill_recent_days(per_day: &[TrendPoint], today: &str, count: u32) -> Vec { + let today_num = match date_to_day_num(today) { + Some(n) => n, + None => return Vec::new(), + }; + + let mut out = Vec::with_capacity(count as usize); + for offset in (0..count).rev() { + let day_num = today_num - offset as i32; + let date = day_num_to_date(day_num); + let found = per_day.iter().find(|p| p.date == date); + out.push(TrendPoint { + date: date.clone(), + rounds: found.map(|p| p.rounds).unwrap_or(0), + focus_mins: found.map(|p| p.focus_mins).unwrap_or(0), + }); + } + out +} + +/// Aggregate per-day totals into Monday-start weeks, most recent `count` weeks. +fn roll_up_weeks(per_day: &[TrendPoint], today: &str, count: u32) -> Vec { + let today_num = match date_to_day_num(today) { + Some(n) => n, + None => return Vec::new(), + }; + + // Weekday of the Monday that starts the current week. + let days_since_monday = weekday_index(today_num); + let current_monday = today_num - days_since_monday as i32; + + let mut out = Vec::with_capacity(count as usize); + for offset in (0..count).rev() { + let start = current_monday - (offset as i32 * 7); + let end = start + 6; + + let mut rounds = 0u32; + let mut focus_mins = 0u32; + for p in per_day { + if let Some(n) = date_to_day_num(&p.date) { + if n >= start && n <= end { + rounds += p.rounds; + focus_mins += p.focus_mins; + } + } + } + + out.push(TrendPoint { + date: day_num_to_date(start), + rounds, + focus_mins, + }); + } + out +} + +/// Inverse of `date_to_day_num`: convert its day number to "YYYY-MM-DD". +/// +/// `date_to_day_num` runs exactly one day ahead of the standard epoch-day +/// numbering used by the civil-from-days algorithm below, so the input is +/// shifted by one first. Only differences between day numbers are ever +/// meaningful for interval maths, but the round-trip must be exact — see the +/// `day_num_round_trips_are_exact` test. +fn day_num_to_date(z: i32) -> String { + // Civil-from-days algorithm (Howard Hinnant), valid for the proleptic + // Gregorian calendar. + let z = z - 1; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}") +} + +/// Monday-based weekday index (0 = Monday … 6 = Sunday) for a day number. +/// +/// The `+ 1` aligns `date_to_day_num`'s numbering with Monday-first weeks; +/// verified against known weekdays in `weekday_index_matches_known_weekdays`. +fn weekday_index(day_num: i32) -> u32 { + (((day_num + 1) % 7 + 7) % 7) as u32 +} + +/// Full analytics payload for the "Better Stats" window. +pub fn get_insights(conn: &Connection) -> Result { + let today: String = conn.query_row("SELECT date('now', 'localtime')", [], |r| r.get(0))?; + let today_num = date_to_day_num(&today).unwrap_or(0); + + let work_days = load_work_days(conn)?; + let per_day = roll_up_by_day(&work_days); + let started_by_day = load_started_by_day(conn)?; + + // --- Today ------------------------------------------------------------- + let today_row = per_day.iter().find(|p| p.date == today); + let today_rounds = today_row.map(|p| p.rounds).unwrap_or(0); + let today_focus_mins = today_row.map(|p| p.focus_mins).unwrap_or(0); + + let mut today_by_hour = vec![0u32; 24]; + for d in work_days.iter().filter(|d| d.date == today) { + today_by_hour[d.hour as usize] += 1; + } + + // --- Windows ----------------------------------------------------------- + let day_str = |offset: i32| day_num_to_date(today_num - offset); + + let week_start = day_str(6); + let month_start = day_str(27); + let last7_start = day_str(6); + let prev7_start = day_str(13); + let prev7_end = day_str(7); + + let week = summarise( + "week", + &per_day, + &started_by_day, + &today, + &week_start, + 7, + ); + let month = summarise( + "month", + &per_day, + &started_by_day, + &today, + &month_start, + 28, + ); + let last_7_days = summarise( + "last7", + &per_day, + &started_by_day, + &today, + &last7_start, + 7, + ); + + // Previous 7 days: [prev7_start, prev7_end]. + let prev7 = { + let started: u32 = started_by_day + .iter() + .filter(|(date, _)| date.as_str() >= prev7_start.as_str() && date.as_str() <= prev7_end.as_str()) + .map(|(_, n)| *n) + .sum(); + let rows: Vec<&TrendPoint> = per_day + .iter() + .filter(|p| p.date.as_str() >= prev7_start.as_str() && p.date.as_str() <= prev7_end.as_str()) + .collect(); + let rounds: u32 = rows.iter().map(|p| p.rounds).sum(); + let focus_mins: u32 = rows.iter().map(|p| p.focus_mins).sum(); + let active_days = rows.len() as u32; + PeriodSummary { + label: "prev7".to_string(), + days: 7, + rounds, + focus_mins, + active_days, + avg_rounds_per_active_day: (active_days > 0).then(|| rounds as f32 / active_days as f32), + avg_focus_mins_per_active_day: (active_days > 0) + .then(|| focus_mins as f32 / active_days as f32), + completion_rate: (started > 0).then(|| rounds as f32 / started as f32), + best_day: rows + .iter() + .max_by_key(|p| (p.rounds, p.focus_mins)) + .map(|p| TrendPoint { + date: p.date.clone(), + rounds: p.rounds, + focus_mins: p.focus_mins, + }), + } + }; + + // --- Momentum ---------------------------------------------------------- + let rounds_to_beat_average = week + .avg_rounds_per_active_day + .map(|avg| avg.round() as i64 - today_rounds as i64); + let ahead_of_average = today_rounds as f32 > week.avg_rounds_per_active_day.unwrap_or(0.0); + + // --- Trend + weekly rollup -------------------------------------------- + let trend = fill_recent_days(&per_day, &today, 28); + let weeks = roll_up_weeks(&per_day, &today, 12); + + // --- Streaks ----------------------------------------------------------- + let mut stmt = conn.prepare( + "SELECT date(started_at, 'unixepoch', 'localtime') AS day + FROM sessions + WHERE round_type = 'work' AND completed = 1 + GROUP BY day + ORDER BY day", + )?; + let days: Vec = stmt + .query_map([], |r| r.get(0))? + .filter_map(|r| r.ok()) + .collect(); + let streak = compute_streak(&days, &today); + let streak_at_risk = streak.current > 0 + && today_rounds == 0 + && days.last().map(|d| d.as_str() != today).unwrap_or(false); + + // --- Profiles ---------------------------------------------------------- + let mut hour_totals = vec![(0u32, 0i64); 24]; + for d in &work_days { + hour_totals[d.hour as usize].0 += 1; + hour_totals[d.hour as usize].1 += d.duration_secs; + } + let hour_profile: Vec = hour_totals + .iter() + .enumerate() + .map(|(hour, (rounds, secs))| HourStat { + hour: hour as u32, + rounds: *rounds, + focus_mins: secs_to_mins(*secs), + }) + .collect(); + + let mut weekday_totals = vec![(0u32, 0i64); 7]; + for d in &work_days { + if let Some(num) = date_to_day_num(&d.date) { + let idx = weekday_index(num) as usize; + weekday_totals[idx].0 += 1; + weekday_totals[idx].1 += d.duration_secs; + } + } + let weekday_profile: Vec = weekday_totals + .iter() + .enumerate() + .map(|(weekday, (rounds, secs))| WeekdayStat { + weekday: weekday as u32, + rounds: *rounds, + focus_mins: secs_to_mins(*secs), + }) + .collect(); + + // --- Long-range windows ------------------------------------------------ + let last_90_days = summarise( + "last90", + &per_day, + &started_by_day, + &today, + &day_str(89), + 90, + ); + + // --- Series ------------------------------------------------------------ + let heatmap = fill_recent_days(&per_day, &today, 120); + let moving_avg_7 = moving_average(&trend, 7); + let focus_trend = trend.clone(); + // --- Momentum comparisons --------------------------------------------- + let comparisons = vec![ + Comparison { + label: "rounds_week".into(), + current: last_7_days.rounds as f32, + previous: prev7.rounds as f32, + delta_pct: pct_change(last_7_days.rounds as f32, prev7.rounds as f32), + }, + Comparison { + label: "focus_week".into(), + current: last_7_days.focus_mins as f32, + previous: prev7.focus_mins as f32, + delta_pct: pct_change(last_7_days.focus_mins as f32, prev7.focus_mins as f32), + }, + Comparison { + label: "active_days_week".into(), + current: last_7_days.active_days as f32, + previous: prev7.active_days as f32, + delta_pct: pct_change(last_7_days.active_days as f32, prev7.active_days as f32), + }, + Comparison { + label: "avg_per_active_day".into(), + current: last_7_days.avg_rounds_per_active_day.unwrap_or(0.0), + previous: prev7.avg_rounds_per_active_day.unwrap_or(0.0), + delta_pct: pct_change( + last_7_days.avg_rounds_per_active_day.unwrap_or(0.0), + prev7.avg_rounds_per_active_day.unwrap_or(0.0), + ), + }, + ]; + + // --- Rhythm grid (weekday × hour) -------------------------------------- + let mut grid = vec![(0u32, 0i64); 7 * 24]; + for d in &work_days { + if let Some(num) = date_to_day_num(&d.date) { + let wd = weekday_index(num) as usize; + let idx = wd * 24 + d.hour as usize; + grid[idx].0 += 1; + grid[idx].1 += d.duration_secs; + } + } + let rhythm_grid: Vec = grid + .iter() + .enumerate() + .map(|(i, (rounds, secs))| RhythmCell { + weekday: (i / 24) as u32, + hour: (i % 24) as u32, + rounds: *rounds, + focus_mins: secs_to_mins(*secs), + }) + .collect(); + + // --- Active span ------------------------------------------------------- + let active_from_hour = work_days.iter().map(|d| d.hour).min(); + let active_to_hour = work_days.iter().map(|d| d.hour).max(); + + // --- Session-length histogram ----------------------------------------- + let session_buckets = build_session_buckets(&work_days); + + // --- Concentration ----------------------------------------------------- + let top_weekday_share = { + let total: u32 = weekday_profile.iter().map(|w| w.focus_mins).sum(); + let best = weekday_profile.iter().map(|w| w.focus_mins).max().unwrap_or(0); + (total > 0).then(|| best as f32 / total as f32) + }; + let top_hour_share = { + let total: u32 = hour_profile.iter().map(|h| h.focus_mins).sum(); + let best = hour_profile.iter().map(|h| h.focus_mins).max().unwrap_or(0); + (total > 0).then(|| best as f32 / total as f32) + }; + + // --- Consistency ------------------------------------------------------- + let consistency = build_consistency(&per_day, &today, 30); + + // --- Records ----------------------------------------------------------- + let total_rounds: u32 = per_day.iter().map(|p| p.rounds).sum(); + let total_focus_mins: u32 = per_day.iter().map(|p| p.focus_mins).sum(); + let total_tracked_days = per_day.len() as u32; + let avg_session_mins = (total_rounds > 0).then(|| total_focus_mins as f32 / total_rounds as f32); + let best_day_rounds = per_day + .iter() + .max_by_key(|p| (p.rounds, p.focus_mins)) + .map(|p| p.clone()); + let best_day_focus = per_day + .iter() + .max_by_key(|p| (p.focus_mins, p.rounds)) + .map(|p| p.clone()); + let best_week = weeks.iter().max_by_key(|p| p.focus_mins).map(|p| p.clone()); + let longest_session_mins = work_days + .iter() + .map(|d| secs_to_mins(d.duration_secs)) + .max(); + + let records = RecordStats { + total_rounds, + total_focus_mins, + tracked_days: total_tracked_days, + avg_session_mins, + longest_session_mins, + avg_rounds_per_active_day: (total_tracked_days > 0) + .then(|| total_rounds as f32 / total_tracked_days as f32), + best_day_rounds, + best_day_focus, + best_week, + }; + + let streak = StreakSummary { + current: streak.current, + longest: streak.longest, + last_active_date: per_day.last().map(|p| p.date.clone()), + at_risk: streak_at_risk, + }; + + Ok(Insights { + today, + today_rounds, + today_focus_mins, + today_by_hour, + rounds_to_beat_average, + ahead_of_average, + week, + month, + last_7_days, + last_90_days, + previous_7_days: prev7, + comparisons, + trend: trend.clone(), + heatmap, + daily_all: per_day, + weeks, + moving_avg_7, + focus_trend, + daily_28: trend, + hour_profile, + weekday_profile, + rhythm_grid, + active_from_hour, + active_to_hour, + session_buckets, + top_weekday_share, + top_hour_share, + consistency, + records, + streak, + }) +} + +// --------------------------------------------------------------------------- +// Insights helpers +// --------------------------------------------------------------------------- + +/// `(current - previous) / previous`, or `None` when the baseline is zero. +fn pct_change(current: f32, previous: f32) -> Option { + (previous > 0.0).then(|| (current - previous) / previous) +} + +/// Mean of the `window` values ending at each index, aligned index-for-index +/// with the input. Values before a full window has accumulated average over +/// whatever is available, so the series has no leading gap. +fn moving_average(points: &[TrendPoint], window: usize) -> Vec { + if points.is_empty() { + return Vec::new(); + } + let window = window.max(1); + points + .iter() + .enumerate() + .map(|(i, _)| { + let start = (i + 1).saturating_sub(window); + let slice = &points[start..=i]; + let sum: u32 = slice.iter().map(|p| p.rounds).sum(); + sum as f32 / slice.len() as f32 + }) + .collect() +} + +/// Histogram of completed round lengths in minute buckets. +fn build_session_buckets(days: &[WorkDay]) -> Vec { + // (lower, upper) in minutes; the last bucket is open-ended. + const BOUNDS: [(u32, Option); 5] = [ + (0, Some(15)), + (15, Some(25)), + (25, Some(40)), + (40, Some(60)), + (60, None), + ]; + + let mut buckets: Vec = BOUNDS + .iter() + .map(|(min, max)| SessionLengthBucket { + min_mins: *min, + max_mins: *max, + rounds: 0, + focus_mins: 0, + }) + .collect(); + + for d in days { + let mins = secs_to_mins(d.duration_secs); + let bucket = buckets.iter_mut().find(|b| match b.max_mins { + Some(max) => mins >= b.min_mins && mins < max, + None => mins >= b.min_mins, + }); + if let Some(b) = bucket { + b.rounds += 1; + b.focus_mins += mins; + } + } + + buckets +} + +/// Habit metrics over the trailing `window` calendar days. +fn build_consistency(per_day: &[TrendPoint], today: &str, window: u32) -> ConsistencyStats { + let filled = fill_recent_days(per_day, today, window); + if filled.is_empty() { + return ConsistencyStats { + window_days: 0, + active_days: 0, + active_ratio: 0.0, + avg_rounds_per_day: 0.0, + avg_focus_mins_per_day: 0.0, + stddev_rounds: 0.0, + strong_days: 0, + light_days: 0, + rest_days: 0, + best_streak_in_window: 0, + }; + } + + let days = filled.len() as u32; + let active_days = filled.iter().filter(|p| p.rounds > 0).count() as u32; + let total_rounds: u32 = filled.iter().map(|p| p.rounds).sum(); + let total_focus: u32 = filled.iter().map(|p| p.focus_mins).sum(); + let mean = total_rounds as f32 / days as f32; + + // Sample standard deviation of daily rounds. + let variance = filled + .iter() + .map(|p| { + let d = p.rounds as f32 - mean; + d * d + }) + .sum::() + / days as f32; + let stddev_rounds = variance.sqrt(); + + let best = filled.iter().map(|p| p.rounds).max().unwrap_or(0); + let strong_cut = (best as f32 * 0.75).ceil() as u32; + let light_cut = (best as f32 * 0.25).floor() as u32; + + let strong_days = filled.iter().filter(|p| p.rounds >= strong_cut && best > 0).count() as u32; + let light_days = filled + .iter() + .filter(|p| p.rounds > 0 && p.rounds <= light_cut) + .count() as u32; + let rest_days = filled.iter().filter(|p| p.rounds == 0).count() as u32; + + let mut best_streak_in_window = 0u32; + let mut run = 0u32; + for p in &filled { + if p.rounds > 0 { + run += 1; + best_streak_in_window = best_streak_in_window.max(run); + } else { + run = 0; + } + } + + ConsistencyStats { + window_days: days, + active_days, + active_ratio: active_days as f32 / days as f32, + avg_rounds_per_day: mean, + avg_focus_mins_per_day: total_focus as f32 / days as f32, + stddev_rounds, + strong_days, + light_days, + rest_days, + best_streak_in_window, + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -442,4 +1321,431 @@ mod tests { assert_eq!(stats.completed_work_sessions, 1); assert_eq!(stats.total_work_secs, 1500); } + + // ----------------------------------------------------------------------- + // Insights + // ----------------------------------------------------------------------- + + #[test] + fn day_num_and_date_round_trip() { + for date in [ + "1970-01-01", + "2000-02-29", + "2024-03-15", + "2026-12-31", + "2027-01-01", + ] { + let num = date_to_day_num(date).expect("parse"); + assert_eq!(day_num_to_date(num), date, "round trip failed for {date}"); + } + } + + #[test] + fn day_num_advances_by_one_per_day() { + let a = date_to_day_num("2024-02-28").unwrap(); + let b = date_to_day_num("2024-02-29").unwrap(); // leap day + let c = date_to_day_num("2024-03-01").unwrap(); + assert_eq!(b - a, 1); + assert_eq!(c - b, 1); + } + + #[test] + fn weekday_index_matches_known_weekdays() { + // (date, Monday-based index) — 0 = Monday … 6 = Sunday. + let cases = [ + ("2024-03-11", 0), // Monday + ("2024-03-12", 1), + ("2024-03-13", 2), // Wednesday + ("2024-03-04", 0), // Monday + ("2024-03-10", 6), // Sunday + ("2000-02-29", 1), // Tuesday + ("1970-01-01", 3), // Thursday + ("2026-12-31", 3), // Thursday + ]; + for (date, want) in cases { + let n = date_to_day_num(date).expect("parse"); + assert_eq!( + weekday_index(n), + want, + "wrong weekday index for {date} (expected {want})" + ); + } + } + + #[test] + fn day_num_round_trips_are_exact() { + // Guards the one-day shift in day_num_to_date. These exact pairs were + // verified against an independent calendar implementation. + for date in [ + "1970-01-01", + "1970-01-02", + "1969-12-31", + "2000-02-29", + "2024-02-28", + "2024-02-29", + "2024-03-01", + "2024-03-15", + "2026-03-09", + "2026-09-25", + "2026-12-31", + "2027-01-01", + "2100-03-01", + "2038-01-19", + ] { + let n = date_to_day_num(date).expect("parse"); + assert_eq!( + day_num_to_date(n), + date, + "round trip failed for {date}" + ); + } + } + + #[test] + fn date_helpers_match_known_calendar_dates() { + // Absolute day numbers are an implementation detail, but these values + // pin the numbering so an accidental change is caught immediately. + assert_eq!(date_to_day_num("1970-01-01"), Some(719_469)); + assert_eq!(date_to_day_num("2026-09-25"), Some(740_190)); + // Consecutive days differ by exactly one. + let a = date_to_day_num("2026-09-24").unwrap(); + let b = date_to_day_num("2026-09-25").unwrap(); + assert_eq!(b - a, 1); + // And the inverse lands on the right date. + assert_eq!(day_num_to_date(740_190), "2026-09-25"); + assert_eq!(day_num_to_date(740_189), "2026-09-24"); + } + + #[test] + fn roll_up_weeks_starts_each_bucket_on_a_monday() { + // Regression guard for the weekday constant: every bucket start must be + // a Monday, and the last bucket must be the week containing `today`. + let weeks = roll_up_weeks(&[], "2024-03-13", 4); // Wednesday + assert_eq!(weeks.len(), 4); + assert_eq!(weeks.last().unwrap().date, "2024-03-11"); + for w in &weeks { + let n = date_to_day_num(&w.date).expect("parse"); + assert_eq!(weekday_index(n), 0, "{} is not a Monday", w.date); + } + } + + #[test] + fn fill_recent_days_zero_fills_and_orders_oldest_first() { + let per_day = vec![ + TrendPoint { date: "2024-03-10".into(), rounds: 2, focus_mins: 50 }, + TrendPoint { date: "2024-03-13".into(), rounds: 4, focus_mins: 100 }, + ]; + let filled = fill_recent_days(&per_day, "2024-03-13", 5); + + assert_eq!(filled.len(), 5); + assert_eq!(filled.first().unwrap().date, "2024-03-09"); + assert_eq!(filled.last().unwrap().date, "2024-03-13"); + + assert_eq!(filled[1].date, "2024-03-10"); + assert_eq!(filled[1].rounds, 2, "existing day must be preserved"); + assert_eq!(filled[3].date, "2024-03-12"); + assert_eq!(filled[3].rounds, 0, "missing days are zero-filled"); + assert_eq!(filled[4].rounds, 4); + } + + #[test] + fn roll_up_weeks_buckets_by_monday_start() { + // 2024-03-13 is a Wednesday → its week starts Monday 2024-03-11. + let per_day = vec![ + TrendPoint { date: "2024-03-11".into(), rounds: 1, focus_mins: 25 }, + TrendPoint { date: "2024-03-13".into(), rounds: 2, focus_mins: 50 }, + TrendPoint { date: "2024-03-04".into(), rounds: 5, focus_mins: 125 }, + ]; + let weeks = roll_up_weeks(&per_day, "2024-03-13", 2); + + assert_eq!(weeks.len(), 2); + assert_eq!(weeks[0].date, "2024-03-04", "previous week starts on a Monday"); + assert_eq!(weeks[0].rounds, 5); + assert_eq!(weeks[1].date, "2024-03-11", "current week starts on a Monday"); + assert_eq!(weeks[1].rounds, 3, "Monday + Wednesday roll into the same week"); + assert_eq!(weeks[1].focus_mins, 75); + } + + #[test] + fn roll_up_by_day_groups_consecutive_days() { + let days = vec![ + WorkDay { date: "2024-03-01".into(), hour: 9, duration_secs: 1500 }, + WorkDay { date: "2024-03-01".into(), hour: 10, duration_secs: 1500 }, + WorkDay { date: "2024-03-02".into(), hour: 11, duration_secs: 300 }, + ]; + let rolled = roll_up_by_day(&days); + + assert_eq!(rolled.len(), 2); + assert_eq!(rolled[0].date, "2024-03-01"); + assert_eq!(rolled[0].rounds, 2); + assert_eq!(rolled[0].focus_mins, 50); + assert_eq!(rolled[1].date, "2024-03-02"); + assert_eq!(rolled[1].rounds, 1); + assert_eq!(rolled[1].focus_mins, 5); + } + + #[test] + fn summarise_computes_averages_and_best_day() { + let per_day = vec![ + TrendPoint { date: "2024-03-11".into(), rounds: 2, focus_mins: 50 }, + TrendPoint { date: "2024-03-13".into(), rounds: 6, focus_mins: 150 }, + ]; + // Two work rounds started on the 11th, eight on the 13th. + let started = vec![("2024-03-11".to_string(), 2u32), ("2024-03-13".to_string(), 8)]; + + let s = summarise("week", &per_day, &started, "2024-03-13", "2024-03-07", 7); + + assert_eq!(s.rounds, 8); + assert_eq!(s.focus_mins, 200); + assert_eq!(s.active_days, 2); + assert_eq!(s.avg_rounds_per_active_day, Some(4.0)); + assert_eq!(s.avg_focus_mins_per_active_day, Some(100.0)); + assert_eq!(s.completion_rate, Some(8.0 / 10.0)); + assert_eq!(s.best_day.as_ref().unwrap().date, "2024-03-13"); + assert_eq!(s.best_day.as_ref().unwrap().rounds, 6); + } + + #[test] + fn summarise_handles_empty_window() { + let s = summarise("week", &[], &[], "2024-03-13", "2024-03-07", 7); + assert_eq!(s.rounds, 0); + assert!(s.avg_rounds_per_active_day.is_none()); + assert!(s.completion_rate.is_none()); + assert!(s.best_day.is_none()); + } + + #[test] + fn get_insights_empty_db_is_well_formed() { + let conn = setup(); + let i = get_insights(&conn).unwrap(); + + assert_eq!(i.today_rounds, 0); + assert_eq!(i.today_focus_mins, 0); + assert_eq!(i.today_by_hour.len(), 24); + assert_eq!(i.hour_profile.len(), 24); + assert_eq!(i.weekday_profile.len(), 7); + assert_eq!(i.trend.len(), 28); + assert_eq!(i.weeks.len(), 12); + assert_eq!(i.heatmap.len(), 120); + assert_eq!(i.moving_avg_7.len(), 28); + assert_eq!(i.rhythm_grid.len(), 7 * 24); + assert_eq!(i.session_buckets.len(), 5); + assert_eq!(i.comparisons.len(), 4); + assert_eq!(i.records.total_rounds, 0); + assert_eq!(i.streak.current, 0); + assert!(!i.streak.at_risk, "no streak means nothing to lose"); + assert!(i.records.avg_session_mins.is_none()); + assert!(i.rounds_to_beat_average.is_none()); + assert!(!i.ahead_of_average); + assert!(i.active_from_hour.is_none()); + assert!(i.top_hour_share.is_none()); + assert_eq!(i.consistency.window_days, 30); + assert_eq!(i.consistency.active_days, 0); + assert_eq!(i.consistency.rest_days, 30, "an empty month is all rest days"); + // Every series must be zero-filled and ordered oldest → newest. + assert!(i.trend.iter().all(|p| p.rounds == 0)); + assert!(i.heatmap.iter().all(|p| p.rounds == 0)); + assert_eq!(i.trend.last().unwrap().date, i.today); + assert_eq!(i.heatmap.last().unwrap().date, i.today); + assert!(i.moving_avg_7.iter().all(|v| *v == 0.0)); + } + + #[test] + fn get_insights_rolls_up_backdated_sessions() { + let conn = setup(); + let today: String = conn + .query_row("SELECT date('now', 'localtime')", [], |r| r.get(0)) + .unwrap(); + + // Two completed work rounds today, one skipped, one break. + let a = insert_session(&conn, "work", 1500).unwrap(); + complete_session(&conn, a, true).unwrap(); + let b = insert_session(&conn, "work", 1500).unwrap(); + complete_session(&conn, b, true).unwrap(); + let c = insert_session(&conn, "work", 1500).unwrap(); + complete_session(&conn, c, false).unwrap(); + let d = insert_session(&conn, "short-break", 300).unwrap(); + complete_session(&conn, d, true).unwrap(); + + let i = get_insights(&conn).unwrap(); + + assert_eq!(i.today_rounds, 2, "only completed work rounds count"); + assert_eq!(i.today_focus_mins, 50); + assert_eq!(i.records.total_rounds, 2); + assert_eq!(i.records.avg_session_mins, Some(25.0)); + assert_eq!(i.records.longest_session_mins, Some(25)); + assert_eq!(i.records.tracked_days, 1); + assert_eq!(i.streak.current, 1, "today counts toward the streak"); + assert!(!i.streak.at_risk, "today already has a session"); + assert_eq!(i.streak.last_active_date.as_deref(), Some(i.today.as_str())); + assert_eq!(i.week.rounds, 2); + assert_eq!(i.week.completion_rate, Some(2.0 / 3.0)); + assert_eq!(i.trend.last().unwrap().date, today); + assert_eq!(i.trend.last().unwrap().rounds, 2); + assert_eq!(i.weekday_profile.iter().map(|w| w.rounds).sum::(), 2); + assert_eq!(i.hour_profile.iter().map(|h| h.rounds).sum::(), 2); + assert_eq!(i.rhythm_grid.iter().map(|c| c.rounds).sum::(), 2); + assert_eq!( + i.session_buckets.iter().map(|b| b.rounds).sum::(), + 2, + "every round lands in exactly one bucket" + ); + // Both rounds are 25 min → the 25–40 bucket. + assert_eq!(i.session_buckets[2].rounds, 2); + // Best day is today for both metrics. + assert_eq!(i.records.best_day_rounds.as_ref().unwrap().date, today); + assert_eq!(i.records.best_day_focus.as_ref().unwrap().date, today); + // Consistency over the trailing 30 days: one active day. + assert_eq!(i.consistency.active_days, 1); + assert_eq!(i.consistency.rest_days, 29); + // 2 rounds on a single day makes a 1-day best streak inside the window. + assert_eq!(i.consistency.best_streak_in_window, 1); + assert!(i.active_from_hour.is_some()); + assert!(i.active_to_hour.is_some()); + assert!(i.moving_avg_7.last().unwrap() > &0.0); + assert_eq!( + i.moving_avg_7.last().copied(), + Some(2.0), + "a single day of 2 rounds averages to 2.0" + ); + // Today beats the trailing average once there is history. + assert_eq!(i.comparisons.len(), 4); + } + + #[test] + fn get_insights_marks_a_streak_at_risk() { + let conn = setup(); + // One completed work round yesterday, none today. + conn.execute( + "INSERT INTO sessions (started_at, ended_at, round_type, duration_secs, completed) + VALUES (strftime('%s', 'now', 'localtime', 'start of day', '-1 day') + 36000, + strftime('%s', 'now', 'localtime', 'start of day', '-1 day') + 37500, + 'work', 1500, 1)", + [], + ) + .unwrap(); + + let i = get_insights(&conn).unwrap(); + assert_eq!(i.today_rounds, 0); + assert_eq!(i.streak.current, 1, "yesterday keeps the streak alive"); + assert!(i.streak.at_risk, "a live streak with nothing logged today is at risk"); + } + + // ----------------------------------------------------------------------- + // Insights helpers + // ----------------------------------------------------------------------- + + #[test] + fn pct_change_handles_zero_baseline() { + assert_eq!(pct_change(10.0, 0.0), None, "dividing by a zero baseline is undefined"); + assert_eq!(pct_change(10.0, 10.0), Some(0.0)); + assert_eq!(pct_change(15.0, 10.0), Some(0.5)); + assert_eq!(pct_change(5.0, 10.0), Some(-0.5)); + } + + #[test] + fn moving_average_ramps_then_tracks_the_window() { + let points: Vec = [4u32, 0, 2, 0, 8, 2, 0, 10] + .iter() + .enumerate() + .map(|(i, r)| TrendPoint { + date: format!("2024-03-{:02}", i + 1), + rounds: *r, + focus_mins: r * 25, + }) + .collect(); + + let avg = moving_average(&points, 7); + assert_eq!(avg.len(), points.len(), "aligned index-for-index with the input"); + // Partial window: the first value is just the first day. + assert_eq!(avg[0], 4.0); + // Three days in: (4 + 0 + 2) / 3 = 2.0 + assert_eq!(avg[2], 2.0); + // Fully saturated 7-day window at index 6: (4+0+2+0+8+2+0)/7 = 2.2857… + assert!((avg[6] - 16.0 / 7.0).abs() < 1e-5); + // Index 7 rolls the window forward: (0+2+0+8+2+0+10)/7 = 22/7 + assert!((avg[7] - 22.0 / 7.0).abs() < 1e-5); + } + + #[test] + fn moving_average_of_empty_series_is_empty() { + assert!(moving_average(&[], 7).is_empty()); + } + + #[test] + fn session_buckets_partition_every_round() { + let days = vec![ + WorkDay { date: "2024-03-01".into(), hour: 9, duration_secs: 5 * 60 }, // 5m + WorkDay { date: "2024-03-01".into(), hour: 10, duration_secs: 15 * 60 }, // boundary → 15-25 + WorkDay { date: "2024-03-01".into(), hour: 11, duration_secs: 25 * 60 }, // boundary → 25-40 + WorkDay { date: "2024-03-01".into(), hour: 12, duration_secs: 60 * 60 }, // open top bucket + WorkDay { date: "2024-03-01".into(), hour: 13, duration_secs: 45 * 60 }, // 40-60 + ]; + let buckets = build_session_buckets(&days); + + assert_eq!(buckets.iter().map(|b| b.rounds).sum::(), 5); + assert_eq!(buckets[0].rounds, 1, "<15m holds the 5m round"); + assert_eq!(buckets[1].rounds, 1, "15m falls into the 15-25 bucket"); + assert_eq!(buckets[2].rounds, 1, "25m falls into the 25-40 bucket"); + assert_eq!(buckets[3].rounds, 1, "45m falls into the 40-60 bucket"); + assert_eq!(buckets[4].rounds, 1, "60m falls into the open bucket"); + assert_eq!(buckets[4].max_mins, None, "the top bucket is open-ended"); + // Buckets are contiguous and ordered. + for pair in buckets.windows(2) { + assert_eq!(pair[0].max_mins, Some(pair[1].min_mins)); + } + } + + #[test] + fn consistency_measures_habit_strength() { + // A deliberate pattern inside a 10-day window: + // 2 days at 8 rounds (strong), 1 day at 1 round (light), 4 rest days. + let mut per_day = Vec::new(); + for (date, rounds) in [ + ("2024-03-01", 8u32), + ("2024-03-02", 8), + ("2024-03-05", 1), + ("2024-03-08", 4), + ("2024-03-09", 6), + ("2024-03-10", 2), + ] { + per_day.push(TrendPoint { date: date.into(), rounds, focus_mins: rounds * 25 }); + } + + let c = build_consistency(&per_day, "2024-03-10", 10); + + assert_eq!(c.window_days, 10); + assert_eq!(c.active_days, 6); + assert!((c.active_ratio - 0.6).abs() < 1e-6); + assert_eq!(c.rest_days, 4); + // Best day is 8 → strong cut is ceil(6) = 6, light cut is floor(2) = 2. + assert_eq!(c.strong_days, 3, "8, 8 and 6 are strong days"); + assert_eq!(c.light_days, 1, "only the 1-round day counts as light"); + // The window has no consecutive active days. + assert_eq!(c.best_streak_in_window, 1); + // Mean over all 10 calendar days, not just active ones. + assert!((c.avg_rounds_per_day - 2.9).abs() < 1e-5); + assert!(c.stddev_rounds > 0.0, "an uneven pattern has spread"); + } + + #[test] + fn consistency_of_empty_history_is_zeroed() { + let c = build_consistency(&[], "2024-03-10", 30); + assert_eq!(c.window_days, 30); + assert_eq!(c.active_days, 0); + assert_eq!(c.rest_days, 30); + assert_eq!(c.stddev_rounds, 0.0); + assert_eq!(c.best_streak_in_window, 0); + } + + #[test] + fn consistency_detects_consecutive_runs() { + let per_day: Vec = ["2024-03-01", "2024-03-02", "2024-03-03", "2024-03-07"] + .iter() + .map(|d| TrendPoint { date: (*d).into(), rounds: 3, focus_mins: 75 }) + .collect(); + let c = build_consistency(&per_day, "2024-03-07", 30); + assert_eq!(c.best_streak_in_window, 3, "the 1st–3rd form a 3-day run"); + assert_eq!(c.active_days, 4); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f4596d7..965f4f02 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,9 +27,10 @@ use commands::{ settings_get, settings_reset_defaults, settings_set, shortcuts_reload, sessions_clear, - stats_get_detailed, stats_get_heatmap, + stats_get_detailed, stats_get_heatmap, stats_get_insights, themes_list, - timer_get_state, timer_reset, timer_restart_round, timer_skip, timer_toggle, + timer_get_state, timer_reset, timer_reset_increment, timer_restart_round, timer_skip, + timer_toggle, window_set_visibility, }; @@ -370,6 +371,7 @@ pub fn run() { // Timer timer_toggle, timer_reset, + timer_reset_increment, timer_restart_round, timer_skip, timer_get_state, @@ -384,6 +386,7 @@ pub fn run() { // Stats stats_get_detailed, stats_get_heatmap, + stats_get_insights, // Window window_set_visibility, // Shortcuts diff --git a/src-tauri/src/settings/defaults.rs b/src-tauri/src/settings/defaults.rs index 2edea8ff..9c6c5ceb 100644 --- a/src-tauri/src/settings/defaults.rs +++ b/src-tauri/src/settings/defaults.rs @@ -23,6 +23,11 @@ pub const DEFAULTS: &[(&str, &str)] = &[ ("time_work_secs", "1500"), ("time_short_break_secs", "300"), ("time_long_break_secs", "900"), + ("incremental_work_enabled", "false"), + ("time_work_increment_secs", "300"), + ("time_work_max_secs", "5400"), + ("incremental_reset_on_long_break", "true"), + ("incremental_reset_daily", "true"), ("volume", "100"), ("websocket_enabled", "false"), ("websocket_port", "1314"), diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 268c76c1..9bb3f3f4 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -37,6 +37,19 @@ pub struct Settings { pub time_short_break_secs: u32, /// Long break duration in seconds. pub time_long_break_secs: u32, + /// Incremental focus mode: each completed work round lengthens the next one. + pub incremental_work_enabled: bool, + /// Seconds added to the work duration after each completed work round. + pub time_work_increment_secs: u32, + /// Hard ceiling for the escalated work duration in seconds. + pub time_work_max_secs: u32, + /// Incremental focus: restart the ladder from the base work duration when a + /// long break begins. When `false` the ladder keeps climbing across cycles. + pub incremental_reset_on_long_break: bool, + /// Incremental focus: restart the ladder from the base work duration when the + /// local calendar day changes. When `false` the ladder keeps climbing across + /// days until a manual reset. + pub incremental_reset_daily: bool, /// Audio volume in the 0.0–1.0 range. pub volume: f32, pub shortcut_toggle: String, @@ -90,6 +103,11 @@ impl Default for Settings { time_work_secs: 25 * 60, time_short_break_secs: 5 * 60, time_long_break_secs: 15 * 60, + incremental_work_enabled: false, + time_work_increment_secs: 5 * 60, + time_work_max_secs: 90 * 60, + incremental_reset_on_long_break: true, + incremental_reset_daily: true, volume: 1.0, #[cfg(target_os = "macos")] shortcut_toggle: "Super+Shift+1".to_string(), @@ -213,6 +231,27 @@ pub fn load(conn: &Connection) -> Result { time_work_secs: parse_u32(&map, "time_work_secs", d.time_work_secs), time_short_break_secs: parse_u32(&map, "time_short_break_secs", d.time_short_break_secs), time_long_break_secs: parse_u32(&map, "time_long_break_secs", d.time_long_break_secs), + incremental_work_enabled: parse_bool( + &map, + "incremental_work_enabled", + d.incremental_work_enabled, + ), + time_work_increment_secs: parse_u32( + &map, + "time_work_increment_secs", + d.time_work_increment_secs, + ), + time_work_max_secs: parse_u32(&map, "time_work_max_secs", d.time_work_max_secs), + incremental_reset_on_long_break: parse_bool( + &map, + "incremental_reset_on_long_break", + d.incremental_reset_on_long_break, + ), + incremental_reset_daily: parse_bool( + &map, + "incremental_reset_daily", + d.incremental_reset_daily, + ), // DB stores 0–100; convert to 0.0–1.0. volume: (parse_u32(&map, "volume", (d.volume * 100.0) as u32) as f32 / 100.0) .clamp(0.0, 1.0), diff --git a/src-tauri/src/timer/mod.rs b/src-tauri/src/timer/mod.rs index c7d6c904..a38ea3e7 100644 --- a/src-tauri/src/timer/mod.rs +++ b/src-tauri/src/timer/mod.rs @@ -9,12 +9,65 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::audio::{AudioCue, AudioManager}; use crate::db::{queries, DbState}; -use crate::settings::Settings; +use crate::settings::{self, Settings}; use crate::tray::{self, TrayState}; use crate::websocket::{self, WsState}; use engine::{EngineHandle, TimerCommand, TimerEvent}; -use sequence::{RoundType, SequenceState}; +use sequence::{DayRollover, RoundType, SequenceState}; + +// --------------------------------------------------------------------------- +// Persisted incremental-focus ladder +// --------------------------------------------------------------------------- +// +// The ladder lives in `SequenceState`, but unlike the round counters it is +// expected to survive a restart: with the long-break/day reset triggers turned +// off it is supposed to keep climbing until the user resets it. It is stored in +// the settings key/value table (which already holds similar runtime state such +// as the last window position) under its own two keys. + +/// Settings key holding the number of increments currently applied. +const KEY_LADDER_STEPS: &str = "ladder_steps"; +/// Settings key holding the local date the ladder was last touched on. +const KEY_LADDER_DAY: &str = "ladder_day"; + +/// Today's local calendar date as `"YYYY-MM-DD"`, or `None` if the database +/// cannot answer. Read through SQLite so the ladder's notion of "today" matches +/// the statistics queries exactly. +fn today_local(db: &DbState) -> Option { + let conn = db.lock().ok()?; + conn.query_row("SELECT date('now', 'localtime')", [], |row| row.get::<_, String>(0)) + .map_err(|e| log::warn!("[timer] could not read the local date: {e}")) + .ok() +} + +/// Copy the ladder's position out of the sequence state. +fn ladder_position(sequence: &Arc>) -> (u32, Option) { + let seq = sequence.lock().unwrap(); + (seq.work_rounds_completed, seq.ladder_day.clone()) +} + +/// Persist the ladder so it survives an app restart. +fn persist_ladder(db: &DbState, steps: u32, day: Option<&str>) { + if let Ok(conn) = db.lock() { + let _ = settings::save_setting(&conn, KEY_LADDER_STEPS, &steps.to_string()); + let _ = settings::save_setting(&conn, KEY_LADDER_DAY, day.unwrap_or_default()); + } +} + +/// Restore a previously persisted ladder into `seq`. Must run before the first +/// duration is computed so a restart resumes at the right step. +fn restore_ladder(db: &DbState, seq: &mut SequenceState) { + let Ok(conn) = db.lock() else { return }; + if let Some(steps) = settings::get_setting(&conn, KEY_LADDER_STEPS) + .and_then(|v| v.parse::().ok()) + { + seq.work_rounds_completed = steps; + } + if let Some(day) = settings::get_setting(&conn, KEY_LADDER_DAY).filter(|d| !d.is_empty()) { + seq.ladder_day = Some(day); + } +} // --------------------------------------------------------------------------- // Snapshot — serialized to JSON for the frontend @@ -38,6 +91,18 @@ pub struct TimerSnapshot { /// Monotonically-increasing focus round count since last reset. Used as a /// session counter when long breaks are disabled. pub session_work_count: u32, + /// Incremental focus mode: whether the escalating ladder is active. + pub incremental_work_enabled: bool, + /// The base (un-escalated) work duration from settings, in seconds. + pub base_work_secs: u32, + /// Seconds added to the work duration per completed work round. + pub work_increment_secs: u32, + /// Escalation ceiling for the work duration in seconds. + pub work_max_secs: u32, + /// How many increments are currently applied to the work duration. + pub increment_steps: u32, + /// True when the work duration has reached the configured ceiling. + pub at_increment_cap: bool, } // --------------------------------------------------------------------------- @@ -58,6 +123,8 @@ pub struct TimerController { sequence: Arc>, settings: Arc>, shared: Arc>, + /// Used to persist the incremental ladder and to resolve the local date. + db: DbState, /// Kept alive so TrayState is not dropped if lib.rs forgets its copy. #[allow(dead_code)] tray: Arc, @@ -72,7 +139,9 @@ impl TimerController { tray: Arc, db: DbState, ) -> Self { - let seq = SequenceState::new(settings.long_break_interval); + let mut seq = SequenceState::new(settings.long_break_interval); + // Resume a ladder that was left climbing by a previous run. + restore_ladder(&db, &mut seq); let duration = seq.current_duration_secs(&settings); let (engine, event_rx) = engine::spawn(duration, Duration::from_secs(1)); @@ -90,6 +159,7 @@ impl TimerController { let shared_thread = Arc::clone(&shared); let engine_thread = engine.clone(); let tray_thread = Arc::clone(&tray); + let db_thread = Arc::clone(&db); std::thread::Builder::new() .name("timer-events".to_string()) @@ -103,7 +173,7 @@ impl TimerController { shared: shared_thread, engine: engine_thread, tray: tray_thread, - db, + db: db_thread, }, ); }) @@ -114,14 +184,59 @@ impl TimerController { sequence, settings: settings_arc, shared, + db, tray, } } // --- Commands --- + /// True when the timer sits at the start of a round: nothing running and + /// nothing elapsed. + fn is_idle(&self) -> bool { + let s = self.shared.lock().unwrap(); + !s.is_running && s.elapsed_secs == 0 + } + + /// Persist the ladder's current position. + fn save_ladder(&self) { + let (steps, day) = ladder_position(&self.sequence); + persist_ladder(&self.db, steps, day.as_deref()); + } + + /// Restart the ladder when the local calendar day has changed. + /// + /// Only fires while the timer is idle: a round that is already running or + /// paused keeps the duration it was primed with, and the rollover is picked + /// up at the next round boundary instead (see the `Complete` handler). + /// Returns true when the ladder actually restarted. + fn roll_ladder_day(&self) -> bool { + if !self.is_idle() { + return false; + } + let Some(today) = today_local(&self.db) else { + return false; + }; + let rollover = { + let mut seq = self.sequence.lock().unwrap(); + let s = self.settings.lock().unwrap(); + seq.check_day_rollover(&s, &today) + }; + if !rollover.day_changed { + return false; + } + // The day moved on even when the reset itself is disabled. + self.save_ladder(); + if rollover.reset { + log::info!("[timer] incremental focus ladder restarted for a new day"); + self.reconfigure(); + } + rollover.reset + } + /// Toggle: start a fresh timer if idle, resume if paused, pause if running. pub fn toggle(&self) { + self.roll_ladder_day(); let s = self.shared.lock().unwrap(); if s.is_running { log::info!("[timer] pause"); @@ -138,12 +253,32 @@ impl TimerController { pub fn reset(&self) { log::info!("[timer] reset"); self.sequence.lock().unwrap().reset(); + self.save_ladder(); // Send only Reset — the event listener's Reset handler will follow up // with Prime once the engine is confirmed Idle. Sending a duration // update here first would race the Reset and can leave the UI stale. self.engine.send(TimerCommand::Reset); } + /// Restart the incremental-focus ladder from the base duration. + /// + /// Manual reset trigger: the round, cycle and session counters are left + /// alone, and a running countdown is not interrupted — only the rounds that + /// follow are shortened back to the base focus duration. + pub fn reset_increment_ladder(&self) { + let today = today_local(&self.db); + let changed = self + .sequence + .lock() + .unwrap() + .reset_ladder(today.as_deref()); + log::info!("[timer] incremental focus ladder reset (cleared {changed})"); + self.save_ladder(); + if self.is_idle() { + self.reconfigure(); + } + } + /// Restart only the current round's timer without touching the sequence. /// Round type, round number, and position in the work/break cycle are all /// preserved — only the elapsed time is zeroed. @@ -179,6 +314,10 @@ impl TimerController { // --- Query --- pub fn get_snapshot(&self) -> TimerSnapshot { + // A snapshot is the main way the ladder becomes visible, so it is also + // where a day rollover is noticed while the app sits idle. + self.roll_ladder_day(); + let seq = self.sequence.lock().unwrap(); let settings = self.settings.lock().unwrap(); let shared = self.shared.lock().unwrap(); @@ -193,6 +332,12 @@ impl TimerController { work_round_number: seq.work_round_number, work_rounds_total: seq.work_rounds_total, session_work_count: seq.session_work_count, + incremental_work_enabled: settings.incremental_work_enabled, + base_work_secs: settings.time_work_secs, + work_increment_secs: settings.time_work_increment_secs, + work_max_secs: settings.time_work_max_secs, + increment_steps: seq.work_increment_steps(&settings), + at_increment_cap: seq.work_duration_at_cap(&settings), } } @@ -209,6 +354,9 @@ impl TimerController { // reflect the new long_break_interval immediately. self.sequence.lock().unwrap().work_rounds_total = new.long_break_interval; *self.settings.lock().unwrap() = new; + // Picks up a day rollover that happened while nothing was running, so + // the reconfigure below primes the corrected duration. + self.roll_ladder_day(); let s = self.shared.lock().unwrap(); let is_idle = !s.is_running && s.elapsed_secs == 0; drop(s); @@ -314,14 +462,35 @@ fn listen_events( } } - // Advance sequence. + // Advance sequence. A new calendar day restarts the ladder + // before the next round's duration is computed. + let today = today_local(&db); + let completed_work_round = completed_round == "work"; let (next_round, next_duration, auto_start_work, auto_start_break) = { let mut seq = sequence.lock().unwrap(); let s = settings.lock().unwrap(); - let (rt, dur) = seq.advance(&s); + let rollover = match today.as_deref() { + Some(today) => seq.check_day_rollover(&s, today), + None => DayRollover::default(), + }; + let (rt, mut dur) = seq.advance(&s); + // A work round that finished just after midnight belongs to + // the day it started in, so it must not extend the new day's + // ladder: drop its step and re-derive the following duration. + if rollover.reset && completed_work_round { + seq.reset_ladder(today.as_deref()); + dur = seq.current_duration_secs(&s); + } (rt, dur, s.auto_start_work, s.auto_start_break) }; + // The ladder moved (or restarted) — keep the persisted copy in + // step. Both locks are released before the database is touched. + { + let (steps, day) = ladder_position(&sequence); + persist_ladder(&db, steps, day.as_deref()); + } + // Reset shared state for the new round. { let mut s = shared.lock().unwrap(); @@ -515,5 +684,71 @@ fn build_snapshot( work_round_number: seq.work_round_number, work_rounds_total: seq.work_rounds_total, session_work_count: seq.session_work_count, + incremental_work_enabled: s.incremental_work_enabled, + base_work_secs: s.time_work_secs, + work_increment_secs: s.time_work_increment_secs, + work_max_secs: s.time_work_max_secs, + increment_steps: seq.work_increment_steps(&s), + at_increment_cap: seq.work_duration_at_cap(&s), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::migrations; + + fn test_db() -> DbState { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + migrations::run(&conn).unwrap(); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn persisted_ladder_survives_a_restart() { + let db = test_db(); + persist_ladder(&db, 3, Some("2026-05-01")); + + // A restart builds a brand-new sequence; the ladder comes back. + let mut seq = SequenceState::new(4); + restore_ladder(&db, &mut seq); + + assert_eq!(seq.work_rounds_completed, 3); + assert_eq!(seq.ladder_day.as_deref(), Some("2026-05-01")); + } + + #[test] + fn missing_ladder_state_restores_to_zero() { + let db = test_db(); + let mut seq = SequenceState::new(4); + restore_ladder(&db, &mut seq); + + assert_eq!(seq.work_rounds_completed, 0); + assert_eq!(seq.ladder_day, None); + } + + #[test] + fn ladder_reset_persists_an_empty_day() { + let db = test_db(); + persist_ladder(&db, 5, Some("2026-05-01")); + // A manual reset clears the day, which round-trips as "unknown". + persist_ladder(&db, 0, None); + + let mut seq = SequenceState::new(4); + restore_ladder(&db, &mut seq); + + assert_eq!(seq.work_rounds_completed, 0); + assert_eq!(seq.ladder_day, None, "an empty day must not become Some(\"\")"); + } + + #[test] + fn today_local_returns_an_iso_date() { + let db = test_db(); + let today = today_local(&db).expect("in-memory SQLite can report the date"); + + assert_eq!(today.len(), 10, "expected YYYY-MM-DD, got {today}"); + assert_eq!(&today[4..5], "-"); + assert_eq!(&today[7..8], "-"); + assert!(today.chars().filter(|c| c.is_ascii_digit()).count() == 8); } } diff --git a/src-tauri/src/timer/sequence.rs b/src-tauri/src/timer/sequence.rs index a90d6d3e..9981bfe4 100644 --- a/src-tauri/src/timer/sequence.rs +++ b/src-tauri/src/timer/sequence.rs @@ -49,6 +49,24 @@ pub struct SequenceState { /// Unlike `work_round_number` this never resets at cycle boundaries, /// so it can be used as a session counter when long breaks are disabled. pub session_work_count: u32, + /// Work rounds finished since the current ladder began. Drives the + /// incremental focus mode. It restarts at the base duration whenever a + /// ladder reset fires — the start of a long break, a new calendar day, or a + /// manual reset — unless the matching setting turns that trigger off, in + /// which case the ladder keeps climbing across cycles and days. + pub work_rounds_completed: u32, + /// Local calendar date (`"YYYY-MM-DD"`) the ladder was last touched on, used + /// to detect a day rollover. `None` until the first rollover check. + pub ladder_day: Option, +} + +/// Result of a day-rollover check (see [`SequenceState::check_day_rollover`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DayRollover { + /// The calendar day changed since the ladder was last touched. + pub day_changed: bool, + /// The ladder restarted from the base duration as a result. + pub reset: bool, } impl SequenceState { @@ -59,23 +77,65 @@ impl SequenceState { work_round_number: 1, work_rounds_total, session_work_count: 1, + work_rounds_completed: 0, + ladder_day: None, } } /// Duration of the current round in seconds, taken from settings. + /// + /// In incremental focus mode a work round is lengthened by + /// `time_work_increment_secs` for every work round already completed in the + /// current ladder, capped at `time_work_max_secs` and never shorter than the + /// configured base duration. Break durations are never escalated. pub fn current_duration_secs(&self, settings: &Settings) -> u32 { match self.current_round { - RoundType::Work => settings.time_work_secs, + RoundType::Work => self.work_duration_secs(settings), RoundType::ShortBreak => settings.time_short_break_secs, RoundType::LongBreak => settings.time_long_break_secs, } } + /// The work duration that applies to the current ladder position. + pub fn work_duration_secs(&self, settings: &Settings) -> u32 { + let base = settings.time_work_secs; + if !settings.incremental_work_enabled || settings.time_work_increment_secs == 0 { + return base; + } + let step = settings + .time_work_increment_secs + .saturating_mul(self.work_rounds_completed); + base.saturating_add(step).min(settings.time_work_max_secs.max(base)) + } + + /// How many increments have been applied to the work duration right now. + /// Used by the frontend to show the current step on the ladder. + pub fn work_increment_steps(&self, settings: &Settings) -> u32 { + if !settings.incremental_work_enabled || settings.time_work_increment_secs == 0 { + return 0; + } + self.work_rounds_completed + } + + /// True when the current work duration has reached the configured ceiling + /// and further rounds will no longer grow. + pub fn work_duration_at_cap(&self, settings: &Settings) -> bool { + if !settings.incremental_work_enabled || settings.time_work_increment_secs == 0 { + return false; + } + let cap = settings.time_work_max_secs.max(settings.time_work_secs); + let uncapped = settings + .time_work_secs + .saturating_add(settings.time_work_increment_secs.saturating_mul(self.work_rounds_completed)); + uncapped >= cap + } + /// Advance to the next round. Returns `(next_round_type, duration_secs)`. /// /// Call this when the engine fires `TimerEvent::Complete`. pub fn advance(&mut self, settings: &Settings) -> (RoundType, u32) { self.previous_round = Some(self.current_round); + let left_work_round = self.current_round == RoundType::Work; self.current_round = match self.current_round { RoundType::Work => { if self.work_round_number >= self.work_rounds_total { @@ -110,6 +170,20 @@ impl SequenceState { } }; + // A completed work round extends the ladder for the rounds that follow. + // Counted before the duration is computed so the very next work round + // already reflects the increment. + if left_work_round { + self.work_rounds_completed = self.work_rounds_completed.saturating_add(1); + } + + // A long break starting is the cycle boundary. By default it restarts + // the ladder, so every cycle begins at the base duration. When the user + // turns that off the ladder carries across cycles and keeps climbing. + if self.current_round == RoundType::LongBreak && settings.incremental_reset_on_long_break { + self.work_rounds_completed = 0; + } + // Increment the session counter every time we enter a new Work round. if self.current_round == RoundType::Work { self.session_work_count += 1; @@ -119,12 +193,48 @@ impl SequenceState { (self.current_round, duration) } + /// Restart the ladder from the base duration, keeping the round and cycle + /// counters untouched. Used by the manual "Reset Focus Ladder" action and by + /// the long-break / day rollover triggers. + /// + /// `today` (when known) is recorded as the ladder's current day so a + /// rollover is measured from now on. Returns true when the step counter + /// actually changed. + pub fn reset_ladder(&mut self, today: Option<&str>) -> bool { + if let Some(day) = today { + self.ladder_day = Some(day.to_string()); + } + let changed = self.work_rounds_completed != 0; + self.work_rounds_completed = 0; + changed + } + + /// Restart the ladder when the local calendar day changes. + /// + /// The current day is always recorded, so the next rollover is measured from + /// today. When `incremental_reset_daily` is off the extra day is noted but + /// the ladder is left climbing — turning the setting back on later will + /// apply a reset on the next check. + pub fn check_day_rollover(&mut self, settings: &Settings, today: &str) -> DayRollover { + let previous = self.ladder_day.replace(today.to_string()); + match previous { + Some(day) if day != today => DayRollover { + day_changed: true, + reset: settings.incremental_reset_daily && self.reset_ladder(Some(today)), + }, + // First observation of a day, or still the same day: nothing to do. + _ => DayRollover::default(), + } + } + /// Reset the sequence to the initial state (used by the Reset command). pub fn reset(&mut self) { self.current_round = RoundType::Work; self.previous_round = None; self.work_round_number = 1; self.session_work_count = 1; + self.work_rounds_completed = 0; + self.ladder_day = None; } } @@ -446,4 +556,340 @@ mod tests { assert_eq!(rt, RoundType::Work); assert_eq!(seq.work_round_number, 1, "counter resets to 1"); } + + // ----------------------------------------------------------------------- + // Incremental focus mode + // ----------------------------------------------------------------------- + + /// Settings with incremental focus enabled: 5 min base, +5 min per round, 20 min cap. + fn incremental_settings() -> Settings { + Settings { + time_work_secs: 5 * 60, + time_short_break_secs: 5 * 60, + time_long_break_secs: 15 * 60, + long_break_interval: 4, + incremental_work_enabled: true, + time_work_increment_secs: 5 * 60, + time_work_max_secs: 20 * 60, + ..Settings::default() + } + } + + #[test] + fn incremental_disabled_keeps_flat_duration() { + let s = Settings { + time_work_secs: 5 * 60, + time_short_break_secs: 5 * 60, + time_long_break_secs: 15 * 60, + long_break_interval: 2, + incremental_work_enabled: false, + time_work_increment_secs: 5 * 60, + time_work_max_secs: 20 * 60, + ..Settings::default() + }; + let mut seq = SequenceState::new(2); + + assert_eq!(seq.current_duration_secs(&s), 5 * 60); + for _ in 0..6 { + let (rt, dur) = seq.advance(&s); + if rt == RoundType::Work { + assert_eq!(dur, 5 * 60, "work duration must stay flat when the feature is off"); + } + } + } + + #[test] + fn incremental_escalates_work_rounds_and_caps() { + let s = incremental_settings(); + let mut seq = SequenceState::new(6); + + // Round 1 starts at the base duration. + assert_eq!(seq.current_duration_secs(&s), 300, "first round is the base duration"); + + // Each Work round is 5 min longer than the previous, until the 20 min cap. + let expected_work = [300u32, 600, 900, 1200, 1200, 1200]; + for (i, want) in expected_work.iter().enumerate() { + if i > 0 { + // Advance Work → ShortBreak → Work. + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::ShortBreak); + assert_eq!(dur, 300, "break durations are never escalated"); + seq.advance(&s); + } + assert_eq!( + seq.current_round, + RoundType::Work, + "step {i}: expected to be on a work round" + ); + assert_eq!( + seq.current_duration_secs(&s), + *want, + "step {i}: unexpected work duration" + ); + } + } + + #[test] + fn incremental_ladder_resets_after_long_break() { + let s = incremental_settings(); + let mut seq = SequenceState::new(2); + + assert_eq!(seq.current_duration_secs(&s), 300); + + seq.advance(&s); // → ShortBreak + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_eq!(dur, 600, "second work round gains one increment"); + + let (rt, _dur) = seq.advance(&s); + assert_eq!(rt, RoundType::LongBreak, "long break at the cycle boundary"); + assert_eq!( + seq.work_rounds_completed, 0, + "the ladder restarts as soon as the long break begins" + ); + + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_eq!(dur, 300, "a new cycle restarts at the base duration"); + assert_eq!(seq.work_rounds_completed, 0, "ladder stays at the base"); + } + + #[test] + fn incremental_ladder_survives_long_breaks_when_reset_is_off() { + let s = Settings { + incremental_reset_on_long_break: false, + ..incremental_settings() + }; + let mut seq = SequenceState::new(2); + + seq.advance(&s); // Work(1) → ShortBreak + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_eq!(dur, 600); + + let (rt, _) = seq.advance(&s); + assert_eq!(rt, RoundType::LongBreak); + assert_eq!( + seq.work_rounds_completed, 2, + "the ladder keeps its steps across the long break" + ); + + // Leaving the long break continues the ladder instead of restarting it. + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_eq!(dur, 900, "the next cycle continues climbing"); + assert_eq!(seq.work_rounds_completed, 2); + } + + #[test] + fn incremental_ladder_climbs_across_cycles_until_the_cap() { + let s = Settings { + incremental_reset_on_long_break: false, + ..incremental_settings() + }; + let mut seq = SequenceState::new(1); // every work round ends in a long break + + // 5 → 10 → 15 → 20 (cap) → 20 → 20 … + let expected = [300u32, 600, 900, 1200, 1200, 1200]; + for (i, want) in expected.iter().enumerate() { + if i > 0 { + seq.advance(&s); // → LongBreak + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_ne!(dur, 0); + } + assert_eq!( + seq.current_duration_secs(&s), + *want, + "step {i}: the ladder must keep climbing across cycles" + ); + } + assert!(seq.work_duration_at_cap(&s)); + assert_eq!(seq.work_increment_steps(&s), 5, "capped rounds still count steps"); + } + + #[test] + fn incremental_ladder_resets_on_manual_reset() { + let s = incremental_settings(); + let mut seq = SequenceState::new(8); + + seq.advance(&s); // → ShortBreak + seq.advance(&s); // → Work (600s) + assert_eq!(seq.current_duration_secs(&s), 600); + + seq.reset(); + assert_eq!(seq.work_rounds_completed, 0); + assert_eq!(seq.current_duration_secs(&s), 300, "reset returns to the base duration"); + } + + #[test] + fn reset_ladder_keeps_the_round_counters() { + let s = incremental_settings(); + let mut seq = SequenceState::new(8); + + seq.advance(&s); // → ShortBreak + seq.advance(&s); // → Work(2), ladder at 600s + assert_eq!(seq.work_round_number, 2); + assert_eq!(seq.session_work_count, 2); + assert_eq!(seq.work_rounds_completed, 1); + + assert!(seq.reset_ladder(Some("2026-05-01")), "the ladder had steps to clear"); + + assert_eq!(seq.work_rounds_completed, 0, "ladder restarts at the base"); + assert_eq!(seq.current_duration_secs(&s), 300); + assert_eq!(seq.work_round_number, 2, "round counter is untouched"); + assert_eq!(seq.session_work_count, 2, "session counter is untouched"); + assert_eq!(seq.current_round, RoundType::Work, "round type is untouched"); + assert_eq!(seq.ladder_day.as_deref(), Some("2026-05-01")); + + assert!(!seq.reset_ladder(Some("2026-05-01")), "nothing left to reset"); + } + + #[test] + fn day_rollover_resets_the_ladder_when_enabled() { + let s = incremental_settings(); + let mut seq = SequenceState::new(8); + + seq.advance(&s); // → ShortBreak + seq.advance(&s); // → Work(2), ladder at 600s + assert_eq!(seq.work_rounds_completed, 1); + + // Same day: no change. + let first = seq.check_day_rollover(&s, "2026-05-01"); + assert_eq!(first, DayRollover { day_changed: false, reset: false }); + assert_eq!(seq.work_rounds_completed, 1); + + // A new day restarts the ladder. + let rollover = seq.check_day_rollover(&s, "2026-05-02"); + assert_eq!(rollover, DayRollover { day_changed: true, reset: true }); + assert_eq!(seq.work_rounds_completed, 0, "a new day starts at the base duration"); + assert_eq!(seq.current_duration_secs(&s), 300); + assert_eq!(seq.ladder_day.as_deref(), Some("2026-05-02")); + + // And the new day is remembered. + let again = seq.check_day_rollover(&s, "2026-05-02"); + assert_eq!(again, DayRollover { day_changed: false, reset: false }); + } + + #[test] + fn day_rollover_keeps_the_ladder_when_daily_reset_is_off() { + let s = Settings { + incremental_reset_daily: false, + ..incremental_settings() + }; + let mut seq = SequenceState::new(8); + // The ladder is tagged with today on its first check. + let _ = seq.check_day_rollover(&s, "2026-05-01"); + + seq.advance(&s); // → ShortBreak + seq.advance(&s); // → Work(2), ladder at 600s + + let rollover = seq.check_day_rollover(&s, "2026-05-02"); + assert_eq!( + rollover, + DayRollover { day_changed: true, reset: false }, + "the day is recorded but the ladder is left alone" + ); + assert_eq!(seq.work_rounds_completed, 1, "the ladder keeps climbing"); + assert_eq!(seq.current_duration_secs(&s), 600); + assert_eq!(seq.ladder_day.as_deref(), Some("2026-05-02")); + + // Turning the setting back on applies the missed reset on the next day. + let s2 = incremental_settings(); + let rollover = seq.check_day_rollover(&s2, "2026-05-03"); + assert_eq!(rollover, DayRollover { day_changed: true, reset: true }); + assert_eq!(seq.work_rounds_completed, 0); + } + + #[test] + fn day_rollover_ignores_the_ladder_when_it_is_already_at_zero() { let s = incremental_settings(); + let mut seq = SequenceState::new(8); + let _ = seq.check_day_rollover(&s, "2026-05-01"); + + let rollover = seq.check_day_rollover(&s, "2026-05-02"); + assert_eq!( + rollover, + DayRollover { day_changed: true, reset: false }, + "an untouched ladder has nothing to restart" + ); + assert_eq!(seq.ladder_day.as_deref(), Some("2026-05-02")); + } + + #[test] + fn incremental_never_shrinks_below_base_when_cap_is_smaller() { + let s = Settings { + time_work_secs: 25 * 60, + incremental_work_enabled: true, + time_work_increment_secs: 5 * 60, + time_work_max_secs: 10 * 60, // misconfigured: cap below the base + long_break_interval: 4, + ..Settings::default() + }; + let seq = SequenceState::new(4); + assert_eq!( + seq.current_duration_secs(&s), + 25 * 60, + "cap below the base duration must not shorten the work round" + ); + } + + #[test] + fn incremental_steps_and_cap_flags_track_the_ladder() { + let s = incremental_settings(); + let mut seq = SequenceState::new(5); + + assert_eq!(seq.work_increment_steps(&s), 0); + assert!(!seq.work_duration_at_cap(&s)); + + // Work(1)→SB→Work(2)→SB→Work(3)→SB→Work(4)→SB→Work(5)→Long → Work(1) + // — a full cycle, which restarts the ladder. + for _ in 0..10 { + seq.advance(&s); + } + assert_eq!(seq.current_round, RoundType::Work); + assert_eq!(seq.work_round_number, 1, "back at the start of a new cycle"); + assert_eq!(seq.work_increment_steps(&s), 0, "ladder restarts each cycle"); + assert!(!seq.work_duration_at_cap(&s)); + assert_eq!(seq.current_duration_secs(&s), 300); + + // Walk part-way into the next cycle and confirm the cap has engaged. + // Three work→break pairs from Work(1) land on Work(4). + for _ in 0..3 { + seq.advance(&s); // Work(n) → ShortBreak + seq.advance(&s); // ShortBreak → Work(n+1) + } + assert_eq!(seq.current_round, RoundType::Work); + assert_eq!(seq.work_round_number, 4, "three work rounds completed this cycle"); + assert_eq!(seq.work_increment_steps(&s), 3); + assert!(seq.work_duration_at_cap(&s), "300 + 300×3 reaches the 1200 s cap"); + assert_eq!(seq.work_duration_secs(&s), 1200); + } + + #[test] + fn work_round_finishing_after_midnight_does_not_extend_the_new_ladder() { + // Mirrors how the timer event listener composes these calls when a work + // round that started yesterday completes just after midnight. + let s = incremental_settings(); + let mut seq = SequenceState::new(4); + let _ = seq.check_day_rollover(&s, "2026-05-01"); + + seq.advance(&s); // Work(1) → ShortBreak + seq.advance(&s); // → Work(2), ladder at 600 s + assert_eq!(seq.work_rounds_completed, 1); + + // Midnight passes while the second work round is still running. + let rollover = seq.check_day_rollover(&s, "2026-05-02"); + assert!(rollover.reset, "the new day restarts the ladder"); + + let (rt, _) = seq.advance(&s); + assert_eq!(rt, RoundType::ShortBreak); + // The listener drops the step that the finished round had just added. + seq.reset_ladder(Some("2026-05-02")); + + // So the first work round of the new day runs at the base duration. + let (rt, dur) = seq.advance(&s); + assert_eq!(rt, RoundType::Work); + assert_eq!(dur, 300, "the new day starts at the base duration"); + assert_eq!(seq.work_increment_steps(&s), 0); + } } diff --git a/src-tauri/src/websocket/mod.rs b/src-tauri/src/websocket/mod.rs index 5d46c348..190a0b60 100644 --- a/src-tauri/src/websocket/mod.rs +++ b/src-tauri/src/websocket/mod.rs @@ -270,6 +270,12 @@ mod tests { work_round_number: 1, work_rounds_total: 4, session_work_count: 1, + incremental_work_enabled: true, + base_work_secs: 1500, + work_increment_secs: 300, + work_max_secs: 3600, + increment_steps: 2, + at_increment_cap: false, } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 457f03bf..6a389096 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Pomotroid", - "version": "1.7.1", + "version": "1.7.2", "identifier": "com.splode.pomotroid", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/lib/components/Timer.svelte b/src/lib/components/Timer.svelte index c82dd9b9..e8db55d0 100644 --- a/src/lib/components/Timer.svelte +++ b/src/lib/components/Timer.svelte @@ -46,6 +46,16 @@ return m.round_label_long_break(); } + /** Compact "base +N" caption shown while the incremental ladder is climbing. */ + let incrementCaption = $derived.by(() => { + if (!state.incremental_work_enabled || state.round_type !== 'work') return null; + const baseMins = Math.round(state.base_work_secs / 60); + if (state.increment_steps <= 0) return `${baseMins}m · ${m.timer_increment_step_1()}`; + const gainedMins = Math.round((state.increment_steps * state.work_increment_secs) / 60); + const suffix = state.at_increment_cap ? ` · ${m.timer_increment_cap_short()}` : ''; + return `${baseMins}m +${gainedMins}m · ${m.timer_increment_step({ n: state.increment_steps + 1 })}${suffix}`; + }); + onMount(() => { const cleanups: UnlistenFn[] = []; @@ -128,6 +138,14 @@ {roundLabel(state.round_type)} + {#if incrementCaption} + +
+ {incrementCaption} +
+
+ {/if} +
@@ -272,4 +290,15 @@ /* Collapse the gap above: the flex gap already provides spacing from the dial. */ margin-top: -8px; } + + /* Incremental ladder caption — sits under the round label, deliberately + quieter than it. Only rendered while a work round is in progress. */ + .increment-caption { + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; + opacity: 0.72; + margin-top: -4px; + } diff --git a/src/lib/components/Titlebar.svelte b/src/lib/components/Titlebar.svelte index 0cb6ebbe..acb0d147 100644 --- a/src/lib/components/Titlebar.svelte +++ b/src/lib/components/Titlebar.svelte @@ -87,6 +87,29 @@ }); } + async function openBetterStats() { + const existing = await WebviewWindow.getByLabel('better-stats'); + if (existing) { + await existing.show(); + await existing.setFocus(); + return; + } + new WebviewWindow('better-stats', { + url: '/better-stats', + title: 'Pomotroid — Better Stats', + width: 1000, + height: 720, + minWidth: 660, + minHeight: 460, + decorations: isMac, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + titleBarStyle: isMac ? ('Overlay' as any) : undefined, + hiddenTitle: isMac ? true : undefined, + resizable: true, + visible: false, + }); + } + async function minimize() { suppressRestoredTitlebarState(); if ($settings.min_to_tray) { @@ -178,17 +201,51 @@ {/snippet} +{#snippet betterStatsBtn()} + + + +{/snippet} +