Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0bdf895
Add daily quests
claude Sep 29, 2026
05bd0c3
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
a2194c4
Add a landing page for GitHub Pages
claude Sep 29, 2026
1f91075
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
063c0a7
Release 1.1.0
claude Sep 29, 2026
0f2a0b0
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
b70bcd1
README: add release, license and Node badges plus website link
claude Sep 29, 2026
6caa805
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
042a14d
Add a contributing guide and issue templates
claude Sep 29, 2026
ed9c067
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
caffc40
Add Dependabot for GitHub Actions and a security policy
claude Sep 29, 2026
5a40e55
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
342831b
Show when the next daily quests arrive
claude Sep 29, 2026
a6389d7
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
ec6141b
Add the Lucky Charm card
claude Sep 29, 2026
01f94d4
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
b1f7e20
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
ba547b2
ci: bump actions/setup-node from 4 to 7
claude Sep 29, 2026
56a0d23
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
254afe7
Add a Surprise Break starter recipe
claude Sep 29, 2026
d734cee
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
e56f97c
Add the Tidy Up and Snapshot action cards
claude Sep 29, 2026
aeace02
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
d6f12e7
Add the Away From Keyboard trigger card
claude Sep 29, 2026
7e57bfa
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
e66fc63
Add the Chain Reaction card
claude Sep 29, 2026
5c1de3c
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
01d370c
Add sound effects with a mute button
claude Sep 29, 2026
916d470
Merge remote-tracking branch 'origin/main' into claude/game-automatio…
claude Sep 29, 2026
c142a12
Add a stats dialog with a 14-day runs chart
claude Sep 29, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- 📊 **Stats dialog**: runs, success rate, time saved, best streak and cards used, a 14-day runs chart (hover for details, or view as a table) and your busiest combos
- 🔊 **Sound effects**: a coin for each run, a low note for failures, chimes for achievements and quests, a fanfare on level-up; mute with the 🔊 button (remembered)
- ⛓️ **Chain Reaction** action card (level 4): run another combo by name, passing the same file along; loops like A → B → A are refused, and chains stop at 5 combos
- 💤 **Away From Keyboard** trigger card (level 3): fires when you've been away N minutes, or when you come back, with `{{idle.awayFor}}`
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Snap cards together into combos: a **WHEN** card (trigger), optional **IF** card
|---|---|
| ![FlowForge on a narrow screen](docs/screenshots/mobile.png) | ![Achievements dialog](docs/screenshots/achievements.png) |

| Stats |
|---|
| ![Stats dialog: totals, a 14-day runs chart and the busiest combos](docs/screenshots/stats.png) |

Every card has its own hand-drawn SVG illustration (`public/art.js`), so there are no image files to download and the art stays sharp at any size. Epic cards glow and legendary cards shimmer.

