From f6cca05a0a2abd3b68769e1b092431e8cf3b2bf0 Mon Sep 17 00:00:00 2001 From: gluansinha-star Date: Fri, 25 Sep 2026 20:04:11 +0530 Subject: [PATCH 1/8] feat: incremental focus ladder and a deeper Better Stats window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two opt-in features on top of upstream Pomotroid, plus a fix for a date-arithmetic bug that made several statistics render as zeros. Incremental Focus - New settings: incremental_work_enabled, time_work_increment_secs, time_work_max_secs (MIGRATION_7 seeds them, so existing DBs upgrade cleanly). - SequenceState owns the ladder: a work round is lengthened by the increment for every work round already completed in the cycle, capped at the ceiling and never shorter than the configured base duration. The first round uses the base duration as-is, breaks are never escalated, and the ladder resets after a long break and on manual Reset. - TimerSnapshot carries the ladder state to the frontend so the UI can show the current step, and Settings -> Timer gains controls plus a live ladder preview. Better Stats - New "better-stats" window, opened from the titlebar, alongside the original Statistics window which is untouched. - Backend: stats_get_insights returns everything in one IPC round-trip — today's activity, trailing 7/28/90-day windows, week-over-week comparisons, a 28-day trend with a 7-day moving average, a 12-week rollup, a 120-day heatmap, full daily history, an all-time weekday x hour grid, a session-length histogram, 30-day consistency metrics, streaks, and personal bests. - Frontend: hand-rolled SVG charts (area/line, ring, donut, bars, heatmap, weekday x hour grid, ranked bars) in src/lib/components/better-stats. - Heatmap has Recent / Month / Year scopes; the month view renders a real calendar with day numbers and navigation. - Durations read naturally: 45m, then 1h 30m, and whole hours past ten (12h). Fixes - day_num_to_date was shifted by one day and weekday_index was misaligned, so the 28-day trend and weekly rollup produced dates that matched no session and rendered as zeros. Both are corrected and pinned by tests, along with the exact day numbers for known dates. Tooling - scripts/seed-demo-data.mjs generates realistic history for reviewing the stats windows; scripts/sync-messages.mjs keeps locale files in sync with en.json. - src/lib/dev/mockTauri.js lets the UI run under plain `npm run dev` in a browser by standing in for the Rust backend. Guarded by import.meta.env.DEV, so it never ships in a production build. README documents the fork, both features, and the development helpers. --- README.md | 153 +- scripts/seed-demo-data.mjs | 339 +++++ scripts/sync-messages.mjs | 61 + src-tauri/capabilities/default.json | 2 +- src-tauri/src/commands.rs | 14 + src-tauri/src/db/migrations.rs | 18 +- src-tauri/src/db/queries.rs | 1306 +++++++++++++++++ src-tauri/src/lib.rs | 3 +- src-tauri/src/settings/defaults.rs | 3 + src-tauri/src/settings/mod.rs | 20 + src-tauri/src/timer/mod.rs | 24 + src-tauri/src/timer/sequence.rs | 217 ++- src-tauri/src/websocket/mod.rs | 6 + src/lib/components/Timer.svelte | 29 + src/lib/components/Titlebar.svelte | 57 + .../better-stats/ConsistencySection.svelte | 183 +++ .../better-stats/HeroSection.svelte | 379 +++++ .../components/better-stats/MetaTile.svelte | 72 + .../better-stats/MomentumCharts.svelte | 205 +++ .../better-stats/RecordsSection.svelte | 69 + .../better-stats/RhythmSection.svelte | 105 ++ .../better-stats/SectionBlock.svelte | 55 + .../better-stats/charts/Donut.svelte | 183 +++ .../better-stats/charts/FocusHeatmap.svelte | 738 ++++++++++ .../better-stats/charts/HBarList.svelte | 113 ++ .../better-stats/charts/MiniBars.svelte | 184 +++ .../better-stats/charts/RhythmGrid.svelte | 244 +++ .../better-stats/charts/Ring.svelte | 112 ++ .../better-stats/charts/TrendChart.svelte | 300 ++++ .../settings/sections/TimerSection.svelte | 204 +++ src/lib/dev/mockTauri.js | 794 ++++++++++ src/lib/ipc/index.ts | 4 + src/lib/stores/settings.ts | 3 + src/lib/stores/timer.ts | 6 + src/lib/types.ts | 151 ++ src/lib/utils/statsFormat.ts | 113 ++ src/messages/de.json | 107 +- src/messages/en.json | 107 +- src/messages/es.json | 107 +- src/messages/fr.json | 107 +- src/messages/ja.json | 107 +- src/messages/pt.json | 107 +- src/messages/tr.json | 107 +- src/messages/zh.json | 107 +- src/routes/+layout.svelte | 4 + src/routes/better-stats/+page.svelte | 303 ++++ 46 files changed, 7616 insertions(+), 16 deletions(-) create mode 100644 scripts/seed-demo-data.mjs create mode 100644 scripts/sync-messages.mjs create mode 100644 src/lib/components/better-stats/ConsistencySection.svelte create mode 100644 src/lib/components/better-stats/HeroSection.svelte create mode 100644 src/lib/components/better-stats/MetaTile.svelte create mode 100644 src/lib/components/better-stats/MomentumCharts.svelte create mode 100644 src/lib/components/better-stats/RecordsSection.svelte create mode 100644 src/lib/components/better-stats/RhythmSection.svelte create mode 100644 src/lib/components/better-stats/SectionBlock.svelte create mode 100644 src/lib/components/better-stats/charts/Donut.svelte create mode 100644 src/lib/components/better-stats/charts/FocusHeatmap.svelte create mode 100644 src/lib/components/better-stats/charts/HBarList.svelte create mode 100644 src/lib/components/better-stats/charts/MiniBars.svelte create mode 100644 src/lib/components/better-stats/charts/RhythmGrid.svelte create mode 100644 src/lib/components/better-stats/charts/Ring.svelte create mode 100644 src/lib/components/better-stats/charts/TrendChart.svelte create mode 100644 src/lib/dev/mockTauri.js create mode 100644 src/lib/utils/statsFormat.ts create mode 100644 src/routes/better-stats/+page.svelte diff --git a/README.md b/README.md index d1506534..608ed22e 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,105 @@ 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 | + +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: + +**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. + +**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. + +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 +163,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 +177,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 +254,36 @@ npm run paraglide:compile This is also run automatically as part of `npm run check`. +### Development helpers + +Two 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 +``` + +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/scripts/seed-demo-data.mjs b/scripts/seed-demo-data.mjs new file mode 100644 index 00000000..4696fa51 --- /dev/null +++ b/scripts/seed-demo-data.mjs @@ -0,0 +1,339 @@ +/** + * Seed the app's SQLite database with realistic session history so the stats + * windows have something to show during review. + * + * This is a DEVELOPMENT helper — it writes directly to the app's data directory + * and CLEARS existing session rows. + * + * Usage: + * node scripts/seed-demo-data.mjs # ~5 months of history + * node scripts/seed-demo-data.mjs --clear # wipe sessions only + * + * The app must not be running when this executes. + */ +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const CLEAR_ONLY = process.argv.includes('--clear'); + +const appDataDir = process.env.APPDATA + ? join(process.env.APPDATA, 'com.splode.pomotroid') + : join(process.env.HOME ?? '.', '.local', 'share', 'com.splode.pomotroid'); + +mkdirSync(appDataDir, { recursive: true }); +const dbPath = join(appDataDir, 'pomotroid.db'); + +console.log(`Database: ${dbPath}`); +if (!existsSync(dbPath)) { + console.log('(file does not exist yet — it will be created)'); +} + +const db = new DatabaseSync(dbPath); + +// --- Schema: mirrors src-tauri/src/db/migrations.rs ------------------------- +db.exec(` + CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + ended_at INTEGER, + round_type TEXT NOT NULL CHECK(round_type IN ('work', 'short-break', 'long-break')), + duration_secs INTEGER NOT NULL CHECK(duration_secs > 0), + completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS custom_themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + colors TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at); + CREATE INDEX IF NOT EXISTS idx_sessions_round_type ON sessions(round_type); +`); + +const version = db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version').get().v; +if (version === 0) { + db.exec('INSERT INTO schema_version VALUES (1)'); +} +// Keep the recorded version current so migrations do not re-run. +const maxVersion = 7; +if (version < maxVersion) { + db.exec(`DELETE FROM schema_version`); + db.exec(`INSERT INTO schema_version VALUES (${maxVersion})`); +} + +const deleted = db.prepare('DELETE FROM sessions').run(); +console.log(`Cleared ${deleted.changes} existing session row(s).`); + +if (CLEAR_ONLY) { + db.close(); + console.log('Done (clear only).'); + process.exit(0); +} + +// --- Deterministic pseudo-random ------------------------------------------- +function hash01(str) { + let h = 2166136261; + for (let i = 0; i < str.length; i += 1) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 10000) / 10000; +} + +// --- Generate sessions ------------------------------------------------------ + +/** Local midnight for a Date. */ +function startOfLocalDay(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); +} + +const now = new Date(); +const todayStart = startOfLocalDay(now); + +// Focus-hour targets are drawn from this weighted pool, biased towards the +// higher end: a typical day lands around 6–9 focused hours. +const HOUR_POOL = [ + 2, 2.5, 3, 3.5, 4, 4, 4.5, 5, 5, 5.5, 6, 6, 6.5, 6.5, 7, 7, 7.5, 7.5, 8, 8, 8.5, 9, 9, 9.5, 10, +]; + +/** Earliest start hour for a day, varying by weekday (0 = Monday). */ +const WEEKDAY_START_HOUR = [7, 7, 8, 8, 9, 9, 10]; + +/** + * Describe one day's activity. + * + * A day is either a rest day (0 focus) or a working day whose total focus time + * is drawn from `HOUR_POOL` (so 2–10 h, biased high). Recently recorded days + * skew a little stronger, which makes the momentum and trend views interesting. + * + * Returns `null` for a rest day. + */ +function planDay(dayOffset) { + const date = new Date(todayStart); + date.setDate(date.getDate() - dayOffset); + const key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + const r = hash01(key); + const weekday = (date.getDay() + 6) % 7; // 0 = Monday + + // Rest days: fewer of them in the last couple of weeks. + const restChance = dayOffset < 14 ? 0.08 : dayOffset < 45 ? 0.14 : 0.2; + if (r < restChance) return null; + + // Pick a focus-hour target, then nudge recent days upward. + let hours = HOUR_POOL[Math.floor(hash01(`h${key}`) * HOUR_POOL.length)]; + if (dayOffset < 21) hours = Math.min(10, hours + 1); + if (dayOffset < 7) hours = Math.min(10, hours + 1); + + // Earlier days rarely reach the very top of the range. + if (dayOffset > 90) hours = Math.min(hours, 8); + + return { + hours, + startHour: WEEKDAY_START_HOUR[weekday], + strength: hours >= 7 ? 'strong' : hours <= 3.5 ? 'light' : 'normal', + }; +} + +/** Weighted pick of a round length (minutes) for a mixed-length day. */ +const LENGTH_POOL = [5, 5, 10, 10, 15, 15, 20, 20, 25, 25, 30, 30, 30, 45]; +function roundLengthMins(offset, index) { + return LENGTH_POOL[Math.floor(hash01(`L${offset}-${index}`) * LENGTH_POOL.length)]; +} + +const insert = db.prepare( + `INSERT INTO sessions (started_at, ended_at, round_type, duration_secs, completed) + VALUES (?, ?, ?, ?, ?)` +); + +const unixSecs = (d) => Math.floor(d.getTime() / 1000); + +let inserted = 0; +let skippedCount = 0; +let totalFocusSecs = 0; + +const DAYS = 200; +// Insert oldest → newest so ids follow chronological order. +for (let offset = DAYS; offset >= 0; offset -= 1) { + const plan = planDay(offset); + if (plan === null) continue; + + const day = new Date(todayStart); + day.setDate(day.getDate() - offset); + + // Target focus time for the day, in whole minutes. + const targetMins = Math.round(plan.hours * 60); + + // Two or three focus blocks per day, separated by real gaps. + const blockCount = 2 + Math.floor(hash01(`b${offset}`) * 2); // 2–3 blocks + // Gaps between blocks, in minutes. + const gaps = Array.from({ length: blockCount - 1 }, () => + 25 + Math.floor(hash01(`gap${offset}${Math.random()}`) * 55) + ); + + // Split the target across blocks (roughly even, with some variation). + const weights = Array.from({ length: blockCount }, (_, i) => 0.75 + hash01(`w${offset}${i}`) * 0.5); + const weightSum = weights.reduce((s, w) => s + w, 0); + + let cursorHour = plan.startHour; + let cursorMin = Math.floor(hash01(`m${offset}`) * 35); + let roundIndex = 0; + let wroteMins = 0; + + for (let block = 0; block < blockCount; block += 1) { + const blockTarget = Math.round((targetMins * weights[block]) / weightSum); + let blockMins = 0; + let blockRounds = 0; + + // Fill the block with rounds until its share of the target is reached. + while (blockMins < blockTarget && blockRounds < 14) { + const lengthMins = roundLengthMins(offset, roundIndex); + const workStart = new Date(day); + workStart.setHours(cursorHour, cursorMin, 0, 0); + // Never place a session in the future. + if (workStart.getTime() > now.getTime()) break; + + // ~1 in 10 rounds is abandoned part-way (started, not completed). + const abandoned = hash01(`a${offset}-${roundIndex}`) < 0.1; + // An abandoned round is cut short, so it contributes less real focus. + const actualMins = abandoned ? Math.max(1, Math.round(lengthMins * 0.4)) : lengthMins; + const workEnd = new Date(workStart.getTime() + actualMins * 60 * 1000); + + insert.run( + unixSecs(workStart), + unixSecs(workEnd), + 'work', + lengthMins * 60, + abandoned ? 0 : 1 + ); + inserted += 1; + if (abandoned) { + skippedCount += 1; + } else { + totalFocusSecs += lengthMins * 60; + } + wroteMins += actualMins; + + // Log the matching break so the timeline stays realistic. Long breaks are + // earned every fourth completed round, mirroring the app's cycle. + const isLongBreak = !abandoned && (blockRounds + 1) % 4 === 0; + const breakMins = isLongBreak ? 15 : 5; + const breakStart = new Date(workEnd.getTime() + 60 * 1000); + insert.run( + unixSecs(breakStart), + unixSecs(new Date(breakStart.getTime() + breakMins * 60 * 1000)), + isLongBreak ? 'long-break' : 'short-break', + breakMins * 60, + 1 + ); + + // Advance the clock: the round, its break, plus a little slack. + cursorMin += actualMins + breakMins + Math.floor(hash01(`s${offset}${roundIndex}`) * 4); + while (cursorMin >= 60) { + cursorMin -= 60; + cursorHour += 1; + } + + blockMins += actualMins; + blockRounds += 1; + roundIndex += 1; + } + + // Bail out of the day if we have run into the evening. + if (cursorHour >= 22) break; + + // Insert the gap before the next block, if there is one. + if (block < blockCount - 1) { + cursorMin += gaps[block] ?? 40; + while (cursorMin >= 60) { + cursorMin -= 60; + cursorHour += 1; + } + if (cursorHour >= 22) break; + } + } +} + +// Guarantee a live "today" so the Today panels and streak are populated. +// The main loop can write little or nothing for today when the generated +// schedule runs past the current clock time, so top it up here. +{ + const todayMins = db + .prepare( + `SELECT COALESCE(SUM(duration_secs), 0) / 60 AS mins FROM sessions + WHERE round_type = 'work' AND completed = 1 + AND date(started_at, 'unixepoch', 'localtime') = date('now', 'localtime')` + ) + .get().mins; + + // Target at least ~2.5 focused hours today (never more than the clock allows). + const targetMins = 150; + if (todayMins < targetMins) { + let cursor = new Date(todayStart); + cursor.setHours(8, 30, 0, 0); + const lengths = [25, 25, 30, 20, 30, 25, 15, 30]; + let i = 0; + + while (cursor.getTime() + 25 * 60 * 1000 < now.getTime() && i < lengths.length) { + const mins = lengths[i]; + const end = new Date(cursor.getTime() + mins * 60 * 1000); + if (end.getTime() > now.getTime()) break; + insert.run(unixSecs(cursor), unixSecs(end), 'work', mins * 60, 1); + inserted += 1; + totalFocusSecs += mins * 60; + // Work round + a 5 minute break before the next one. + cursor = new Date(end.getTime() + 5 * 60 * 1000); + i += 1; + } + } +} + +// Also make sure yesterday has sessions, so the streak reads as alive. +{ + const existingYesterday = db + .prepare( + `SELECT COUNT(*) AS n FROM sessions + WHERE round_type = 'work' + AND date(started_at, 'unixepoch', 'localtime') = date('now', 'localtime', '-1 day')` + ) + .get().n; + + if (existingYesterday === 0) { + const day = new Date(todayStart); + day.setDate(day.getDate() - 1); + for (let i = 0; i < 6; i += 1) { + const start = new Date(day); + start.setHours(9 + i, 10 + (i % 3) * 7, 0, 0); + const dur = (20 + i * 5) * 60; + insert.run(unixSecs(start), unixSecs(new Date(start.getTime() + dur * 1000)), 'work', dur, 1); + inserted += 1; + totalFocusSecs += dur; + } + } +} + +// --- Report ----------------------------------------------------------------- +const summary = db + .prepare( + `SELECT COUNT(*) AS total, + SUM(completed) AS completed, + COUNT(DISTINCT date(started_at, 'unixepoch', 'localtime')) AS days, + MIN(date(started_at, 'unixepoch', 'localtime')) AS first, + MAX(date(started_at, 'unixepoch', 'localtime')) AS last + FROM sessions WHERE round_type = 'work'` + ) + .get(); + +console.log(''); +console.log(`Inserted : ${inserted} session row(s) (${skippedCount} abandoned)`); +console.log(`Focus time : ${Math.round(totalFocusSecs / 3600)}h ${Math.round((totalFocusSecs % 3600) / 60)}m`); +console.log(`Work rounds : ${summary.completed} completed / ${summary.total} started`); +console.log(`Active days : ${summary.days}`); +console.log(`Date range : ${summary.first} → ${summary.last}`); + +db.close(); +console.log('\nDone. Launch the app to review the stats.'); 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/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..55f7a7f8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -378,6 +378,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..a58864bf 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -90,6 +90,16 @@ 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); +"; + /// 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 +141,12 @@ 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"); + } + Ok(()) } @@ -166,7 +182,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, 7); } #[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..f56c845a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,7 +27,7 @@ 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, window_set_visibility, @@ -384,6 +384,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..f32a9e49 100644 --- a/src-tauri/src/settings/defaults.rs +++ b/src-tauri/src/settings/defaults.rs @@ -23,6 +23,9 @@ 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"), ("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..725d4784 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -37,6 +37,12 @@ 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, /// Audio volume in the 0.0–1.0 range. pub volume: f32, pub shortcut_toggle: String, @@ -90,6 +96,9 @@ 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, volume: 1.0, #[cfg(target_os = "macos")] shortcut_toggle: "Super+Shift+1".to_string(), @@ -213,6 +222,17 @@ 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), // 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..4097fc2e 100644 --- a/src-tauri/src/timer/mod.rs +++ b/src-tauri/src/timer/mod.rs @@ -38,6 +38,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, } // --------------------------------------------------------------------------- @@ -193,6 +205,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), } } @@ -515,5 +533,11 @@ 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), } } diff --git a/src-tauri/src/timer/sequence.rs b/src-tauri/src/timer/sequence.rs index a90d6d3e..4f1b3c14 100644 --- a/src-tauri/src/timer/sequence.rs +++ b/src-tauri/src/timer/sequence.rs @@ -49,6 +49,10 @@ 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 and resets at every long-break boundary (and on + /// a full Reset), so each new cycle starts at the base duration again. + pub work_rounds_completed: u32, } impl SequenceState { @@ -59,23 +63,64 @@ impl SequenceState { work_round_number: 1, work_rounds_total, session_work_count: 1, + work_rounds_completed: 0, } } /// 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 +155,19 @@ 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); + } + + // Leaving a long break (or wrapping the cycle when breaks are disabled) + // starts a fresh ladder from the base duration. + if self.current_round == RoundType::Work && self.work_round_number == 1 { + 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; @@ -125,6 +183,7 @@ impl SequenceState { self.previous_round = None; self.work_round_number = 1; self.session_work_count = 1; + self.work_rounds_completed = 0; } } @@ -446,4 +505,160 @@ 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, 2); + + 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 resets at the cycle boundary"); + } + + #[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 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. + for _ in 0..3 { + seq.advance(&s); // Work(2) → SB → Work(3) → SB → Work(4) + seq.advance(&s); + } + assert_eq!(seq.current_round, RoundType::ShortBreak); + assert_eq!(seq.work_increment_steps(&s), 3); + assert!(seq.work_duration_at_cap(&s), "900 + 300×3 is past the 1200 s cap"); + assert_eq!(seq.work_duration_secs(&s), 1200); + } } 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/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} +