Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/images/better-stats-consistency.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/better-stats-momentum.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/better-stats-rhythm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/incremental-focus-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ thumbs.db
*.cert
.svelte-kit/
build/
.tmp/
174 changes: 170 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 |

<div align="center">
<img alt="Pomotroid Timer settings showing the Incremental Focus toggle, Add per Round and Maximum Focus sliders, and the live focus ladder preview" src=".github/images/incremental-focus-settings.png" width="720px">
</div>

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:

<div align="center">
<img alt="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" src=".github/images/better-stats-momentum.png" width="800px">
</div>

**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.

<div align="center">
<img alt="Better Stats — consistency: 120-day heatmap with month/year scopes, habit strength donut, focus window, and round-length mix" src=".github/images/better-stats-consistency.png" width="800px">
</div>

**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.

<div align="center">
<img alt="Better Stats — weekday by hour rhythm grid, focus by weekday and hour breakdowns, and personal bests" src=".github/images/better-stats-rhythm.png" width="800px">
</div>

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

<div align="center">
<img alt="Pomotroid statistics window" src=".github/images/pomotroid-stats.png" width="800px">
</div>

## 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
Expand All @@ -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.

<div align="center">
<img alt="Pomotroid statistics window" src=".github/images/pomotroid-stats.png" width="800px">
</div>
This fork keeps that window and adds the richer [Better Stats](#better-stats) window alongside it.

## Themes

Expand All @@ -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).

Expand Down Expand Up @@ -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
<http://localhost:1420>. 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 &copy; [Christopher Murphy](https://github.com/Splode)
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
155 changes: 155 additions & 0 deletions scripts/publish-release.mjs
Original file line number Diff line number Diff line change
@@ -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 `<upstream-version>-fork.<n>`. */
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`);
}
Loading