## Run it
Expand Down Expand Up @@ -76,6 +80,8 @@ New here? Click a **starter recipe** (Downloads Sorter, Invoice Catcher, Screens

**What did it do?** Each combo shows its **last 5 runs** (done, held back or failed) with the time and what happened.

**See your progress:** the 📊 button shows total runs, success rate, time saved, your best streak, a chart of runs over the last 14 days (hover a day for details, or open it as a table) and your busiest combos.

**Sound:** runs go *ka-ching*, achievements chime and level-ups play a fanfare, all synthesized in the browser. The 🔊 / 🔇 button mutes it, and FlowForge remembers.

**Light or dark:** the ☀️ / 🌙 button in the header switches themes, and FlowForge remembers your choice.
Expand Down
Binary file added docs/screenshots/stats.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 97 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ async function refresh() {
renderCollection();
renderCombos();
renderActivity();
if ($('#stats').open) renderStats();
renderAchievements();
if (!forgeRendered) renderForge();
}
Expand Down Expand Up @@ -677,6 +678,102 @@ function renderAchievements() {
$('#open-achievements').addEventListener('click', () => $('#achievements').showModal());
$('#close-achievements').addEventListener('click', () => $('#achievements').close());

// ---------- stats ----------
const DAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

function dayLabel(d) {
const [, month, day] = d.day.split('-').map(Number);
const monthName = new Date(2000, month - 1, 1).toLocaleString(undefined, { month: 'short' });
return `${DAY_SHORT[d.weekday]} ${day} ${monthName}`;
}

// Drawn at the chart's real width so labels stay 12px on small screens.
function runsChartHtml(days, width) {
const W = Math.max(280, width || 560);
const H = 180;
const top = 18;
const bottom = 24;
const plotH = H - top - bottom;
const max = Math.max(1, ...days.map((d) => d.runs));
const slot = W / days.length;
const barW = Math.min(28, slot - 6);
const y = (v) => top + plotH - (v / max) * plotH;
const bars = days.map((d, i) => {
const x = i * slot + (slot - barW) / 2;
const h = (d.runs / max) * plotH;
const today = i === days.length - 1;
// Rounded top only, anchored flat on the baseline.
const r = Math.min(4, h / 2, barW / 2);
const path = h > 0
? `M${x} ${top + plotH}V${y(d.runs) + r}Q${x} ${y(d.runs)} ${x + r} ${y(d.runs)}H${x + barW - r}Q${x + barW} ${y(d.runs)} ${x + barW} ${y(d.runs) + r}V${top + plotH}Z`
: '';
const tip = `${dayLabel(d)}: ${d.runs} run${d.runs === 1 ? '' : 's'}${d.failures ? `, ${d.failures} failed` : ''}${d.seconds ? `, ${formatDuration(d.seconds)} saved` : ''}`;
return `<g class="bar${today ? ' today' : ''}" data-tip="${esc(tip)}">
<rect class="hit" x="${i * slot}" y="${top}" width="${slot}" height="${plotH + bottom}"></rect>
${path ? `<path d="${path}"></path>` : ''}
${today || d.runs === max ? `<text class="value" x="${x + barW / 2}" y="${y(d.runs) - 5}">${d.runs}</text>` : ''}
<text class="tick" x="${x + barW / 2}" y="${H - 6}">${today && slot >= 44 ? 'Today' : DAY_SHORT[d.weekday][0]}</text>
</g>`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Runs per day for the last 14 days, up to ${max} a day">
<path class="grid" d="M0 ${y(max)}H${W}M0 ${top + plotH}H${W}"></path>
${bars}
</svg><div class="chart-tip" hidden></div>`;
}

function renderStats() {
const p = state.player;
const s = p.stats;
const tries = s.runs + s.failures;
const tiles = [
['⚙️', s.runs, 'successful runs'],
['✅', tries ? `${Math.round((s.runs / tries) * 100)}%` : '–', 'success rate'],
['⏳', formatDuration(s.secondsSaved), 'manual work saved'],
['🔥', s.streak.best, `best streak (now ${p.streak})`],
['🃏', `${s.cardsUsed.length}/${state.cards.length}`, 'different cards used'],
];
$('#stat-tiles').innerHTML = tiles.map(([emoji, value, label]) =>
`<div class="stat-tile"><span>${emoji}</span><b>${esc(value)}</b><small>${esc(label)}</small></div>`).join('');

$('#runs-chart').innerHTML = runsChartHtml(p.daily, $('#runs-chart').clientWidth);
$('#runs-table').innerHTML = '<tr><th>Day</th><th>Runs</th><th>Failed</th><th>Saved</th></tr>'
+ p.daily.map((d) => `<tr><td>${esc(dayLabel(d))}</td><td>${d.runs}</td><td>${d.failures}</td><td>${formatDuration(d.seconds)}</td></tr>`).join('');

const top = [...state.combos].filter((c) => c.runs).sort((a, b) => b.runs - a.runs).slice(0, 5);
const most = top[0]?.runs || 1;
$('#top-combos').innerHTML = top.length
? top.map((c) => `<li><span class="name">${esc(c.name)}</span>
<span class="track"><span class="fill" style="width:${Math.max(2, (c.runs / most) * 100)}%"></span></span>
<span class="num">${c.runs}</span></li>`).join('')
: '<li class="empty">No runs yet. Press ▶ Play on a combo!</li>';
}

// Hover (or focus) a day to see its numbers.
$('#runs-chart').addEventListener('pointermove', (e) => {
const bar = e.target.closest('.bar');
const tip = $('#runs-chart .chart-tip');
if (!tip) return;
if (!bar) {
tip.hidden = true;
return;
}
const box = $('#runs-chart').getBoundingClientRect();
tip.textContent = bar.dataset.tip;
tip.hidden = false;
const x = Math.min(e.clientX - box.left + 12, box.width - tip.offsetWidth - 4);
tip.style.transform = `translate(${Math.max(0, x)}px, ${e.clientY - box.top - 36}px)`;
});
$('#runs-chart').addEventListener('pointerleave', () => {
const tip = $('#runs-chart .chart-tip');
if (tip) tip.hidden = true;
});

$('#open-stats').addEventListener('click', () => {
$('#stats').showModal();
renderStats();
});
$('#close-stats').addEventListener('click', () => $('#stats').close());

// ---------- theme ----------
function applyTheme(theme) {
const light = theme === 'light';
Expand Down
17 changes: 17 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<div class="stat" title="Successful runs"><span>⚙️</span><b id="stat-runs">0</b></div>
<button class="stat stat-btn theme-toggle" id="sound-toggle" title="Mute sound" aria-label="Mute sound">🔊</button>
<button class="stat stat-btn theme-toggle" id="theme-toggle" title="Switch to light theme" aria-label="Switch to light theme">☀️</button>
<button class="stat stat-btn" id="open-stats" title="Stats" aria-label="Stats">📊</button>
<button class="stat stat-btn" id="open-achievements" title="Achievements"><span>🏆</span><b id="stat-ach">0/0</b></button>
</div>
</header>
Expand Down Expand Up @@ -109,6 +110,22 @@ <h2>🏆 Achievements</h2>
<div class="ach-grid" id="ach-grid"></div>
</dialog>

<dialog id="stats">
<div class="dialog-head">
<h2>📊 Stats</h2>
<button class="ghost small" id="close-stats">Close</button>
</div>
<div class="stat-tiles" id="stat-tiles"></div>
<h3>Runs, last 14 days</h3>
<div class="chart" id="runs-chart"></div>
<details class="chart-table">
<summary>Show as a table</summary>
<table id="runs-table"></table>
</details>
<h3>Busiest combos</h3>
<ol class="top-combos" id="top-combos"></ol>
</dialog>

<div class="toasts" id="toasts"></div>
<script src="app.js" type="module"></script>
</body>
Expand Down
40 changes: 40 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@
--legendary-bg: #3a2a17;
--toast-glow: #3a2614;
--glow: rgba(255, 138, 61, 0.12);
/* Chart bars: checked for lightness and contrast against the panel. */
--chart: #d9661f;
--chart-hover: #e8742a;
color-scheme: dark;
}

/* Light theme: parchment instead of the forge at night. Card art windows
stay dark on purpose, like the art frames on real cards. */
:root[data-theme="light"] {
--bg: #f4ede3;
--chart: #d9621b;
--chart-hover: #c2560f;
--bg-2: #ebe2d5;
--panel: #fffaf2;
--panel-2: #f2e9dc;
Expand Down Expand Up @@ -472,6 +477,41 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.ach.locked { opacity: 0.45; filter: grayscale(1); }
.ach.unlocked { border-color: var(--ember); box-shadow: 0 0 12px rgba(255, 138, 61, 0.2); }

/* ---------- stats ---------- */
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }
.stat-tile { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; background: var(--bg-2); display: grid; gap: 2px; }
.stat-tile span { font-size: 18px; }
.stat-tile b { font-size: 22px; font-variant-numeric: tabular-nums; }
.stat-tile small { color: var(--muted); font-size: 12px; }
.chart { position: relative; }
.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
.chart .grid { stroke: var(--line); stroke-width: 1; fill: none; }
.chart .bar path { fill: var(--chart); }
.chart .bar .hit { fill: transparent; }
.chart .bar:hover path { fill: var(--chart-hover); }
.chart .bar:hover .hit { fill: var(--glow); }
.chart text { fill: var(--muted); font-size: 12px; text-anchor: middle; font-family: var(--font); }
.chart .value { fill: var(--text); font-weight: 600; }
.chart .today .tick { fill: var(--text); font-weight: 700; }
.chart-tip {
position: absolute; left: 0; top: 0; pointer-events: none; white-space: nowrap;
background: var(--panel-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px;
padding: 5px 9px; font-size: 12px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
}
.chart-table { margin-top: 6px; font-size: 13px; color: var(--muted); }
.chart-table summary { cursor: pointer; }
.chart-table table { width: 100%; border-collapse: collapse; margin-top: 6px; color: var(--text); }
.chart-table th, .chart-table td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--line); font-variant-numeric: tabular-nums; }
.chart-table th { color: var(--muted); font-weight: 500; }
.top-combos { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.top-combos li { display: grid; grid-template-columns: minmax(90px, 34%) 1fr auto; gap: 10px; align-items: center; font-size: 13px; }
.top-combos .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-combos .track { height: 10px; background: var(--bg-2); border-radius: 4px; overflow: hidden; }
.top-combos .fill { display: block; height: 100%; background: var(--chart); border-radius: 0 4px 4px 0; }
.top-combos .num { font-variant-numeric: tabular-nums; color: var(--text); min-width: 2ch; text-align: right; }
.top-combos .empty { display: block; color: var(--muted); }
#stats h3 { margin-top: 18px; }

/* ---------- toasts ---------- */
.toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 20; width: min(340px, calc(100vw - 32px)); }
.toast {
Expand Down
9 changes: 7 additions & 2 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { render, baseContext } from './template.js';
import {
XP_PER_RUN, XP_PER_ACTION, levelInfo, levelFromXp, comboLevelInfo,
checkAchievements, ACHIEVEMENTS, publicAchievement, recordStreakDay, liveStreak,
progressQuests, questStatus,
progressQuests, questStatus, recordDaily, dailySeries,
} from './game.js';

const MAX_ACTIVITY = 150;
Expand Down Expand Up @@ -171,6 +171,7 @@ export class Engine extends EventEmitter {
combo.lastError = err.message;
combo.lastRun = this.now().toISOString();
this.store.profile.stats.failures += 1;
recordDaily(this.store.profile.stats, this.now(), { failures: 1 });
const where = err.card ? `${err.card.emoji} ${err.card.name} failed: ` : '';
this.log('error', `${combo.name}: ${where}${err.message}`, combo.id);
this.recordHistory(combo, 'error', `${where}${err.message}`);
Expand Down Expand Up @@ -220,6 +221,7 @@ export class Engine extends EventEmitter {
profile.stats.secondsSaved += seconds;
if (now.getHours() < 5) profile.stats.nightRuns += 1;
recordStreakDay(profile.stats, now);
recordDaily(profile.stats, now, { runs: 1, seconds });
for (const slot of combo.actions) {
profile.stats.actions[slot.card] = (profile.stats.actions[slot.card] || 0) + 1;
}
Expand Down Expand Up @@ -377,7 +379,10 @@ export class Engine extends EventEmitter {
const { profile } = this.store;
const player = levelInfo(profile.xp);
return {
player: { ...player, stats: profile.stats, streak: liveStreak(profile.stats, this.now()) },
player: {
...player, stats: profile.stats, streak: liveStreak(profile.stats, this.now()),
daily: dailySeries(profile.stats, this.now(), 14),
},
quests: questStatus(profile, this.now()),
cards: CARDS.map((c) => publicCard(c, player.level)),
combos: this.store.combos.map((c) => ({
Expand Down
27 changes: 26 additions & 1 deletion src/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function newProfile() {
return {
xp: 0,
achievements: [],
stats: { runs: 0, failures: 0, secondsSaved: 0, nightRuns: 0, actions: {}, cardsUsed: [], streak: { current: 0, best: 0, lastDay: null } },
stats: { runs: 0, failures: 0, secondsSaved: 0, nightRuns: 0, actions: {}, cardsUsed: [], streak: { current: 0, best: 0, lastDay: null }, daily: {} },
quests: { day: null, progress: {}, seen: {}, done: [] },
};
}
Expand All @@ -67,6 +67,31 @@ const pad = (n) => String(n).padStart(2, '0');
const dayKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const yesterdayKey = (now) => dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1));

export const DAILY_DAYS_KEPT = 60;

// Runs and time saved per local day, for the stats chart. Only the most
// recent DAILY_DAYS_KEPT days are kept so the save file stays small.
export function recordDaily(stats, now, { runs = 0, failures = 0, seconds = 0 }) {
const today = dayKey(now);
const day = (stats.daily[today] ??= { runs: 0, failures: 0, seconds: 0 });
day.runs += runs;
day.failures += failures;
day.seconds += seconds;
const keys = Object.keys(stats.daily).sort();
for (const key of keys.slice(0, Math.max(0, keys.length - DAILY_DAYS_KEPT))) delete stats.daily[key];
}

// The last `days` days, oldest first, with zeros for days without runs.
export function dailySeries(stats, now, days = 14) {
const out = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
const key = dayKey(d);
out.push({ day: key, weekday: d.getDay(), ...{ runs: 0, failures: 0, seconds: 0 }, ...stats.daily?.[key] });
}
return out;
}

// Daily streak: consecutive local days with at least one successful run.
export function recordStreakDay(stats, now) {
const streak = stats.streak;
Expand Down
1 change: 1 addition & 0 deletions src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class Store {
const profile = { ...fresh.profile, ...saved.profile };
profile.stats = { ...fresh.profile.stats, ...saved.profile?.stats };
profile.stats.streak = { ...fresh.profile.stats.streak, ...profile.stats.streak };
profile.stats.daily = { ...profile.stats.daily };
return { ...fresh, ...saved, profile };
} catch (err) {
const backup = `${this.file}.broken-${Date.now()}`;
Expand Down
23 changes: 23 additions & 0 deletions test/engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,29 @@ test('Chain Reaction runs another combo with the same file, and refuses loops',
assert.equal(paused.summaries[1], 'Second is paused, skipped it');
});

test('runs and failures are counted per day for the stats chart', async () => {
const { dailySeries, recordDaily, DAILY_DAYS_KEPT } = await import('../src/game.js');
const ok = engine.saveCombo({ name: 'Ok', trigger: { card: 'manual' }, actions: [{ card: 'notify' }] });
const bad = engine.saveCombo({
name: 'Bad', trigger: { card: 'manual' },
actions: [{ card: 'move-file', params: { destination: path.join(dir, 'x') } }],
});
await engine.fire(ok.id, {}, 'manual');
await engine.fire(ok.id, {}, 'manual');
await engine.fire(bad.id, {}, 'manual');
const series = engine.snapshot().player.daily;
assert.equal(series.length, 14);
assert.deepEqual(series.at(-1), { day: '2026-09-26', weekday: 6, runs: 2, failures: 1, seconds: 10 });
assert.equal(series[0].day, '2026-09-13');
assert.equal(series[0].runs, 0);

const stats = { daily: {} };
for (let i = 0; i < DAILY_DAYS_KEPT + 5; i++) recordDaily(stats, new Date(2026, 0, 1 + i), { runs: 1 });
assert.equal(Object.keys(stats.daily).length, DAILY_DAYS_KEPT);
assert.equal(Object.keys(stats.daily).sort()[0], '2026-01-06', 'oldest days are dropped');
assert.equal(dailySeries(stats, new Date(2026, 2, 7), 3).map((d) => d.runs).join(), '1,1,0');
});

test('Compactor gzips a file and can delete the original', async () => {
const zlib = await import('node:zlib');
const { fileInfo } = await import('../src/cards.js');
Expand Down
Loading