Show the build, play from the keyboard, and clean up the ledger

Five things from the second playtest.

A status line at the top of every screen: the game, the pack, your current
title, and the version plus the commit it came from. `tools/stamp.js` writes the
commit into src/build-info.js before serving and before building, so a
distributed dist/theladder.html states exactly which commit produced it. An
uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a
bare SHA would not be.

The whole game is now playable on Enter. Every screen marks one control and the
app focuses it after each render: the first *available* option while choosing,
the next-turn button once the result is in, "keep going" on the retrospective.
Tab and Shift+Tab reach the other options. Locked options needed no special
handling — the browser's tab order already skips disabled controls, so they stay
visible without being in the way.

"What is notable? under What changed?" — two fixes. `notable` is the
retrospective's own list of moments; a display rule can now say
`inLedger: false` and it is left out, because "Notable — changed" tells nobody
anything. Flags are the opposite and stay, but as sentences the pack supplies
rather than as booleans: "The envelope is in your locker." instead of "the
envelope: now true".

Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest
one there when given no argument. A log records what a real person did turn by
turn, including whatever they typed into the feedback box — committing one once
would put it in the history for good.

Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface
0.2; say if you want a different scheme and I will renumber.

119 tests. Reasoning in docs/DECISIONS.md §25-28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
This commit is contained in:
JesseMarkowitz
2026-09-10 05:41:32 -04:00
co-authored by Claude Opus 5
parent 4cab9fc41e
commit 76072edf4e
19 changed files with 416 additions and 25 deletions
+5
View File
@@ -1,4 +1,9 @@
node_modules/
dist/
# Exported playtest logs are somebody's real session, including whatever
# they typed into the feedback box. Kept local; see logs/README.md.
logs/*
!logs/README.md
*.log
.DS_Store
+13 -2
View File
@@ -31,6 +31,8 @@ currently playable only from a test harness.
| Calendar, scheduled wages and rent | done |
| Promotion from the mailroom to Dispatch | done |
| Repetition analytics (`npm run analyze`) | done |
| Version and build shown on screen | done |
| Keyboard play (Enter / Tab) | done |
| Played in a real browser | done — first playtest 2026-09-09 |
The first playtest confirmed the interface, saves and the exported log all work
@@ -49,6 +51,11 @@ it.**
npm run serve # then open http://localhost:8080
```
The game plays from the keyboard: Enter takes the focused option, Enter again
moves to the next day, and Tab / Shift+Tab pick a different option. The top of
every screen names the game, the pack, your current title, and the version and
commit you are running.
A server is needed during development because browsers refuse ES module imports
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
instead.
@@ -63,7 +70,7 @@ That is one self-contained file with the stylesheet and every module inlined.
It plays by double-clicking it — no server, no network, nothing installed.
```sh
npm test # 114 tests: engine, content, UI wiring, and the build
npm test # 119 tests: engine, content, UI wiring, and the build
```
To see what a playtest actually did — turns against distinct situations, how
@@ -71,9 +78,13 @@ often each recurred, which locked gates players kept meeting, and anything they
typed into the feedback box:
```sh
npm run analyze -- theladder-feedback-2026-09-10-11-04-22.json
npm run analyze # the newest export in logs/
npm run analyze -- <file> # a specific one
```
Drop exports into `logs/`. They are gitignored — a log is a record of what a
real person did, including anything they typed into the feedback box.
There are no runtime dependencies and no build step for development. Node is
used only to run the tests, the single-file build, and the log analyser.
+30 -6
View File
@@ -116,12 +116,36 @@ export const corporateLadder = {
'reputation': { label: 'Standing', format: 'meter', order: 3 },
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
'notable': { hidden: true },
'flags.took_loan': { label: 'a loan on the books', hidden: true },
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
'flags.promoted': { label: 'a promotion', hidden: true },
'flags.recommended_trevor': { label: "Trevor's recommendation", hidden: true },
// Kept out of the header, and out of the ledger too: `notable` is the
// retrospective's own list, and "Notable — changed" tells nobody anything.
'notable': { hidden: true, inLedger: false },
// Flags do belong in the ledger — a flag flipping is a fact about the
// world — but as a sentence rather than as "now true".
'flags.took_loan': {
label: 'The loan', hidden: true,
whenTrue: 'You are carrying a loan now.',
whenFalse: 'The loan is settled.',
},
'flags.holds_the_envelope': {
label: 'The envelope', hidden: true,
whenTrue: 'The envelope is in your locker.',
whenFalse: 'The envelope is back in the system.',
},
'flags.covered_for_trevor': {
label: "Trevor's Thursday", hidden: true,
whenTrue: 'Trevor owes you a Thursday.',
whenFalse: 'You and Trevor are square.',
},
'flags.promoted': {
label: 'Promotion', hidden: true,
whenTrue: 'You are out of the mailroom.',
whenFalse: 'Back in the mailroom.',
},
'flags.recommended_trevor': {
label: "Trevor's recommendation", hidden: true,
whenTrue: "Trevor's name is in for the mailroom.",
whenFalse: "Trevor's name is not in for the mailroom.",
},
},
characters,
+52
View File
@@ -313,3 +313,55 @@ eight events against the mailroom's nineteen meant its two floor events were 46%
of every promoted run, one every three turns. Promotion was moving the player
into *thinner* content. Three more events and a weight rebalance took the top
two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
## 25. The screen says which build it is
Playtest: *"need entry on the main screen showing what version we're running…
so it's easy to tell when new stuff is."*
A status line at the top of every screen names the game, the pack, the player's
current title, and the version plus the commit it was built from. `tools/stamp.js`
writes the commit into `src/build-info.js` before serving and before building,
so a distributed `dist/theladder.html` states exactly which commit produced it,
and a dev session says so too. A working tree with uncommitted changes gets a
`+` suffix — "4cab9fc+" is honest in a way that a bare SHA would not be.
The file is tracked rather than generated-and-ignored so a fresh clone runs
without a build step first. It changes at most once per commit, and the stamper
ignores a same-commit restamp so a running server does not churn the tree.
## 26. The whole game is playable from the keyboard
Playtest: *"when I get the results, the focus should automatically be on the
next day, so I can just hit Enter."*
Every screen marks one control with `dataset.autofocus` and the app focuses it
after each render: the first *available* option while choosing, the next-turn
button once the result is in, "keep going" on the retrospective. A run is
therefore playable on Enter alone, with Tab and Shift+Tab to reach a different
option.
Locked options need no special handling — the browser's own tab order skips
disabled controls, so they stay visible without being in the way, which is
exactly the behaviour wanted.
## 27. The ledger is written for a player, not for the engine
Two fixes from the same playtest note, *"what is notable? under what changed?"*.
`notable` is the retrospective's own list of moments. Reading "Notable —
changed" in the turn ledger tells nobody anything, so a display rule may now
say `inLedger: false` and it is left out.
Flags are the opposite: a flag flipping is a fact about the world and worth
seeing. But "the envelope: now true" is engine talk. A display rule can supply
`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and
the ledger prints those instead of a delta.
## 28. Exported logs live in logs/, and are not committed
`logs/` holds exported feedback logs and `npm run analyze` reads the newest one
there when given no argument. The logs are gitignored: they record what a real
person did turn by turn, including anything they typed into the feedback box.
That is not repository content, and making it so once would put it in the
history for good.
+13
View File
@@ -0,0 +1,13 @@
# Exported playtest logs
Drop exported feedback logs here — the browser downloads them wherever it
normally does, and this is where they go to be read.
```sh
npm run analyze # the newest log in this folder
npm run analyze -- <file> # a specific one
```
The logs themselves are gitignored. They record what a real person did, turn by
turn, including anything they typed into the feedback box, so they are not
committed: keep them local, or share them deliberately.
+4 -3
View File
@@ -1,13 +1,14 @@
{
"name": "theladder",
"version": "0.0.1",
"version": "0.3.0",
"private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module",
"scripts": {
"test": "node --test \"test/**/*.test.js\"",
"serve": "python3 -m http.server 8080",
"build": "node tools/build.js",
"stamp": "node tools/stamp.js",
"serve": "node tools/stamp.js && python3 -m http.server 8080",
"build": "node tools/stamp.js && node tools/build.js",
"analyze": "node tools/analyze-log.js"
}
}
+7
View File
@@ -0,0 +1,7 @@
// Stamped by tools/stamp.js, which runs before `npm run serve` and
// `npm run build`. The commit is whatever HEAD was at stamp time, so a dist
// file says exactly which commit produced it.
export const BUILD = {
commit: '4cab9fc+',
builtAt: '2026-09-10',
};
+4 -1
View File
@@ -16,7 +16,7 @@ import { resolveSchema } from '../engine/state.js';
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
import { turnScreen } from './screens/turn.js';
import { retrospectiveScreen } from './screens/retrospective.js';
import { fill } from './dom.js';
import { fill, focusMarked } from './dom.js';
import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js';
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
import { downloadText, readFile, stamp } from '../io/download.js';
@@ -49,6 +49,9 @@ function render() {
? retrospectiveScreen(pack, app.state, schema, actions)
: turnScreen(pack, app.state, schema, app.view, actions);
fill(app.root, screen);
// After the screen is in the document, put the caret where the player most
// likely wants it. This is what makes the game playable on Enter alone.
focusMarked(app.root);
}
const actions = {
+38 -4
View File
@@ -2,8 +2,27 @@
import { h } from './dom.js';
import { formatValue, labelFor, headerStats } from './format.js';
import { APP, versionLine } from '../version.js';
import { getPath } from '../engine/paths.js';
/**
* What you are playing, which pack, and who you currently are — plus the build,
* so a playtester can say which version they were on.
*/
export function statusBar(pack, state) {
const title = pack.stages?.[state.stage]?.title ?? state.stage;
return h('div', { class: 'statusbar' },
h('div', { class: 'statusbar__what' },
h('span', { class: 'statusbar__game' }, APP.name),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__pack' }, pack.name ?? pack.id),
h('span', { class: 'statusbar__sep' }, '·'),
h('span', { class: 'statusbar__title' }, title),
),
h('span', { class: 'statusbar__version', title: 'app version · build' }, versionLine()),
);
}
/** A labelled bar for a bounded stat. */
export function meter(label, value, bounds, { compact = false } = {}) {
const min = bounds?.min ?? 0;
@@ -60,14 +79,29 @@ export function characterCard(pack, state, schema, character) {
/** The change list shown after a choice: what moved, and by how much. */
export function changeList(pack, changes, { className = 'changes' } = {}) {
const meaningful = changes.filter((c) => c.from !== c.to);
const meaningful = changes.filter((change) => {
if (change.from === change.to) return false;
// Some state is bookkeeping the player has no use for — `notable` is the
// retrospective's own list, and reading "Notable — changed" in the ledger
// tells nobody anything.
return pack.display?.[change.path]?.inLedger !== false;
});
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
return h('ul', { class: className },
meaningful.map((change) => {
const rising = typeof change.to === 'number' && typeof change.from === 'number'
? change.to > change.from
: Boolean(change.to);
// A flag flipping is a fact about the world, not a quantity. Say it as a
// sentence the pack supplies rather than as "now true".
if (typeof change.to === 'boolean') {
const rule = pack.display?.[change.path];
const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse)
?? `${labelFor(pack, change.path)}: ${change.to ? 'yes' : 'no longer'}`;
return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` },
h('span', { class: 'change__phrase' }, phrase),
);
}
const rising = change.to > change.from;
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
// Upkeep changes carry the label of the entry that caused them —
// "Rent", "Wages" — which is far more use than the path's own name.
+22
View File
@@ -24,3 +24,25 @@ export function fill(container, ...children) {
container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
return container;
}
/**
* Focus whatever the screen marked with `dataset.autofocus`.
*
* Every screen names the one control a player most likely wants next — the
* first available option while choosing, "next day" once the result is in — so
* a whole run can be played on Enter alone, with Tab and Shift+Tab to pick a
* different option. Disabled buttons are skipped by the browser's own tab
* order, so locked options are visible without being in the way.
*/
export function focusMarked(root) {
const queue = [root];
while (queue.length) {
const node = queue.shift();
if (node?.dataset?.autofocus) {
node.focus?.();
return node;
}
queue.push(...(node?.childNodes ?? []));
}
return null;
}
+8 -3
View File
@@ -5,7 +5,7 @@
// not an ending: the player can close it and carry on from the same turn.
import { h } from '../dom.js';
import { statPanel, characterCard } from '../components.js';
import { statPanel, characterCard, statusBar } from '../components.js';
import { formatMoney, labelFor } from '../format.js';
import { summarize } from '../summary.js';
@@ -14,6 +14,8 @@ export function retrospectiveScreen(pack, state, schema, actions) {
const unit = pack.turnUnit ?? 'turn';
return h('div', { class: 'screen screen--retrospective' },
statusBar(pack, state),
h('header', { class: 'retro__head' },
h('p', { class: 'retro__eyebrow' }, pack.name),
h('h1', { class: 'retro__title' }, 'Career retrospective'),
@@ -64,8 +66,11 @@ export function retrospectiveScreen(pack, state, schema, actions) {
),
h('footer', { class: 'controls' },
h('button', { class: 'button button--primary', onClick: actions.onResume },
'Keep going'),
h('button', {
class: 'button button--primary',
dataset: { autofocus: 'true' },
onClick: actions.onResume,
}, 'Keep going'),
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
+12 -3
View File
@@ -10,13 +10,15 @@
// because this is the moment a player has an opinion.
import { h } from '../dom.js';
import { statPanel, characterCard, changeList } from '../components.js';
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
return h('div', { class: 'screen screen--turn' },
statusBar(pack, state),
h('header', { class: 'hud' },
h('div', { class: 'hud__where' },
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
@@ -78,6 +80,9 @@ function diary(pack, state) {
function choosingView(pack, view, actions) {
const { event, options } = view.turn;
const visible = options.filter((o) => !o.hidden);
// The first option a player can actually take gets the focus, so Enter plays
// it and Tab walks to the others.
const firstAvailable = visible.find((o) => o.available)?.option.id;
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, event.description),
@@ -86,6 +91,7 @@ function choosingView(pack, view, actions) {
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, option.label),
@@ -114,8 +120,11 @@ function outcomeView(pack, view, actions) {
feedbackWidget(view, actions),
h('button', { class: 'button button--primary button--next', onClick: actions.onNext },
`Next ${pack.turnUnit ?? 'turn'}`),
h('button', {
class: 'button button--primary button--next',
dataset: { autofocus: 'true' },
onClick: actions.onNext,
}, `Next ${pack.turnUnit ?? 'turn'} →`),
);
}
+21
View File
@@ -51,6 +51,24 @@ body {
}
.app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; }
/* ---------- status bar ---------- */
.statusbar {
display: flex; flex-wrap: wrap; gap: 8px 16px;
align-items: baseline; justify-content: space-between;
margin-bottom: 18px; padding-bottom: 10px;
border-bottom: 1px solid var(--rule);
font-size: 12px; letter-spacing: 0.04em;
}
.statusbar__what { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; }
.statusbar__game { font-weight: 600; color: var(--ink); }
.statusbar__sep { color: var(--rule-strong); }
.statusbar__pack { color: var(--ink-soft); }
.statusbar__title { color: var(--accent); }
.statusbar__version {
font-variant-numeric: tabular-nums; color: var(--ink-faint); font-size: 11px;
}
.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; }
/* ---------- head-up display ---------- */
@@ -178,6 +196,9 @@ body {
.change--up .change__delta { color: var(--up); }
.change--down .change__delta { color: var(--down); }
.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; }
.change--note .change__phrase { font-style: italic; }
.change--note.change--up .change__phrase { color: var(--up); }
.change--note.change--down .change__phrase { color: var(--ink-soft); }
.button--next { margin-top: 8px; }
+16
View File
@@ -0,0 +1,16 @@
// What is running. Shown on the main screen so a playtester can say which
// build they were on, and so it is obvious when new work has landed.
import { BUILD } from './build-info.js';
export const APP = {
name: 'The Ladder',
version: '0.3.0',
};
/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */
export function versionLine() {
return `${APP.version} · ${BUILD.commit}`;
}
export { BUILD };
+4
View File
@@ -24,6 +24,9 @@ test('the bundle builds, and the bundled game boots and plays', () => {
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
assert.match(html, /--paper:/, 'the CSS made it in');
// A distributed file has to be able to say which commit made it.
assert.match(html, /commit: '[^']+'/, 'the build stamp is present');
assert.doesNotMatch(html, /commit: 'dev'/, 'a built file is never stamped "dev"');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'the bundled script is present');
@@ -36,6 +39,7 @@ test('the bundle builds, and the bundled game boots and plays', () => {
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
assert.match(dom.root.textContent, /Day 1/);
assert.match(dom.root.textContent, /Marlene Hobb/);
assert.match(dom.root.textContent, /The Ladder/, 'the status bar is in the bundle');
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
assert.ok(playable.length > 0, 'the bundled game offers choices');
+5
View File
@@ -79,6 +79,10 @@ class FakeElement extends FakeNode {
}
click() { this.dispatch('click'); }
focus() {
if (globalThis.document) globalThis.document.activeElement = this;
}
}
function walk(node, out = []) {
@@ -122,6 +126,7 @@ export function installFakeDom() {
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
activeElement: null,
};
const listeners = {};
const window = {
+91
View File
@@ -275,3 +275,94 @@ test('wages and rent appear in the ledger on the days they fall due', async () =
dom.restore();
}
});
test('the status bar says what you are playing, which pack, and who you are', async () => {
const dom = await boot();
try {
const status = text(firstByClass(dom.root, 'statusbar'));
assert.match(status, /The Ladder/, 'the game');
assert.match(status, /Corporate Ladder/, 'the pack');
assert.match(status, /Mail Associate I/, 'the current title');
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
} finally {
dom.restore();
}
});
test('the status bar title follows the player up a tier', async () => {
const { statusBar } = await import('../src/ui/components.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
assert.match(promoted.textContent, /Dispatch Coordinator/);
} finally {
dom.restore();
}
});
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
const dom = await boot();
try {
// Choosing: the first option a player can actually take.
const firstPlayable = enabledOptions(dom.root)[0];
assert.equal(dom.document.activeElement, firstPlayable,
'the first available option is focused, so Enter takes it');
dom.document.activeElement.click();
// Outcome: the button that moves the run on.
const next = firstByClass(dom.root, 'button--next');
assert.equal(dom.document.activeElement, next,
'the next-turn button is focused, so Enter advances');
dom.document.activeElement.click();
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
} finally {
dom.restore();
}
});
test('locked options are not in the tab order', async () => {
const dom = await boot();
try {
// Disabled buttons are skipped by the browser's own tab handling; the
// guarantee this test protects is that locked options stay disabled.
for (let i = 0; i < 40; i++) {
const locked = optionButtons(dom.root).filter((b) => b.disabled);
if (locked.length) {
assert.ok(locked.every((b) => b.disabled === true));
return;
}
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.fail('no locked option appeared in 40 turns');
} finally {
dom.restore();
}
});
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
const { changeList } = await import('../src/ui/components.js');
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
const dom = installFakeDom();
try {
const rendered = changeList(pack, [
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
]);
const body = rendered.textContent;
assert.match(body, /Money/);
assert.match(body, /−\$20/);
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
assert.doesNotMatch(body, /now true/, 'not as a boolean');
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
assert.doesNotMatch(body, /Kept an envelope\./);
} finally {
dom.restore();
}
});
+27 -3
View File
@@ -10,7 +10,29 @@
// It also surfaces the two things worth more than any rating — the gates
// players kept bouncing off, and anything they said they wanted to do.
import { readFile } from 'node:fs/promises';
import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const logsDir = resolve(root, 'logs');
/** The most recently modified log in logs/, when none was named. */
async function newestLog() {
let names;
try {
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
} catch {
return null;
}
if (!names.length) return null;
const timed = await Promise.all(names.map(async (name) => {
const path = join(logsDir, name);
return { path, at: (await stat(path)).mtimeMs };
}));
return timed.sort((a, b) => b.at - a.at)[0].path;
}
const bar = (fraction, width = 24) =>
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
@@ -147,12 +169,14 @@ function reportFeedback(entries) {
}
async function main() {
const path = process.argv[2];
const path = process.argv[2] ?? await newestLog();
if (!path) {
console.error('usage: node tools/analyze-log.js <exported-feedback-log.json>');
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
console.error(' or: drop the export into logs/ and run npm run analyze');
process.exitCode = 1;
return;
}
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
const log = JSON.parse(await readFile(path, 'utf8'));
const entries = log.entries ?? [];
+44
View File
@@ -0,0 +1,44 @@
// Write the current commit into src/build-info.js.
//
// Runs before serving and before building, so what is on screen always names
// the commit it came from. The file is tracked rather than generated-and-
// ignored so that a fresh clone works without running anything first; it
// changes at most once per commit, and only when the value actually differs.
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const target = resolve(root, 'src/build-info.js');
function commit() {
try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' })
.toString().trim();
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' })
.toString().trim().length > 0;
return dirty ? `${sha}+` : sha;
} catch {
return 'dev';
}
}
const next = `// Stamped by tools/stamp.js, which runs before \`npm run serve\` and
// \`npm run build\`. The commit is whatever HEAD was at stamp time, so a dist
// file says exactly which commit produced it.
export const BUILD = {
commit: '${commit()}',
builtAt: '${new Date().toISOString().slice(0, 10)}',
};
`;
const current = readFileSync(target, 'utf8');
// Only the date differs on a same-commit restamp; leave the file alone then,
// so a working tree does not churn every time the server starts.
const strip = (text) => text.replace(/builtAt: '[^']*'/, '');
if (strip(current) !== strip(next)) {
writeFileSync(target, next, 'utf8');
console.log(`stamped build ${commit()}`);
}