A zero-build, single-file project plan that renders its own progress report.
Open project-plan.html in any browser β no server, no build step, no
dependencies beyond one CDN script (Frappe Gantt,
MIT, ~50KB, zero dependencies of its own).
βββββββββββββββββββββββββββββββββββββββββββ
β MINI REPORT (generated) β β stall alerts, summary cards,
β stall banner Β· cards Β· phase bars β per-phase progress
βββββββββββββββββββββββββββββββββββββββββββ€
β GANTT TIMELINE (generated) β β Frappe Gantt, SVG
βββββββββββββββββββββββββββββββββββββββββββ€
β PHASES & CHECKLIST (generated) β β pretty render of the plan
βββββββββββββββββββββββββββββββββββββββββββ€
β PLAN SOURCE (you edit this) β β inert plain-text block:
β <script type="text/plain"> β the single source of truth
βββββββββββββββββββββββββββββββββββββββββββ€
β PARSER + RENDERER <script> β β ~150 lines of vanilla JS
βββββββββββββββββββββββββββββββββββββββββββ
There is exactly one data representation: a human-readable, line-based plain text block. The report, Gantt chart, and checklist are all derived from it at page load. There is no JSON block and no Python regeneration script, because two representations of the same plan will eventually drift; a derived view cannot.
The plan lives in:
<script type="text/plain" id="plan-source"> ... </script>Browsers ignore (never execute, never display) <script> elements whose
type is not a known script/JSON type, but the content remains in the DOM and
is readable via element.textContent. This makes it a safe in-page data
container β no escaping issues, no eval, no XSS surface from the data itself.
The parser <script> sits at the very end of <body>. HTML parsing is
sequential, so by the time it executes, every element above it β including the
plan block β already exists. No DOMContentLoaded listener is needed, and the
report containers at the top of the page can be filled immediately.
Strict, line-based, deterministic. One regex per line type; anything that matches nothing is reported in a red "unparseable lines" banner rather than silently dropped.
project: Storefront v2
owner: Noel
stall-threshold-days: 2
phase: Phase 2 β Core Features
[status] id | Task name | YYYY-MM-DD -> YYYY-MM-DD | deps: id, id
| Marker | Meaning |
|---|---|
[x] |
done |
[>] |
in progress |
[ ] |
todo |
The deps: segment is optional. Dependency ids draw arrows in the Gantt.
YYYY-MM-DD NN% optional free-text note
To record work, append a log line β never rewrite history. The log is an append-only journal, which is what makes stall detection possible.
RE_META = /^([a-z-]+):\s*(.+)$/
RE_PHASE = /^phase:\s*(.+)$/
RE_TASK = /^\[(x|>| )\]\s*(\S+)\s*\|\s*(.+?)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*->\s*(\d{4}-\d{2}-\d{2})\s*(?:\|\s*deps:\s*(.*))?$/
RE_LOG = /^\s+(\d{4}-\d{2}-\d{2})\s+(\d{1,3})%\s*(.*)$/The format is intentionally rigid so that AI coding agents can edit it reliably β there is exactly one way to write each line.
For every task, the renderer computes:
progressβ100if status is[x], otherwise the % of the last log entry, otherwise0.- Overall progress β unweighted mean of all task progress values.
- Phase progress β unweighted mean within the phase.
- Target date β max
enddate across tasks (shown in the subtitle).
Goal: flag a bug/feature that has been "worked on" for 2β3 days without actual movement β e.g. daily log entries that all say 40%.
Definition: a task is stalled iff
- its status is
[>](in progress), and idleDays >= stall-threshold-days, whereidleDaysis the number of whole days between the last date its logged % actually increased and today.
// For each task: walk the log in order, tracking the previous %.
let lastMovement = null, prev = 0;
for (const e of t.log) {
if (e.progress > prev) lastMovement = d(e.date); // a real increase
prev = e.progress;
}
// Fallbacks: a log with no increases β first log date;
// no log at all β the task's start date.
if (!lastMovement) lastMovement = t.log.length ? d(t.log[0].date) : d(t.start);
const idleDays = Math.floor((today - lastMovement) / 86400000);
const stalled = t.status === "in-progress" && idleDays >= THRESH;- Deterministic. Output depends only on the log lines plus today's date β no heuristics, no state stored anywhere else. The same file gives the same verdict on any machine on the same day.
- Activity β progress. Log entries that repeat the same % do not reset the clock. Only an increase counts as movement. "Touched it every day, still at 40%" is exactly the situation that gets flagged.
- Honest fallbacks. A task that was started (
[>]) but never logged is measured from its plannedstartdate, so silent tasks can't hide. - Done/todo tasks are exempt.
[x]can't stall by definition;[ ]hasn't started, so lateness is visible in the Gantt instead (bar left of the today line). - Tunable per file.
stall-threshold-days: 2in the header. Use3for a looser policy. - Midnight-normalized.
todayis truncated to local midnight and dates are parsed as local dates, so a log entry from yesterday evening counts as 1 idle day, not 0.86.
- A red alert banner at the top: task name, the % it's stuck at, days idle, and the most recent note (usually the blocker, e.g. "blocked on vendor API rate limits").
- A red Gantt bar (
custom_class: "bar-stalled"+ CSS fill override). - A
stalled Ndbadge in the checklist, and a red dot icon. - The Stalled summary card turns red with the count.
- A task whose % decreases (re-scoped estimate) does not count as movement; the clock keeps running from the last increase. If you re-scope, it's honest to keep the flag until the % moves up again.
- Weekends count as idle days. If you don't want that, replace
daysBetweenwith a business-day count β it's the only function you'd touch. - Two log entries on the same date: last one wins for current %, and an increase on that date still registers as movement.
new Gantt("#gantt", tasks.map(...), {
view_mode: "Week", // whole project visible at typical widths
readonly: true, // report, not an editor β text is the editor
infinite_padding: false, // don't generate months of empty columns
view_mode_select: true, // Day/Week/Month dropdown in the header
scroll_to: "start",
container_height: "auto", // never clip rows
});Per-task styling uses Frappe's custom_class hook plus CSS overrides:
bar-stalled (red fill) and bar-done (green progress fill).
One workaround worth knowing: Frappe still pads some empty columns before the
first task, so after render the script finds the minimum x of all
rect.bar elements and sets the container's scrollLeft to it. Without this
the initial view can open on empty space or auto-scroll to today, hiding
completed early-phase bars.
If the CDN is unreachable (offline), typeof Gantt === "undefined" is
detected and the chart degrades to a notice β the report, alerts, and
checklist are pure vanilla JS and still work.
Daily update = append one line under the task you touched:
2026-06-11 55% vendor unblocked us, fix in review
Flip [>] to [x] when done. Add tasks/phases as plain lines. Reload the
page β the report recomputes. The raw source is also viewable in-page via the
"View raw plan source" disclosure at the bottom, so reviewers never need to
open the file in an editor.
Because the format is line-based and append-only, it diffs cleanly in git: one log line per update, no JSON re-serialization noise.
- Programmatic consumers: the four regexes above port to Python in ~30
lines if you ever want
plan.html β plan.jsonfor external tooling. The HTML file needs nothing from that script β it would be a one-way export. - Multiple projects: copy the file; everything is self-contained.
- Stricter stalls: also flag
[>]tasks past theirenddate, or todo tasks whosestartis in the past β both are one-line filters over the derived task list.