diff --git a/.fp/extensions/tsconfig.json b/.fp/extensions/tsconfig.json new file mode 100644 index 0000000..101e980 --- /dev/null +++ b/.fp/extensions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "paths": { + "@fiberplane/extensions": [ + "/Users/mark/.fiberplane/extensions/types" + ] + } + }, + "include": [ + "*.ts" + ] +} diff --git a/.gitignore b/.gitignore index 006f3df..30d421b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,44 @@ !fp-report.template.html **/.DS_Store + +# Fiberplane local issue tracking. Extensions are SOURCE (they define this project's +# status workflow and label vocabulary, which the report mirrors) — track them. As above, +# the directory must be re-included before anything inside it can be. +.fp/* +!.fp/remote.toml +!.fp/extensions/ +.fp/extensions/**/node_modules/ + +# Personal convenience symlinks (e.g. _fp-report -> fp-report.sh) — never committed +/_* + +# Sokrates analysis. The rule above would swallow the whole _sokrates dir, so re-include +# it first: git cannot un-ignore a file whose parent directory is still excluded. +# Keep config.json (it defines the analysis); ignore everything Sokrates generates. +!/_sokrates/ +_sokrates/reports +_sokrates/findings +_sokrates/history + +# fp-report's own label cache — derived from fp state, rebuilt on demand, ~100KB of churn +# per run. Matched by name, not path, since OUTPUT_DIR is configurable. +.fp-report.labels.json + +# Export bundles written outside dist/ +*.zip + +# Secrets. This tool reads no .env (config lives in fp-report.conf, which IS committed) — +# this is a guard against one being dropped here by habit, not a supported config path. +.env +.env.* +!.env.example + +# Editor / IDE +.idea/ +.vscode/ +*.iml +*.swp + +# Claude Code: shared project settings are committed, personal overrides are not +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..456aaad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +@FP_AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3309544..197c5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,47 @@ template, or tooling — not the issue data a given report happens to show. ## [Unreleased] +### Changed +- **The template is now a directory of parts** (`template/`): the page shell plus + `styles/*.css` and `app/*.js`, inlined at render time via ``. + The output is unchanged — still one self-contained HTML file, verified byte-identical + to the previous monolith — but the 880-line file is now eleven files of 10–210 lines. + The engine aborts on a missing include, and on a directive that isn't alone on its line + (which cannot be substituted and would otherwise ship as a literal comment). +- `install.sh` and `export.sh` moved to `tools/`. The engine itself did not move, so + existing `~/bin/fp-report` symlinks keep working — only the install/export commands + gain a `tools/` prefix. + ### Added +- **Themes tab** — a new first/default page of tiles that groups the whole backlog into + themes. Each tile carries a **priority distribution** (critical / high / medium / low / + unset), every row a bar **segmented by status**, scaled against the largest row across + the whole grid so bar length is comparable between tiles. Tiles are grouped by *facet* + (Quality / Area / Work type, configurable) and clicking one jumps to the Issues tab + filtered to that theme. + - **hide done** now also drives the tiles, and **ships ticked**: they open on live work + only, so a mostly-complete backlog stops flattening every theme into the same shape. + Headline, legend, footer and bar scale all follow the toggle. + - The taxonomy is per-project data (`fp-report.themes.json`, `THEMES_FILE`), meant to + mirror the project's `.fp/extensions/labels` vocabulary. + - Every issue lands in **exactly one theme per facet**, so tiles always partition the + backlog. Resolution order: **fp label → inherited from its epic → keyword match → + unthemed**, with the page footnoting how many issues came from each source. Labels + always win, so labelling an issue is how you correct a tile. + - Added a **theme filter** to the Issues tab, following the selected facet. + - Tiles count **leaf work items**; epics are reported separately as `N epics (M open)`. + An epic's status is a roll-up of its children, so counting both double-counts the + same work — that alone put 8 phantom items into MC's WIP figure. + - **Epic-scoped priority is kept distinct.** Inside an epic, "critical" ranks a + sub-issue against its siblings, not against the backlog; the epic roadmap already + scores epics by their own priority for this reason. Each priority row's tooltip + splits its count into top-level versus epic-scoped. + - A sub-issue **follows its epic's theme** unless it carries its own label, so one + initiative stays in one tile instead of fragmenting across five. +- **Label enrichment.** `fp issue list` omits `properties`, so the engine now fetches + each issue's labels via parallel `fp issue show` calls and caches them by `updatedAt` + next to the report — a cold run on ~1000 issues takes about a minute, later runs under + a second. `--no-labels` skips it; `--refresh-labels` ignores the cache. - **Global search** at the top of every report — matches across issue **id, title, and spec (description)**, ANDs space-separated terms, ranks id/title hits above spec hits, highlights matches, and hides the dashboard while searching. Tap a result to expand its diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e0c7af --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@FP_CLAUDE.md diff --git a/FP_AGENTS.md b/FP_AGENTS.md new file mode 100644 index 0000000..67e5d9b --- /dev/null +++ b/FP_AGENTS.md @@ -0,0 +1,77 @@ + + +## FP Issue Tracking + +This project uses **fp** for issue tracking. AI agents must follow these rules. + +## Workflow Primers + +Run `fp guide` for workflow primers and a snapshot of this project (registered statuses, properties, extensions). + +```bash +fp guide # List available primers +fp guide plan # Planning workflow (alias: planning) +fp guide implement # Implementation workflow +fp guide brainstorm # Brainstorm authoring (alias: bs) +fp guide extension # Extension authoring (aliases: extensions, ext) +``` + +Some primers bundle references reachable via `--references`, e.g. `fp guide brainstorm --references mermaid` for the Mermaid diagram guide. + +### Task Tracking + +- Use `fp issue` for all task tracking - do not use built-in todo tools +- Create subissues with `--parent` flag - never use markdown checklists (`- [ ]`) +- Break work into atomic tasks (1-3 hours each) + +### Work Session Flow + +1. `fp issue list --status todo` - find available work +2. `fp issue update --status in-progress ` - claim it before starting +3. `fp comment "progress..."` - log at every milestone +4. `fp issue update --status done ` - mark complete when finished + +When you commit work, mention the fp issue in the message, unless otherwise instructed. + +### Progress Logging + +- Run `fp comment "..."` at every milestone +- Write comments after significant commits +- Always leave a final comment before ending session + +### Commands Reference + +```bash +fp tree [parent-id] # View issue hierarchy (optionally only show tree of parent-id) +fp issue list --status X # Filter by status (todo/in-progress/done) +fp search # Search issues (AND by default, OR, "phrases", -negation) +fp issue create --title "..." --parent X --property key=value +fp issue update --status X --property key=value +fp comment "message" +fp comment update "new message" # Alias: edit +fp comment delete # Soft-delete/hide a comment +fp context # Load full issue context +fp guide extension # Print the extensions authoring guide +fp guide brainstorm # Print the brainstorm-authoring guide +``` + +### Flag Conventions + +- Standard attributes use dedicated flags: `--status`, `--priority`, `--parent`, `--title`, `--description` +- `fp issue update --depends "" ` replaces the entire dependency set. It does not append. To add one dependency, first inspect the current dependencies and pass the full desired set. +- `--property key=value` is for **extension-registered custom properties only** (e.g., labels, env, notes) +- Do not use `--property` for standard attributes — use the dedicated flags instead + +### Extensions + +FP is extensible via TypeScript extensions. Extensions can hook into issue and comment lifecycle events (e.g., before marking issues done, after comments are added) to automate workflows. + +- Guide: `.fp/extensions/EXTENSIONS.md` (or run `fp guide extension` if the file is not present) +- Extensions live in `.fp/extensions/` as `.ts` files + +### Brainstorms + +Brainstorm plans (`fp brainstorm create` / `fp bs create`) support a rich markdown + Mermaid authoring surface. Before writing or editing one, load the bundled authoring docs: + +- `fp guide brainstorm` — entrypoint; read this first +- `fp guide brainstorm --references mermaid` — diagram authoring reference diff --git a/FP_CLAUDE.md b/FP_CLAUDE.md new file mode 100644 index 0000000..9e948c6 --- /dev/null +++ b/FP_CLAUDE.md @@ -0,0 +1,42 @@ +# FP Claude Instructions + +This file is managed by `fp agent setup claude` and `fp init --agent claude`. + +This project uses **fp** for issue tracking. Claude Code agents must follow these rules. + +## Guides + +Run `fp guide` for workflow primers and a snapshot of this project (registered statuses, properties, extensions). + +```bash +fp guide plan +fp guide implement +fp guide brainstorm +fp guide extension +``` + +## Issue Workflow + +- Before starting implementation work, load the relevant issue with `fp context `. +- Mark claimed work in progress with `fp issue update --status in-progress `. +- Add comments at meaningful checkpoints with `fp comment ""`. +- Use `fp tree` and `fp issue list` to inspect nearby work before creating duplicate issues. +- When work is complete and verified, mark the issue done with `fp issue update --status done `. + +## Commands Reference + +```bash +fp tree [parent-id] # View issue hierarchy +fp issue list --status X # Filter by status +fp issue create --title "..." --parent X --property key=value +fp issue update --status X --property key=value +fp comment "message" +fp comment update "new message" # Alias: edit +fp comment delete # Soft-delete/hide a comment +fp context # Load full issue context +``` + +## Project Notes + +- Treat this file as fp-managed. Put project-specific Claude Code preferences in `CLAUDE.md`. +- Keep user-authored content in `CLAUDE.md`; fp only ensures that file includes `@FP_CLAUDE.md`. diff --git a/README.md b/README.md index 4d99b6e..7e34df5 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,23 @@ built from live [fp](https://fiberplane.com) issue state. One shared engine + te many projects. Read-only — it never mutates fp. The report is a fully offline, theme-aware HTML file: a **global search** (id / title / -spec) at the top, KPI signals, open-backlog composition, an expandable **Epics** tab, a -filterable/sortable **Issues** tab, and dependency signals. It is **responsive** — the -same single file lays out as cards on an iPhone-class screen — and lets you tap/right-click -an issue id to copy it. No external requests — safe to open anywhere or share. +spec) at the top, KPI signals, open-backlog composition, a tiled **Themes** tab, an +expandable **Epics** tab, a filterable/sortable **Issues** tab, and dependency signals. +It is **responsive** — the same single file lays out as cards on an iPhone-class screen — +and lets you tap/right-click an issue id to copy it. No external requests — safe to open +anywhere or share. + +The **Themes** tab is built from the project's own fp `labels` and `workflow` extensions. +fp's defaults are too thin to tile well, so a more elaborate taxonomy is what buys a good +result — see [the Themes page is only as good as your fp +extensions](#the-themes-page-is-only-as-good-as-your-fp-extensions). ## Install ```sh git clone https://github.com/markhm/fp-report.git cd fp-report -./install.sh # symlinks ~/bin/fp-report -> ./fp-report.sh +tools/install.sh # symlinks ~/bin/fp-report -> ./fp-report.sh ``` `~/bin` needs to be on your `$PATH`. Requires `fp` and `python3`. @@ -27,11 +33,16 @@ cd ~/git/some-fp-project fp-report --init # scaffold this project (or: --init --theme graphite) fp-report # generate the report and open it fp-report --no-open # generate only +fp-report --help # every flag, straight from the script ``` -`--init` writes `scripts/fp-report.conf` + `scripts/fp-report.status.json` + a theme -into the project (prefix auto-detected from `.fp`), and a `scripts/fp-report` symlink. -Edit the conf, then run `fp-report` from anywhere in the repo. +Working on the tool itself? `bash test/run.sh` runs the whole suite offline in a couple +of seconds — see [Development](#development). + +`--init` writes `scripts/fp-report.conf`, `scripts/fp-report.status.json`, +`scripts/fp-report.themes.json` and a theme into the project (prefix auto-detected from +`.fp`), plus a `scripts/fp-report` symlink. Edit the conf, then run `fp-report` from +anywhere in the repo. ## Per-project config @@ -45,6 +56,9 @@ shared here). `fp-report.conf` — sourced as bash — sets: | `APP_NAME` | application name in the browser `` + header kicker | | `REPORT_TITLE` | on-page heading | | `STATUS_FILE` | status registry (see below) | +| `THEMES_FILE` | theme taxonomy for the Themes tab (see below) | +| `FETCH_LABELS` | fetch each issue's labels (default `true`; see below) | +| `LABEL_CACHE` / `LABEL_JOBS` | label cache path / parallel fetches (defaults are fine) | | `THEME_FILE` | colour theme (see below) | | `LOGO_LIGHT` / `LOGO_DARK` | brand wordmarks (light / dark theme); omit → neutral default | | `OUTPUT_DIR` / `OUTPUT_FILE` | where the HTML lands (default `../reports/fp-report.html`) | @@ -53,16 +67,141 @@ Relative paths resolve against the conf's own directory. **Config discovery orde `-c/--config` → `$FP_REPORT_CONF` → next to an invoking `scripts/fp-report` symlink → `./scripts/fp-report.conf` walking up from `$PWD` → this repo's `defaults/`. +> **Looking for `.env.example`?** There isn't one, and there is nothing secret to put in +> it. [`defaults/fp-report.conf`](defaults/fp-report.conf) is this tool's annotated +> reference configuration — every supported key, commented, with its default. `fp-report +> --init` copies a trimmed version into your project; open the reference file for the +> keys it leaves out (deployment, `ASSETS_DIR`, label-cache tuning). The only environment +> variable the tool reads is `$FP_REPORT_CONF`, which just points at a config file. + ### Status registry (`fp-report.status.json`) -Mirror the project's `.fp/extensions/workflow.ts`. Each status has a `role`: -`open` (active work), `done` (terminal complete), `rejected` (terminal won't-do) — -a dependency is satisfied once `done` or `rejected`; any other role (e.g. `deferred`) -is neither. `color` is a semantic name +Mirror the project's `.fp/extensions/workflow.ts` — order here is display + sort order. +Each status has a `role`: `open` (active work), `done` (terminal complete), `rejected` +(terminal won't-do) — a dependency is satisfied once `done` or `rejected`; any other +role (e.g. `deferred`) is neither. `color` is a semantic name (`neutral`/`grey`/`blue`/`indigo`/`purple`/`teal`/`mint`/`green`/`amber`/`orange`/`red`) or a raw `#hex` / `var(--x)`. -### Themes (`fp-report.theme.css`) +This registry does more than colour badges: the **Themes** tiles draw one chip per +`open` status, so how finely you split "open" in `workflow.ts` decides how much a tile +can tell you. See [the Themes page is only as good as your fp +extensions](#the-themes-page-is-only-as-good-as-your-fp-extensions). + +### Theme taxonomy (`fp-report.themes.json`) + +Drives the **Themes** tab: a grid of tiles, one per theme. Each tile carries a +**priority distribution** — one row for critical / high / medium / low / unset — and each +row is a bar **segmented by status** (one segment per open status, plus done), so you read +both dimensions at once: how much work sits at each priority, and how far along it is. +Every bar is scaled against the largest row **across the whole grid**, so length is +comparable between tiles — a full bar means the same count wherever you see it. Hovering +a row gives the exact per-status breakdown. + +**hide done** in the tab bar is **on by default**: tiles open showing only backlog and +in-progress work. On a backlog that is three-quarters complete, done otherwise fills every +bar and all themes collapse to the same shape — and a theme whose only "critical" work +shipped months ago would open looking like it still has critical work. Untick it to see +the full history; the headline, legend, footer and bar scale all follow the toggle. Tiles are grouped by *facet* (shipped defaults: **Quality** (ISO/IEC +25010), **Area**, **Work type**) and clicking one jumps to the Issues tab filtered to +that theme. Mirror the project's `.fp/extensions/labels` vocabulary here so themes +converge on the labels you actually use. + +Tiles count **leaf work items**. An epic's status is a roll-up of its children, so +counting an in-progress epic *and* the sub-issue driving it double-counts the same work; +epics get their own `N epics (M open)` figure instead. + +Priority needs the same care: inside an epic, "critical" ranks a sub-issue against its +siblings — sequencing, not urgency — so it is not comparable with a top-level critical. +The rows show the full distribution, and each row's tooltip splits it into *top-level* +versus *scoped to an epic* so a headline number can't be read off epic-internal ranking. + +Every issue lands in **exactly one theme per facet**, so tiles always partition the +backlog. The theme is resolved in descending order of trust: + +1. **label** — an fp label whose value matches a theme `key` (ties broken by taxonomy order) +2. **inherited** — a sub-issue follows its epic's theme. This deliberately outranks its + own keywords: an initiative that fragments across five tiles because each sub-issue + matched a different word is exactly what stops the page helping you focus. +3. **inferred** — weighted keyword match: `strong`/`weak` regex fragments scored + 6/3 in the title and 2/1 in the description, best theme wins if it clears `threshold` + (settable per facet). This classifies epics and standalone issues. +4. **unthemed** — no signal; its own tile, and the cue to start labelling + +The page footnotes how many issues came from each source. **Labels always win**, so +labelling an issue is how you correct a tile. + +#### The Themes page is only as good as your fp extensions + +Tiles are built from two things fp holds per project, and **fp's out-of-the-box defaults +are not enough to tile well**: + +| what the tiles need | where it comes from | fp's default | +|---|---|---| +| which theme an issue belongs to | `.fp/extensions/labels` | **no label vocabulary at all** | +| the per-status chips, and what counts as open / done | `.fp/extensions/workflow.ts` | 6 statuses, only 3 of them `open` | + +With a stock fp project you get a Themes page where **every** tile is keyword-inferred +or unthemed, and the chips collapse to a single "Backlog" bar. It renders, but it can't +tell you much. Getting a good result means elaborating both extensions and then mirroring +them into this tool's two config files: + +``` +.fp/extensions/labels/index.ts ──mirror──▶ fp-report.themes.json (theme keys, facets, colours) +.fp/extensions/workflow.ts ──mirror──▶ fp-report.status.json (labels, colours, roles) +``` + +**Labels — build a faceted, enforced vocabulary.** A flat bag of free-text labels tiles +badly: synonyms split one theme across two tiles, and typos create tiles of one. What +works is a small controlled vocabulary in **orthogonal facets**, so an issue can carry +one label from each and the report can offer each facet as a separate grouping. The +worked example this tool's defaults are modelled on uses three: + +- **Area** — *where* in the system (component). `mc-portal`, `backend`, `infra`, … +- **Work type** — *what kind* of work. `bug`, `change`, `tech-debt`. +- **Quality** — *which quality attribute*, named per **ISO/IEC 25010** rather than + ad-hoc synonyms, so "resiliency" and "reliability" can't become two tiles. + +Enforce it in an `issue:creating` / `issue:updating` hook that rejects anything outside +the allow-list — a taxonomy that can rot via free-form strings will rot, and every +misspelling becomes a bogus tile. Each theme `key` in `fp-report.themes.json` must equal +the label value for that label to win over keyword inference. + +**Workflow — more open statuses means a more useful tile.** The chips on a tile are one +per `open` status, in registry order. With a single open status every tile shows one +number and you cannot see whether a theme is stalled in backlog or actually moving. +Distinguishing at least *backlog → selected → in progress* is what makes the chips +worth reading, and `deferred` / `rejected` keep parked work out of the open counts +without deleting it. Give each status a `role` (`open` / `done` / `rejected` / anything +else) — that is what the tiles, the progress bars, and dependency satisfaction all key off. + +**Keep the mirrors in sync.** These two JSON files are hand-maintained copies of the +extensions, so they drift silently — e.g. a status whose colour is `purple` in +`workflow.ts` but `indigo` in `fp-report.status.json` renders a chip that doesn't match +fp's own UI. Re-check them whenever you edit an extension. + +**Coverage is the lever.** Once the vocabulary is right, the tiles improve in proportion +to how many issues actually carry labels — nothing else moves the needle as much. At +~14% label coverage on a ~950-issue backlog, roughly a fifth of the theming came from +real labels and the rest from keywords and epic inheritance. The **Unthemed** tile and +the per-tile `N labelled` figure are there to show you exactly where that gap is; label +the epics first, since sub-issues inherit from them. + +### Labels (`FETCH_LABELS`) + +`fp issue list` omits `properties`, so labels are only available via one +`fp issue show` call per issue. The engine fetches them in parallel and caches the +result by `updatedAt` next to the report, re-fetching only issues that changed — a +cold run on a ~1000-issue backlog takes about a minute, later runs under a second. + +```sh +fp-report --no-labels # skip it entirely (themes then come from keywords only) +fp-report --refresh-labels # ignore the cache and re-fetch everything +``` + +The cache (`.fp-report.labels.json`) is a derived file — don't commit it. + +### Colour themes (`fp-report.theme.css`) A raw CSS file of custom properties for `:root` (light) and dark. Two ship in `defaults/`: @@ -75,10 +214,17 @@ contract. The JS status colour map references the tokens by name, so keep the na ## Deploy (publish to a static site) -`fp-report-deploy.sh` regenerates the report straight into a static site repo's served -directory, then commits and pushes — so a git-triggered host (e.g. CloudFlare Pages) -redeploys. Still one self-contained HTML file; no build step. The target is set per -project in `fp-report.conf`: +**The model:** the report is one self-contained HTML file, so "publishing" is just +committing that file into a repo a static host watches. `fp-report-deploy.sh` does the +whole loop in one command: + +1. locates the same `fp-report.conf` the engine uses (identical precedence), +2. regenerates the report **directly into** `DEPLOY_REPO/DEPLOY_SUBDIR/$OUTPUT_FILE`, +3. `git add -A` + commit in that repo (exits quietly if nothing changed), +4. pushes `DEPLOY_BRANCH` to `origin` — and the host redeploys on the push. + +There is **no build step and no separate deploy config**: the publishing target lives in +the project's `fp-report.conf` alongside everything else: | key | meaning | |-----|---------| @@ -88,24 +234,110 @@ project in `fp-report.conf`: | `DEPLOY_REMOTE` | origin to add/create on first run; blank → skip remote setup | ```sh -fp-report-deploy # generate + commit + push, using the located conf -fp-report-deploy --dry-run # generate only; show git status, don't commit/push +fp-report-deploy # generate + commit + push, using the located conf +fp-report-deploy --dry-run # generate only; show git status, don't commit/push +fp-report-deploy -c PATH/conf # use an explicit project config +fp-report-deploy -m "message" # custom commit message (default: timestamped) +fp-report-deploy --help # the same summary, from the script itself ``` -First run bootstraps the repo: `git init` on `DEPLOY_BRANCH`, adds origin, and (if `gh` -is available and the remote is missing) creates the GitHub repo before pushing. +**First run bootstraps the publishing repo:** `git init` on `DEPLOY_BRANCH`, adds +`DEPLOY_REMOTE` as origin, and — if the remote doesn't exist yet and `gh` is on `$PATH` — +creates it as a **private** GitHub repo before pushing. Leave `DEPLOY_REMOTE` blank to +commit locally and never push. + +**Credentials: there are none to configure.** This tool stores no tokens and reads no +`.env`. The push uses whatever git credentials you already have, and `gh` is only invoked +to create the remote on that first run. So the prerequisites are simply: `git` configured +with a usable identity, push rights on the target repo, and optionally `gh` for +repo creation. Point your host (CloudFlare Pages, Netlify, GitHub Pages) at +`DEPLOY_BRANCH` / `DEPLOY_SUBDIR` once, in its own dashboard — that part is not +automated. ## Packaging / offline -`./export.sh` bundles the engine, template, and `defaults/` into a self-contained -`dist/fp-report.zip` for sharing where `git clone` isn't handy. +`tools/export.sh` bundles the engine, the whole `template/` directory, and `defaults/` +into a self-contained `dist/fp-report.zip` for sharing where `git clone` isn't handy. The +unzipped bundle runs as-is — it assembles the template exactly like a checkout does. ## Development -`bash test/run.sh` runs the test suite — it renders fixtures through the engine -(`--issues-file`, no live fp needed) and asserts on the output: placeholder -replacement, prefix/title/theme/status injection, the open/blocked model, injection -safety, and `--init` scaffolding. CI runs it on every push (`.github/workflows/tests.yml`). +### Layout + +``` +fp-report.sh the engine — locates config, pulls fp state, renders +fp-report-deploy.sh publish the rendered report to a static-site repo +template/ the report, authored as parts and assembled at render time + fp-report.template.html the shell: page markup + include directives + styles/*.css base · tiles · components · mobile + app/*.js model · kpis · epics · table · themes · deps · ui +defaults/ reference config, status registry, theme taxonomy, themes, logos +tools/ install.sh, export.sh +test/ run.sh + fixtures + assertion helpers +``` + +### The template is many files, the report is one + +The output has to stay a **single self-contained HTML file** — that is the whole point: +you can email it, drop it in a bucket, or open it offline with no server. But an 880-line +monolith is miserable to work in, so the template is authored as small files and assembled +at render time. + +`template/fp-report.template.html` is the page shell; wherever content belongs it carries +a directive: + +```html +<!--#include styles/tiles.css --> +<!--#include app/themes.js --> +``` + +The engine inlines each one (relative to the template's own directory, nesting allowed, +with cycle and depth guards) before substituting `__FP_DATA__` and friends. Two rules +worth knowing: + +- **A directive must sit alone on its line.** Anything else can't be substituted, so the + engine aborts rather than shipping a page with a literal `<!--#include …-->` comment + where content should be. A missing file aborts too — silence here would mean publishing + a hole. +- **All of `app/` is concatenated into one `<script>`, in the listed order.** The parts + share a top-level scope and rely on hoisting across sections, so separate `<script>` + tags would change behaviour. Order matters: `model.js` first, then anything that + consumes it. + +Adding a section is: drop a file in `styles/` or `app/`, add one include line in the +shell. No build step, no bundler, no dependencies. + +### Tests + +```sh +bash test/run.sh # the whole suite; exits non-zero if anything fails +``` + +**No setup, no network, no live fp.** Every case renders `test/fixture.issues.json` +through the real engine with `--issues-file` and asserts on the produced HTML. It needs +`bash` + `python3`; `node` is optional and only unlocks the JS-level checks (they skip +silently without it). CI runs the same one line on every push +(`.github/workflows/tests.yml`, Node 20). + +What it covers: placeholder replacement and injection safety (a hostile issue title must +not break out of the embedded JSON), prefix/title/theme/status/taxonomy injection and +their fallbacks, the open/blocked model, `--init` scaffolding, the pure path helpers, and +the Themes model — resolution order (label > inherited > keyword > unthemed), the +partition invariants, epic/leaf separation, and epic-scoped priority. + +**Adding a test.** `run.sh` is a flat script of one-line assertions; `t "<description>" +<command…>` passes if the command exits 0. Three helpers do the heavy lifting: + +| helper | use it for | +|---|---| +| `render <out> <issues> [conf]` | run the engine offline; `mkconf` writes a throwaway config | +| `python3 test/assert_model.py <html> <metric> <n>` | issue/open/blocked counts parsed out of the embedded JSON | +| `th <html> <check> [args…]` | the Themes model — see `test/assert_themes.js` for the checks | + +`test/assert_themes.js` evaluates the report's **real** injected script against a minimal +DOM stub, so classifier assertions exercise the shipped code rather than a reimplementation +of its rules — a rule cannot pass its test and still be wrong in the browser. Fixtures stay +deliberately tiny (7 issues, a 3-theme taxonomy) so every expected number is checkable by hand. ## Changelog diff --git a/defaults/fp-report.conf b/defaults/fp-report.conf index 21f05d5..a434e70 100644 --- a/defaults/fp-report.conf +++ b/defaults/fp-report.conf @@ -19,6 +19,19 @@ REPORT_TITLE="Roadmap & prioritisation" # .fp/extensions/workflow.ts. Relative → resolved against this conf's dir. STATUS_FILE="fp-report.status.json" +# Theme taxonomy for the Themes tiles page — the facets (Quality / Area / Work type), +# their colours, and the keyword rules used to infer a theme when an issue carries no +# fp label. Mirror the project's .fp/extensions/labels vocabulary so inferred themes +# converge on real labels. Relative → resolved against this conf's dir. +THEMES_FILE="fp-report.themes.json" + +# Labels. 'fp issue list' omits properties, so each issue's labels need a separate +# 'fp issue show' call. Results are cached by updatedAt, so only changed issues are +# re-fetched. Set FETCH_LABELS="false" to skip it (themes then come from keywords only). +FETCH_LABELS="true" +# LABEL_CACHE="" # default: OUTPUT_DIR/.fp-report.labels.json — a derived file, don't commit it +# LABEL_JOBS="12" # parallel 'fp issue show' calls + # Theme (colour palette + background, light & dark). A raw CSS file of custom properties # for :root (light) and dark. Copy the tool's default and edit the hex values to rebrand. # Relative → resolved against this conf's dir. Missing → the tool's neutral default. @@ -35,3 +48,15 @@ LOGO_DARK="fp-report.logo-dark.svg" OUTPUT_DIR="" OUTPUT_FILE="fp-report.html" # ASSETS_DIR="" # override only if logos live somewhere other than OUTPUT_DIR + +# ---- Deployment (fp-report-deploy.sh) ---- +# Publishing target for `fp-report-deploy`. It regenerates the report straight into a +# static-site repo's served directory, commits, and pushes — a git-triggered host +# (CloudFlare Pages, Netlify, GitHub Pages) then redeploys. There is no build step and +# NO SECRETS: pushing uses your existing git credentials, and `gh` (if installed) is only +# used to create the remote repo on first run. Leave these unset if you don't publish. +# +# DEPLOY_REPO="../my-report-site" # local checkout of the publishing repo (abs, or rel. to this conf) +# DEPLOY_SUBDIR="src" # served directory inside it — the report lands here +# DEPLOY_BRANCH="master" # branch the host deploys from +# DEPLOY_REMOTE="" # origin to add/create on first run; blank → commit locally, don't push diff --git a/defaults/fp-report.themes.json b/defaults/fp-report.themes.json new file mode 100644 index 0000000..7e17a07 --- /dev/null +++ b/defaults/fp-report.themes.json @@ -0,0 +1,428 @@ +{ + "_comment": "Theme taxonomy for the Themes tiles page. This file is a MIRROR of the project's .fp/extensions/labels vocabulary: every theme 'key' must equal a label value, or that label can never win over keyword inference. fp ships no label vocabulary by default, so on a stock project every tile is keyword-inferred — see the README section 'The Themes page is only as good as your fp extensions'. Each issue's theme is resolved PER FACET in descending order of trust: (1) an explicit fp label matching a theme key (ties broken by the order themes appear here), (2) inherited from its epic — this outranks the issue's own keywords on purpose, so one initiative stays in one tile instead of fragmenting, (3) a weighted keyword match on title + description, which is how epics and standalone issues get classified, (4) 'unthemed'. Tiles count LEAF work items and partition them exactly; epics are reported separately, since an epic's status is a roll-up of its children. 'color' takes the same semantic names as the status registry (neutral/grey/blue/indigo/purple/teal/mint/green/amber/orange/red) or a raw #hex / var(--x). 'strong'/'weak' are case-insensitive regex fragments, OR-ed together; a title hit scores 6 (strong) / 3 (weak), a description hit 2 / 1. Best-scoring theme wins if it clears 'threshold' — raise it for fewer, more confident themes; lower it to leave less unthemed. A facet may override 'threshold' for its own themes.", + "threshold": 5, + "descriptionChars": 1500, + "facets": [ + { + "key": "quality", + "label": "Quality", + "hint": "ISO/IEC 25010 product-quality characteristics — what kind of goodness the work buys", + "themes": [ + { + "key": "security", + "label": "Security", + "color": "red", + "strong": [ + "securit(y|ies)", + "authenticat", + "authoris", + "authoriz", + "vulnerab", + "\\bxss\\b", + "\\bcsrf\\b", + "\\brbac\\b", + "tenant isolation", + "credential", + "secret[s ]" + ], + "weak": [ + "\\bauth\\b", + "permission", + "\\btoken", + "login", + "access control", + "gitleaks", + "trivy" + ] + }, + { + "key": "reliability", + "label": "Reliability", + "color": "orange", + "strong": [ + "reliabilit", + "resilien", + "failover", + "availabilit", + "recover", + "fault.toleran", + "incident", + "outage", + "\\bsla\\b" + ], + "weak": [ + "retry", + "retries", + "timeout", + "crash", + "robust", + "stabilit", + "failed task", + "\\b5\\d\\d\\b" + ] + }, + { + "key": "performance-efficiency", + "label": "Performance", + "color": "amber", + "strong": [ + "performance", + "latency", + "throughput", + "optimi[sz]", + "\\bslow\\b", + "n\\+1", + "index(es|ing)\\b", + "\\boom\\b" + ], + "weak": [ + "cache", + "caching", + "speed", + "memory", + "faster", + "batch" + ] + }, + { + "key": "maintainability", + "label": "Maintainability", + "color": "purple", + "strong": [ + "maintainab", + "refactor", + "duplicat", + "tech(nical)?.debt", + "unit size", + "complexity", + "clean ?up", + "dead code" + ], + "weak": [ + "readab", + "modular", + "test coverage", + "\\blint", + "retire", + "legacy", + "consolidat", + "\\bdedup" + ] + }, + { + "key": "usability", + "label": "Usability", + "color": "teal", + "strong": [ + "usabilit", + "user experience", + "\\bux\\b", + "onboard", + "accessib", + "responsive", + "\\bmobile\\b" + ], + "weak": [ + "\\bui\\b", + "layout", + "navigat", + "breadcrumb", + "\\bpage\\b", + "tooltip", + "wording" + ] + }, + { + "key": "observability", + "label": "Observability", + "color": "indigo", + "strong": [ + "observabilit", + "telemetry", + "monitoring", + "tracing", + "alerting", + "diagnos", + "grafana" + ], + "weak": [ + "logging", + "metrics", + "\\blog\\b", + "\\btrace\\b", + "alert", + "audit trail" + ] + }, + { + "key": "data-integrity", + "label": "Data integrity", + "color": "mint", + "strong": [ + "data.integrit", + "orphan", + "denormali[sz]", + "corrupt", + "\\bdrift\\b", + "snapshot_data", + "consistency", + "contract test" + ], + "weak": [ + "\\bschema\\b", + "migration", + "integrity", + "cascade", + "validation", + "backfill" + ] + }, + { + "key": "compatibility", + "label": "Compatibility", + "color": "blue", + "strong": [ + "compatibilit", + "interop", + "backward.compat", + "\\bupgrade\\b", + "version bump", + "version register" + ], + "weak": [ + "api contract", + "openapi", + "protocol", + "version(ing)?\\b" + ] + }, + { + "key": "portability", + "label": "Portability", + "color": "grey", + "strong": [ + "portabilit", + "on-?prem", + "self-host", + "cross-platform", + "air.?gap", + "deployment model" + ], + "weak": [ + "\\bdocker\\b", + "kubernetes", + "\\bk8s\\b", + "platform-agnostic" + ] + }, + { + "key": "functional-suitability", + "label": "Functional suitability", + "color": "green", + "strong": [ + "scoring model", + "quality model", + "recalibrat", + "benchmark", + "feature gat", + "license gat" + ], + "weak": [ + "\\bscore", + "portfolio", + "\\bexport\\b", + "\\bcsv\\b", + "analysis result" + ] + } + ] + }, + { + "key": "area", + "label": "Area", + "hint": "Where in the system the work lands — the component facet", + "themes": [ + { + "key": "mc-portal", + "label": "Portal", + "color": "blue", + "strong": [ + "mc-portal" + ], + "weak": [ + "\\bportal\\b", + "\\badmin\\b", + "superuser" + ] + }, + { + "key": "mc-worker", + "label": "Worker & analyzer", + "color": "purple", + "strong": [ + "mc-worker", + "mc-analyzer", + "mc-quality-engine", + "sokrates" + ], + "weak": [ + "\\bworker\\b", + "analyzer", + "\\btask queue\\b" + ] + }, + { + "key": "frontend", + "label": "Frontend", + "color": "teal", + "strong": [ + "frontend", + "\\breact\\b", + "\\bcss\\b", + "\\bd3\\b", + "tailwind" + ], + "weak": [ + "\\bui\\b", + "\\bpage\\b", + "chart", + "dashboard", + "layout", + "component" + ] + }, + { + "key": "backend", + "label": "Backend", + "color": "indigo", + "strong": [ + "backend", + "prisma", + "postgres", + "\\bjdbc\\b", + "\\bspring\\b", + "\\bneon\\b" + ], + "weak": [ + "\\bapi\\b", + "endpoint", + "database", + "\\bsql\\b", + "\\bjava\\b" + ] + }, + { + "key": "common", + "label": "Shared & tooling", + "color": "mint", + "strong": [ + "mc-parent", + "monorepo", + "shared package", + "mc-algorithms", + "mc-license-client" + ], + "weak": [ + "\\bshared\\b", + "\\bcommon\\b", + "library", + "\\bscripts?\\b" + ] + }, + { + "key": "infra", + "label": "Infra & delivery", + "color": "orange", + "strong": [ + "\\binfra\\b", + "cloudflare", + "\\bfly\\.io\\b", + "terraform", + "github actions", + "release train", + "promotion" + ], + "weak": [ + "deploy", + "pipeline", + "\\bci\\b", + "\\bcd\\b", + "image tag", + "\\buat\\b", + "\\bprd\\b" + ] + } + ] + }, + { + "key": "work", + "label": "Work type", + "hint": "What kind of work it is — defect, change, or debt", + "threshold": 3, + "themes": [ + { + "key": "bug", + "label": "Bug", + "color": "red", + "strong": [ + "\\bbug\\b", + "regression", + "\\bbroken\\b", + "does ?n'?t work", + "incorrect", + "silently fail" + ], + "weak": [ + "\\bfails?\\b", + "failing", + "\\berror\\b", + "\\bcrash", + "\\bwrong\\b", + "\\bfix\\b" + ] + }, + { + "key": "tech-debt", + "label": "Tech debt", + "color": "amber", + "strong": [ + "tech(nical)?.debt", + "refactor", + "clean ?up", + "duplicat", + "\\bretire\\b", + "deprecat", + "dead code" + ], + "weak": [ + "legacy", + "consolidat", + "\\bunify\\b", + "\\bmigrate\\b", + "vestigial" + ] + }, + { + "key": "change", + "label": "Change", + "color": "blue", + "strong": [ + "\\bepic\\b", + "\\bintroduce\\b", + "implement", + "\\bnew page\\b", + "\\bnew\\b .*\\bfeature\\b" + ], + "weak": [ + "\\badd\\b", + "\\bsupport\\b", + "improve", + "enhance", + "extend", + "\\benable\\b" + ] + } + ] + } + ] +} diff --git a/fp-report.sh b/fp-report.sh index 0c6b641..139df7a 100755 --- a/fp-report.sh +++ b/fp-report.sh @@ -9,6 +9,8 @@ # fp-report -c PATH/conf # use an explicit project config # fp-report -o PATH.html # write to a custom output path # fp-report --issues-file J # render JSON J instead of calling fp (offline/CI) +# fp-report --no-labels # skip the per-issue label fetch (faster, no themes from labels) +# fp-report --refresh-labels # ignore the label cache and re-fetch every issue # # The engine + HTML template live once (this folder). Each project supplies its own # fp-report.conf + fp-report.status.json + logos. The config is located, in order: @@ -36,7 +38,7 @@ while [ -h "$SOURCE" ]; do done SELF="$SOURCE" TOOL_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" -TEMPLATE="$TOOL_DIR/fp-report.template.html" +TEMPLATE="$TOOL_DIR/template/fp-report.template.html" # a directory of parts, assembled at render time DEFAULTS_DIR="$TOOL_DIR/defaults" # ---- pure helpers (safe to source and unit-test) ---- @@ -88,6 +90,7 @@ cmd_init() { theme_src="$DEFAULTS_DIR/fp-report.theme.css"; theme_name="fp-report.theme.css" fi cp "$DEFAULTS_DIR/fp-report.status.json" "$scripts_dir/fp-report.status.json" + cp "$DEFAULTS_DIR/fp-report.themes.json" "$scripts_dir/fp-report.themes.json" cp "$theme_src" "$scripts_dir/$theme_name" cat > "$conf" <<EOF # fp-report.conf — generated by 'fp-report --init'. Sourced as bash; relative paths @@ -97,6 +100,7 @@ PROJECT_NAME="$pname" APP_NAME="$pname" REPORT_TITLE="Roadmap & prioritisation" STATUS_FILE="fp-report.status.json" +THEMES_FILE="fp-report.themes.json" THEME_FILE="$theme_name" OUTPUT_FILE="fp-report.html" EOF @@ -112,16 +116,20 @@ EOF main() { # ---- args ---- DO_OPEN=true; CONF_ARG=""; OUT_OVERRIDE=""; ISSUES_FILE=""; INIT=false; THEME_ARG="" + LABELS_ARG=""; REFRESH_LABELS=false while [ $# -gt 0 ]; do case "$1" in --no-open) DO_OPEN=false ;; --open) DO_OPEN=true ;; # default; kept for back-compat --init) INIT=true ;; --theme) shift; THEME_ARG="$1" ;; + --no-labels) LABELS_ARG=false ;; # skip the per-issue property fetch + --labels) LABELS_ARG=true ;; + --refresh-labels) REFRESH_LABELS=true ;; # ignore the cache, re-fetch everything -c|--config) shift; CONF_ARG="$1" ;; -o|--out) shift; OUT_OVERRIDE="$1" ;; --issues-file) shift; ISSUES_FILE="$1" ;; # render this JSON instead of calling fp (offline/CI) - -h|--help) sed -n '2,20p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,22p' "$SELF" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown arg: $1" >&2; exit 2 ;; esac shift @@ -144,12 +152,16 @@ main() { APP_NAME="" # application name in the browser <title>; empty → PROJECT_NAME REPORT_TITLE="Roadmap & prioritisation" STATUS_FILE="fp-report.status.json" + THEMES_FILE="fp-report.themes.json" # theme taxonomy for the Themes tiles page THEME_FILE="fp-report.theme.css" LOGO_LIGHT="fp-report.logo-light.svg" LOGO_DARK="fp-report.logo-dark.svg" OUTPUT_FILE="fp-report.html" OUTPUT_DIR="" # empty → CONF_DIR/../reports ASSETS_DIR="" # empty → OUTPUT_DIR (logos live with the report) + FETCH_LABELS=true # enrich the backlog with each issue's labels + LABEL_CACHE="" # empty → OUTPUT_DIR/.fp-report.labels.json + LABEL_JOBS=12 # parallel 'fp issue show' calls # shellcheck source=/dev/null . "$CONF" [ -n "$APP_NAME" ] || APP_NAME="$PROJECT_NAME" # default the app name to the project name @@ -162,6 +174,8 @@ main() { STATUS_PATH="$(resolve_under "$STATUS_FILE" "$CONF_DIR")" [ -f "$STATUS_PATH" ] || STATUS_PATH="$DEFAULTS_DIR/fp-report.status.json" # fall back to defaults + THEMES_PATH="$(resolve_under "$THEMES_FILE" "$CONF_DIR")" + [ -f "$THEMES_PATH" ] || THEMES_PATH="$DEFAULTS_DIR/fp-report.themes.json" # fall back to defaults THEME_PATH="$(resolve_under "$THEME_FILE" "$CONF_DIR")" [ -f "$THEME_PATH" ] || THEME_PATH="$DEFAULTS_DIR/fp-report.theme.css" # fall back to defaults LOGO_LIGHT_PATH="$(resolve_under "$LOGO_LIGHT" "$ASSETS_DIR")" @@ -174,6 +188,7 @@ main() { [ -n "$ISSUES_FILE" ] || command -v fp >/dev/null 2>&1 || { echo "Error: fp CLI not found on PATH" >&2; exit 1; } [ -f "$TEMPLATE" ] || { echo "Error: template missing: $TEMPLATE" >&2; exit 1; } [ -f "$STATUS_PATH" ] || { echo "Error: status registry missing: $STATUS_PATH" >&2; exit 1; } + [ -f "$THEMES_PATH" ] || { echo "Error: theme taxonomy missing: $THEMES_PATH" >&2; exit 1; } [ -f "$THEME_PATH" ] || { echo "Error: theme missing: $THEME_PATH" >&2; exit 1; } mkdir -p "$(dirname "$OUT")" @@ -188,6 +203,67 @@ main() { ( cd "$CONF_DIR" && fp issue list --limit 5000 --format json ) > "$TMP_JSON" fi + # ---- enrich with per-issue properties (labels) ---- + # 'fp issue list' omits `properties` entirely; only 'fp issue show' returns it. So the + # labels the Themes page prefers over inference need one call per issue (~0.2s each). + # We cache them keyed on the issue's updatedAt, so only issues that actually changed + # since the last report are re-fetched: first run costs ~1s/15 issues, later runs ~0. + [ -z "$LABELS_ARG" ] || FETCH_LABELS="$LABELS_ARG" + [ -n "$LABEL_CACHE" ] || LABEL_CACHE="$OUTPUT_DIR/.fp-report.labels.json" + LABEL_CACHE="$(resolve_under "$LABEL_CACHE" "$CONF_DIR")" + if [ -z "$ISSUES_FILE" ] && [ "$FETCH_LABELS" = true ]; then + JSON="$TMP_JSON" CACHE="$LABEL_CACHE" JOBS="$LABEL_JOBS" CWD="$CONF_DIR" \ + REFRESH="$REFRESH_LABELS" python3 - <<'PY' +import json, os, subprocess, sys +from concurrent.futures import ThreadPoolExecutor + +path, cache_path = os.environ["JSON"], os.environ["CACHE"] +jobs, cwd = int(os.environ["JOBS"] or 12), os.environ["CWD"] +doc = json.load(open(path, encoding="utf-8")) +issues = doc.get("issues", doc) if isinstance(doc, dict) else doc + +cache = {} +if os.environ.get("REFRESH") != "true": + try: + cache = json.load(open(cache_path, encoding="utf-8")).get("issues", {}) + except Exception: + cache = {} # missing/corrupt cache is not an error — just refetch + +# stale = never seen, or edited since we cached it +stale = [i for i in issues + if cache.get(i["id"], {}).get("updatedAt") != i.get("updatedAt")] + +def fetch(i): + try: + r = subprocess.run(["fp", "issue", "show", i["shortId"], "--format", "json"], + capture_output=True, text=True, timeout=60, cwd=cwd) + return i["id"], json.loads(r.stdout).get("properties") or {} + except Exception: + return i["id"], None # leave this issue unlabelled rather than fail the report + +if stale: + print(f" fetching labels for {len(stale)} issue(s) " + f"({len(issues)-len(stale)} cached)…", file=sys.stderr) + with ThreadPoolExecutor(max_workers=jobs) as ex: + for iid, props in ex.map(fetch, stale): + if props is None: continue + cache[iid] = {"updatedAt": next(i["updatedAt"] for i in stale if i["id"] == iid), + "properties": props} + +live = {i["id"] for i in issues} +cache = {k: v for k, v in cache.items() if k in live} # drop deleted issues +for i in issues: + i["properties"] = cache.get(i["id"], {}).get("properties", {}) + +json.dump(doc, open(path, "w", encoding="utf-8")) +try: + os.makedirs(os.path.dirname(cache_path), exist_ok=True) + json.dump({"issues": cache}, open(cache_path, "w", encoding="utf-8")) +except OSError as e: + print(f" (label cache not written: {e})", file=sys.stderr) +PY + fi + GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Inject data + settings + logos into the template. Python for robust replacement and @@ -196,19 +272,51 @@ main() { TEMPLATE="$TEMPLATE" OUT="$OUT" JSON="$TMP_JSON" GENERATED_AT="$GENERATED_AT" \ FP_PREFIX="$FP_PREFIX" PROJECT_NAME="$PROJECT_NAME" APP_NAME="$APP_NAME" REPORT_TITLE="$REPORT_TITLE" \ LOGO_LIGHT="$LOGO_LIGHT_PATH" LOGO_DARK="$LOGO_DARK_PATH" STATUS_PATH="$STATUS_PATH" \ - THEME_PATH="$THEME_PATH" python3 - <<'PY' -import os, base64, mimetypes, json + THEMES_PATH="$THEMES_PATH" THEME_PATH="$THEME_PATH" python3 - <<'PY' +import os, re, base64, mimetypes, json def datauri(path): mime = mimetypes.guess_type(path)[0] or "image/png" b = open(path, "rb").read() return f"data:{mime};base64," + base64.b64encode(b).decode("ascii") -tpl = open(os.environ["TEMPLATE"], encoding="utf-8").read() + +# The template is authored as a directory of small files (styles/*.css, app/*.js) and +# assembled here into the single self-contained page. `<!--#include path -->` inlines a +# file relative to the template's own dir. The app parts are concatenated into ONE +# <script>, deliberately: they share a top-level scope and rely on hoisting across +# sections, so separate <script> tags would change behaviour. +INCLUDE = re.compile(r'^[ \t]*<!--#include\s+(\S+)\s*-->[ \t]*$', re.M) +def assemble(path, depth=0, seen=None): + if depth > 8: + raise SystemExit(f"fp-report: include nesting too deep at {path}") + seen = seen or [] + base = os.path.dirname(os.path.abspath(path)) + def sub(m): + inc = os.path.join(base, m.group(1)) + if inc in seen: + raise SystemExit("fp-report: circular include: " + " -> ".join(seen + [inc])) + if not os.path.isfile(inc): + raise SystemExit(f"fp-report: missing include {m.group(1)} (from {path})") + body = assemble(inc, depth + 1, seen + [inc]) + return body[:-1] if body.endswith("\n") else body + return INCLUDE.sub(sub, open(path, encoding="utf-8").read()) + +tpl = assemble(os.environ["TEMPLATE"]) +# A directive only matches on its own line. Anything left saying "#include" is therefore +# malformed (indented oddly, inline, misspelled) and would otherwise ship as a literal +# HTML comment with its content silently missing — fail instead of publishing a hole. +leftover = [ln.strip() for ln in tpl.split("\n") if "#include" in ln] +if leftover: + raise SystemExit("fp-report: unresolved include directive (must be alone on its line): " + + leftover[0]) data = open(os.environ["JSON"], encoding="utf-8").read().strip().replace("<", "\\u003c") # Re-serialise the status registry (validates it's JSON) and inline it as a JS literal. status = json.dumps(json.load(open(os.environ["STATUS_PATH"], encoding="utf-8"))).replace("<", "\\u003c") +# Same for the theme taxonomy (keyword regexes + colours for the Themes tiles page). +themes = json.dumps(json.load(open(os.environ["THEMES_PATH"], encoding="utf-8"))).replace("<", "\\u003c") theme = open(os.environ["THEME_PATH"], encoding="utf-8").read().rstrip("\n") # raw CSS custom properties html = (tpl.replace("__FP_DATA__", data) .replace("__STATUS_CONFIG__", status) + .replace("__THEMES_CONFIG__", themes) .replace("__THEME_CSS__", theme) .replace("__GENERATED_AT__", os.environ["GENERATED_AT"]) .replace("__ID_PREFIX__", os.environ["FP_PREFIX"]) diff --git a/fp-report.template.html b/fp-report.template.html deleted file mode 100644 index 4ace34d..0000000 --- a/fp-report.template.html +++ /dev/null @@ -1,643 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>__REPORT_TITLE__ — __APP_NAME__ - - - -
-
-
- - -
-
__APP_NAME__
-

__REPORT_TITLE__

-
-
-
- -
- - - -
-

Signals

-
- -

Open backlog composition

-
-
By priority
-
By status
-
- - - -
-

Epic roadmap — parents with children plus open issues titled “Epic”, by urgency · click a row to expand

-
-
- - -
- -
-
-
- - - - - diff --git a/template/app/deps.js b/template/app/deps.js new file mode 100644 index 0000000..fdaf804 --- /dev/null +++ b/template/app/deps.js @@ -0,0 +1,11 @@ +// ---- dependency signals ---- +const blockers = ISSUES.filter(i=>i._open && i._blockingOpen>0).sort((a,b)=>b._blockingOpen-a._blockingOpen).slice(0,12); +document.getElementById("blocking").innerHTML = blockers.length ? blockers.map(i=>` +
  • ${i._blockingOpen}${prioBadge(i.priority)} ${IDP}${i.shortId} ${esc(i.title)}
  • `).join("") + : `
  • Nothing is blocking open work.
  • `; +const blocked = ISSUES.filter(i=>i._blocked).sort((a,b)=>PRIO_ORDER[prioKey(a.priority)]-PRIO_ORDER[prioKey(b.priority)]).slice(0,12); +document.getElementById("blockedList").innerHTML = blocked.length ? blocked.map(i=>` +
  • ${i._unmet.length}${prioBadge(i.priority)} ${IDP}${i.shortId} ${esc(i.title)} + waits on ${i._unmet.map(d=>IDP+d.shortId).join(", ")}
  • `).join("") + : `
  • No open issue is blocked.
  • `; + diff --git a/template/app/epics.js b/template/app/epics.js new file mode 100644 index 0000000..a8d7407 --- /dev/null +++ b/template/app/epics.js @@ -0,0 +1,55 @@ +// ---- epic roadmap ---- +// An "epic" = any issue with children, OR any issue whose title contains the whole +// word "Epic" (e.g. "Epic: …" containers not yet broken into sub-issues). +const isEpicTitle = i => /\bepic\b/i.test(i.title||""); +const epics = ISSUES.filter(i=>kids.has(i.id) || isEpicTitle(i)).map(e=>{ + const ch = kids.get(e.id) || []; + const done = ch.filter(c=>DONE.has(c.status)).length; + const openc = ch.filter(c=>c._open).length; + const other = ch.length-done-openc; + const hp = prioKey(e.priority); // the epic's OWN priority — child priorities are scoped to the epic, not global + return {e, total:ch.length, done, openc, other, hp}; +}) // active roadmap: epics with open children, +.filter(x=> x.openc>0 || (isEpicTitle(x.e) && x.e._open)) // plus still-open "Epic"-titled issues (incl. childless) +.sort((a,b)=> (PRIO_ORDER[a.hp]-PRIO_ORDER[b.hp]) || (b.openc-a.openc) || (b.total-a.total)); +document.getElementById("tabEpicsN").textContent = epics.length; +function childRow(c){ + return `
    + ${IDP}${c.shortId} + ${esc(c.title)} + ${prioBadge(c.priority)} + ${statusBadge(c.status)} + ${c._stale!=null?c._stale+"d":"—"} +
    `; +} +document.getElementById("epics").innerHTML = epics.length ? epics.map((x,idx)=>{ + const t=x.total, dpct=t?x.done/t*100:0, opct=t?x.openc/t*100:0, rpct=t?x.other/t*100:0; + const pm = PRIO_META[x.hp]; + const ch = (kids.get(x.e.id)||[]).slice().sort((a,b)=> + ((a._open?0:1)-(b._open?0:1)) || + (PRIO_ORDER[prioKey(a.priority)]-PRIO_ORDER[prioKey(b.priority)]) || + (a.statusb.status?1:0)); + const sLabel = (STATUS_META[x.e.status]||{label:x.e.status}).label; + const ecLine = t + ? `${IDP}${x.e.shortId} · ${x.done}/${x.total} done · ${x.openc} open${x.other?` · ${x.other} other`:""}` + : `${IDP}${x.e.shortId} · ${sLabel} · no sub-issues yet`; + return `
    +
    ▸ ${esc(x.e.title)}
    +
    ${ecLine}
    +
    + + +
    +
    ${pm.icon} ${pm.label}
    +
    + `; +}).join("") : `
    No epics to show.
    `; +document.getElementById("epics").addEventListener("click", ev=>{ + const row = ev.target.closest(".epic"); if(!row) return; + const kidsEl = row.nextElementSibling; + if(!kidsEl || !kidsEl.classList.contains("epic-children")) return; + const willOpen = kidsEl.hasAttribute("hidden"); + kidsEl.toggleAttribute("hidden", !willOpen); + row.classList.toggle("open", willOpen); +}); + diff --git a/template/app/kpis.js b/template/app/kpis.js new file mode 100644 index 0000000..49f6637 --- /dev/null +++ b/template/app/kpis.js @@ -0,0 +1,39 @@ +// ---- meta ---- +document.getElementById("meta").textContent = + `${ISSUES.length} issues · ${openIssues.length} open · generated ${new Date(NOW).toLocaleString()}`; + +// ---- KPIs ---- +const wip = ISSUES.filter(i=>i.status==="in-progress"); +const kpi = [ + {v:openIssues.length, l:"Open (active)"}, + {v:wip.length, l:"WIP — in progress", accent:wip.length>8?"var(--serious)":"var(--good)", + note:wip.length>8?"above a healthy WIP limit":"within WIP limit"}, + {v:openIssues.filter(i=>i.priority==="critical").length, l:"Critical open", accent:"var(--critical)", icon:"⏫"}, + {v:openIssues.filter(i=>i.priority==="high").length, l:"High open", accent:"var(--serious)"}, + {v:ISSUES.filter(i=>i._blocked).length, l:"Blocked (unmet deps)", accent:"var(--warning)"}, + {v:ISSUES.filter(i=>i._isStale).length, l:"Stale >14d", accent:"var(--warning)"}, + {v:openIssues.filter(i=>i._unprio).length, l:"Unprioritised open", accent:"var(--baseline)"}, + {v:ISSUES.filter(i=>i._open&&i._badDesc).length, l:"Hygiene: bad descriptions", accent:"var(--serious)"}, +]; +document.getElementById("kpis").innerHTML = kpi.map(k=>` +
    +
    ${k.v}
    ${k.icon?k.icon+" ":""}${esc(k.l)}
    + ${k.note?`
    ${esc(k.note)}
    `:""}
    `).join(""); + +// ---- distributions (open) ---- +function distBars(el, groups, colorFn){ + const max = Math.max(1, ...groups.map(g=>g.n)); + el.innerHTML = groups.map(g=>` +
    + ${esc(g.label)} + + ${g.n} +
    `).join(""); +} +distBars(document.getElementById("distPrio"), + ["critical","high","medium","low","unset"].map(p=>({label:PRIO_META[p].label, + color:PRIO_META[p].color, n:openIssues.filter(i=>prioKey(i.priority)===p).length}))); +distBars(document.getElementById("distStatus"), + OPEN_ORDER.map(s=>({label:STATUS_META[s].label, + color:STATUS_META[s].color, n:openIssues.filter(i=>i.status===s).length}))); + diff --git a/template/app/model.js b/template/app/model.js new file mode 100644 index 0000000..c50ed15 --- /dev/null +++ b/template/app/model.js @@ -0,0 +1,63 @@ +"use strict"; +const GENERATED_AT = "__GENERATED_AT__"; +const RAW = JSON.parse(document.getElementById("fp-data").textContent); +const ISSUES = (RAW.issues || RAW || []); +const NOW = new Date(GENERATED_AT).getTime() || Date.now(); +const DAY = 86400000; +const IDP = "__ID_PREFIX__" + "-"; // fp short-id prefix, e.g. PMDS- (injected from fp-report.conf) + +// ---- theme taxonomy (configurable — injected from fp-report.themes.json) ---- +const THEMES_CONFIG = __THEMES_CONFIG__; + +// ---- status model (configurable — injected from fp-report.status.json, mirrors workflow.ts) ---- +const STATUS_CONFIG = __STATUS_CONFIG__; +const STATUS_LIST = STATUS_CONFIG.statuses || []; +// Semantic colour names → theme CSS vars (light/dark aware). A raw #hex or var(...) passes through. +const COLOR_VARS = {neutral:"--muted", grey:"--ink-2", gray:"--ink-2", blue:"--blue", gold:"--blue", + teal:"--teal", purple:"--purple", indigo:"--aqua", mint:"--mint", green:"--good", success:"--good", + orange:"--serious", amber:"--warning", red:"--critical", critical:"--critical"}; +const statusColor = c => !c ? "var(--muted)" : (/^(var\(|#)/.test(c) ? c : `var(${COLOR_VARS[c]||"--muted"})`); +// role: "open" = active work; "done" = terminal complete; "rejected" = terminal, won't-do. +// A dependency is "satisfied" (MET) once it is done OR rejected. +const OPEN = new Set(STATUS_LIST.filter(s=>s.role==="open").map(s=>s.key)); +const DONE = new Set(STATUS_LIST.filter(s=>s.role==="done").map(s=>s.key)); +const MET = new Set(STATUS_LIST.filter(s=>s.role==="done"||s.role==="rejected").map(s=>s.key)); +const OPEN_ORDER = STATUS_LIST.filter(s=>s.role==="open").map(s=>s.key); // registry order, for dist + filter +const PRIO_ORDER = {critical:0, high:1, medium:2, low:3, "":4, null:4, undefined:4}; +const PRIO_META = { + critical:{label:"Critical", color:"var(--critical)", icon:"⏫"}, + high: {label:"High", color:"var(--serious)", icon:"🔼"}, + medium: {label:"Medium", color:"var(--warning)", icon:"◆"}, + low: {label:"Low", color:"var(--muted)", icon:"▽"}, + unset: {label:"Unset", color:"var(--baseline)", icon:"—"}, +}; +// STATUS_META (label + theme-aware colour) built from the configurable registry above. +const STATUS_META = Object.fromEntries( + STATUS_LIST.map(s=>[s.key, {label:s.label||s.key, color:statusColor(s.color)}])); +const prioKey = p => (p && PRIO_META[p]) ? p : "unset"; +const days = ts => { const t = Date.parse(ts||""); return isNaN(t)?null:Math.floor((NOW-t)/DAY); }; +const esc = s => String(s??"").replace(/[&<>"]/g, c=>({ "&":"&","<":"<",">":">",'"':"""}[c])); + +// ---- index + derive ---- +const byId = new Map(ISSUES.map(i=>[i.id,i])); +const kids = new Map(); // parentId -> children[] +const dependents = new Map(); // depId -> [issues depending on it] +for(const i of ISSUES){ + if(i.parent){ (kids.get(i.parent)||kids.set(i.parent,[]).get(i.parent)).push(i); } + for(const d of (i.dependencies||[])){ (dependents.get(d)||dependents.set(d,[]).get(d)).push(i); } +} +for(const i of ISSUES){ + i._open = OPEN.has(i.status); + i._age = days(i.createdAt); + i._stale = days(i.updatedAt); + i._isStale = i._open && (i._stale!=null && i._stale>14); + i._unmet = (i.dependencies||[]).map(d=>byId.get(d)).filter(d=>d && !MET.has(d.status)); + i._blocked = i._open && i._unmet.length>0; + i._blockingOpen = (dependents.get(i.id)||[]).filter(x=>x._open===undefined?OPEN.has(x.status):x._open).length; + i._unprio = i._open && !PRIO_META[i.priority]; + const d = (i.description||"").trim(); + i._badDesc = /^\/\S+\.(md|txt|json)$/.test(d) || d===""; +} +const shortOf = id => (byId.get(id)?.shortId) ? IDP+byId.get(id).shortId : (id||"").slice(0,8); +const openIssues = ISSUES.filter(i=>i._open); + diff --git a/template/app/table.js b/template/app/table.js new file mode 100644 index 0000000..9d00a53 --- /dev/null +++ b/template/app/table.js @@ -0,0 +1,74 @@ +// ---- prioritisation table ---- +function prioBadge(p){const m=PRIO_META[prioKey(p)]; + return `${m.label}`;} +function statusBadge(s){const m=STATUS_META[s]||{label:s,color:"var(--muted)"}; + return `${m.label}`;} + +const COLS = [ + {k:"shortId", t:"ID", get:i=>IDP+i.shortId, cell:i=>`${IDP}${i.shortId}`, num:false}, + {k:"title", t:"Title", get:i=>i.title, cell:i=>`
    ${esc(i.title)}
    `}, + {k:"epic", t:"Epic", get:i=>i.parent?shortOf(i.parent):"~", cell:i=>i.parent?`${shortOf(i.parent)}`:`—`}, + {k:"priority", t:"Priority", get:i=>PRIO_ORDER[prioKey(i.priority)], cell:i=>prioBadge(i.priority)}, + {k:"status", t:"Status", get:i=>i.status, cell:i=>statusBadge(i.status)}, + {k:"age", t:"Age d", get:i=>i._age??-1, cell:i=>i._age??"—", num:true}, + {k:"stale", t:"Idle d", get:i=>i._stale??-1, cell:i=>`${i._stale??"—"}${i._isStale?' stale':""}`, num:true}, + {k:"blocked", t:"Blocked", get:i=>i._unmet.length, cell:i=>i._unmet.length?`✦ ${i._unmet.length}`:"", num:true}, + {k:"blocking", t:"Blocks", get:i=>i._blockingOpen, cell:i=>i._blockingOpen||"", num:true}, +]; +let sortK="priority", sortAsc=true; +// no parent + no children default ON — the individual-issue review usually wants leaf issues (no epics, no umbrellas) +const F = {q:"",prio:"",status:"",epic:"",theme:"",blocked:false,stale:false,unprio:false,noParent:true,noChildren:true}; + +// filter option lists +const epicOpts = [...new Set(openIssues.filter(i=>i.parent).map(i=>i.parent))] + .map(id=>({id,label:shortOf(id)+" · "+((byId.get(id)?.title)||"").slice(0,40)})) + .sort((a,b)=>a.label.localeCompare(b.label)); +document.getElementById("fEpic").insertAdjacentHTML("beforeend", + epicOpts.map(o=>``).join("")); +document.getElementById("fPrio").insertAdjacentHTML("beforeend", + ["critical","high","medium","low","unset"].map(p=>``).join("")); +document.getElementById("fStatus").insertAdjacentHTML("beforeend", + OPEN_ORDER.map(s=>``).join("")); + +function rows(){ + let r = openIssues.filter(i=>{ + if(F.q){const q=F.q.toLowerCase(); if(!(i.title.toLowerCase().includes(q)||(IDP.toLowerCase()+i.shortId).includes(q))) return false;} + if(F.prio && prioKey(i.priority)!==F.prio) return false; + if(F.status && i.status!==F.status) return false; + if(F.epic && i.parent!==F.epic) return false; + // themes are resolved per facet; filter against whichever facet the tiles show + if(F.theme && facetOf(facetKey)._index.get(i.id).key!==F.theme) return false; + if(F.blocked && !i._blocked) return false; + if(F.stale && !i._isStale) return false; + if(F.unprio && !i._unprio) return false; + if(F.noParent && i.parent) return false; + if(F.noChildren && kids.has(i.id)) return false; + return true; + }); + const col = COLS.find(c=>c.k===sortK); + r.sort((a,b)=>{const av=col.get(a),bv=col.get(b); + let c = (avbv?1:0); if(c===0) c = PRIO_ORDER[prioKey(a.priority)]-PRIO_ORDER[prioKey(b.priority)]; + return sortAsc?c:-c;}); + return r; +} +function renderTable(){ + const thead=document.querySelector("#tbl thead"), tbody=document.querySelector("#tbl tbody"); + thead.innerHTML = ""+COLS.map(c=>`${c.t}`).join("")+""; + const r=rows(); + tbody.innerHTML = r.map(i=>``+ + COLS.map(c=>`${c.cell(i)}`).join("")+"").join(""); + document.getElementById("tCount").innerHTML = `${r.length} of ${openIssues.length} shown`; + thead.querySelectorAll("th").forEach(th=>th.onclick=()=>{ + const k=th.dataset.k; if(k===sortK) sortAsc=!sortAsc; else {sortK=k; sortAsc=(k==="title"||k==="shortId");} + renderTable();}); +} +document.getElementById("q").oninput=e=>{F.q=e.target.value;renderTable();}; +document.getElementById("fPrio").onchange=e=>{F.prio=e.target.value;renderTable();}; +document.getElementById("fStatus").onchange=e=>{F.status=e.target.value;renderTable();}; +document.getElementById("fEpic").onchange=e=>{F.epic=e.target.value;renderTable();}; +document.getElementById("fBlocked").onchange=e=>{F.blocked=e.target.checked;renderTable();}; +document.getElementById("fStale").onchange=e=>{F.stale=e.target.checked;renderTable();}; +document.getElementById("fUnprio").onchange=e=>{F.unprio=e.target.checked;renderTable();}; +document.getElementById("fNoParent").onchange=e=>{F.noParent=e.target.checked;renderTable();}; +document.getElementById("fNoChildren").onchange=e=>{F.noChildren=e.target.checked;renderTable();}; + diff --git a/template/app/themes.js b/template/app/themes.js new file mode 100644 index 0000000..9ce2037 --- /dev/null +++ b/template/app/themes.js @@ -0,0 +1,209 @@ +// ---- themes ---- +// Every issue lands in exactly ONE theme per facet, so tile counts sum to the backlog. +// The theme is resolved in descending order of trust: +// 1. label — an fp label matching a theme key (ties broken by taxonomy order) +// 2. inherited— an epic's children belong to their epic's theme. This outranks the +// issue's own keywords ON PURPOSE: an initiative that fragments across +// five tiles because each sub-issue matched a different word is exactly +// what stops a "where to focus" page from helping you focus. A child +// leaves its epic's theme only by carrying its own label (rule 1). +// 3. inferred — weighted keyword match on title (×3) + description. This is how epics +// and standalone issues get classified — and how a child of an +// *unthemed* parent still finds a home. +// 4. unthemed — no signal at all; the tile that says "these need labelling" +const FACETS = (THEMES_CONFIG.facets||[]).filter(f=>(f.themes||[]).length); +const TH_MIN = THEMES_CONFIG.threshold ?? 5; +const TH_DESC = THEMES_CONFIG.descriptionChars ?? 1500; +const UNTHEMED = {key:"__unthemed", label:"Unthemed", color:"neutral"}; +for(const f of FACETS){ + for(const t of f.themes){ + // one alternation per bucket, compiled once — the classifier runs over every issue + t._strong = (t.strong||[]).length ? new RegExp((t.strong||[]).join("|"), "gi") : null; + t._weak = (t.weak ||[]).length ? new RegExp((t.weak ||[]).join("|"), "gi") : null; + t._color = statusColor(t.color); + } +} +UNTHEMED._color = statusColor(UNTHEMED.color); +const hits = (re, s) => { if(!re) return 0; const m = s.match(re); return m ? m.length : 0; }; + +const labelTheme = (i, f) => { + const labels = (i.properties && i.properties.labels) || []; + for(const t of f.themes) if(labels.includes(t.key)) return {key:t.key, src:"label"}; + return null; +}; +function ownTheme(i, f){ + const byLabel = labelTheme(i, f); if(byLabel) return byLabel; + const title = (i.title||"").toLowerCase(); + const desc = (i.description||"").slice(0, TH_DESC).toLowerCase(); + let best=null, bestScore=0; + for(const t of f.themes){ + const s = 6*hits(t._strong,title) + 3*hits(t._weak,title) + + 2*hits(t._strong,desc) + 1*hits(t._weak,desc); + if(s > bestScore){ bestScore=s; best=t.key; } + } + return bestScore >= (f.threshold ?? TH_MIN) ? {key:best, src:"inferred"} : null; +} +// issueId -> {key, src} for one facet. `busy` guards a malformed parent cycle. +function themeIndex(f){ + const out = new Map(), busy = new Set(); + const resolve = i => { + if(out.has(i.id)) return out.get(i.id); + if(busy.has(i.id)) return {key:UNTHEMED.key, src:"none"}; + busy.add(i.id); + // an own label wins outright; otherwise a child follows its epic before its own words + let r = labelTheme(i, f); + if(!r && i.parent && byId.has(i.parent)){ + const p = resolve(byId.get(i.parent)); + if(p.key !== UNTHEMED.key) r = {key:p.key, src:"inherited"}; + } + if(!r) r = ownTheme(i, f); + if(!r) r = {key:UNTHEMED.key, src:"none"}; + busy.delete(i.id); out.set(i.id, r); + return r; + }; + for(const i of ISSUES) resolve(i); + return out; +} +for(const f of FACETS) f._index = themeIndex(f); +let facetKey = (FACETS[0]||{}).key || ""; +const PRIO_ROWS = ["critical","high","medium","low","unset"]; +// "hide done" (the tab-bar toggle) drops the done + terminal segments from every tile, so +// the bars show live work only. Default ON: on a backlog that is 3/4 complete, done fills +// every bar and all themes collapse to the same shape — a theme whose only "critical" work +// shipped months ago should not open looking like it has critical work. +let showDone = false; +const facetOf = k => FACETS.find(f=>f.key===k) || FACETS[0]; + +// per-theme roll-up for one facet. +// Counts are over LEAF issues only. An epic's status is a roll-up of its children, not +// separate work: 8 of MC's 14 "in progress" were epics in progress *because a child was*, +// which double-counted the same work and made WIP look sprayed across every theme. Epics +// are still surfaced — as their own "N epics" figure, which is what they actually measure. +function themeStats(f){ + const bucket = new Map([...f.themes, UNTHEMED].map(t=>[t.key, {t, all:[], open:[], epics:[]}])); + for(const i of ISSUES){ + const b = bucket.get(f._index.get(i.id).key); if(!b) continue; + if(kids.has(i.id)){ b.epics.push(i); continue; } + b.all.push(i); if(i._open) b.open.push(i); + } + return [...bucket.values()].map(b=>({ + t: b.t, + total: b.all.length, + open: b.open.length, + done: b.all.filter(i=>DONE.has(i.status)).length, + other: b.all.filter(i=>!DONE.has(i.status) && !i._open).length, + // One row per priority, each segmented by status. `topLevel` is carried per row + // because only TOP-LEVEL priority is globally comparable — inside an epic "critical" + // ranks a sub-issue against its siblings (sequencing, not urgency), which is why it + // is reported in the row's tooltip rather than silently pooled with the rest. + prio: PRIO_ROWS.map(p=>{ + const items = b.all.filter(i=>prioKey(i.priority)===p); + const live = items.filter(i=>i._open); + return { + p, meta: PRIO_META[p], total: items.length, + topLevel: items.filter(i=>!i.parent).length, + openTotal: live.length, openTopLevel: live.filter(i=>!i.parent).length, + byStatus: OPEN_ORDER.map(s=>({s, n:items.filter(i=>i.status===s).length})), + done: items.filter(i=>DONE.has(i.status)).length, + other: items.filter(i=>!DONE.has(i.status) && !i._open).length, + }; + }), + epics: b.epics.length, + openEpics: b.epics.filter(i=>i._open).length, + labelled: b.all.concat(b.epics).filter(i=>f._index.get(i.id).src==="label").length, + byStatus: OPEN_ORDER.map(s=>({s, n:b.open.filter(i=>i.status===s).length})), + })).sort((a,b)=> + (a.t.key===UNTHEMED.key) - (b.t.key===UNTHEMED.key) || // "Unthemed" always last + (b.open-a.open) || (b.total-a.total)); +} + +// the segments of one priority row, in registry order; done/terminal only when shown +function segmentsOf(r){ + const segs = OPEN_ORDER.map(s=>({ + label: STATUS_META[s].label, color: STATUS_META[s].color, + n: (r.byStatus.find(x=>x.s===s)||{n:0}).n })); + if(showDone){ + segs.push({label:"Done", color:"var(--good)", n:r.done}); + segs.push({label:"Other", color:"var(--baseline)", n:r.other}); + } + return segs.filter(x=>x.n); +} +function renderThemes(){ + const f = facetOf(facetKey), stats = themeStats(f); + document.getElementById("facetPicker").innerHTML = + FACETS.map(x=>``).join("") + + (f.hint?`${esc(f.hint)}`:"") + + `` + OPEN_ORDER.map(s=> + `${esc(STATUS_META[s].label)}`).join("") + + (showDone?`Done`:"") + + ``; + // Scale every bar against the biggest row ACROSS ALL TILES, not per tile. Per-tile + // scaling made a 1-issue row fill its track exactly like a 14-issue row next to it, + // so the eye — which compares tiles side by side — read them as equal. + const scale = Math.max(1, ...stats.flatMap(s=>s.prio.map(r=> + segmentsOf(r).reduce((a,g)=>a+g.n, 0)))); + document.getElementById("tiles").innerHTML = stats.map(s=>{ + const rows = s.prio.map(r=>({r, segs:segmentsOf(r)})) + .map(x=>({...x, n:x.segs.reduce((a,g)=>a+g.n,0)})); + const max = scale; + const prows = rows.map(({r,segs,n})=>{ + const detail = segs.map(g=>`${g.label} ${g.n}`).join(" · ") || "none"; + // describe the same set the bar draws, so the tooltip can't contradict the row + const tot = showDone ? r.total : r.openTotal; + const top = showDone ? r.topLevel : r.openTopLevel; + const scope = top < tot + ? ` — ${top} top-level, ${tot-top} scoped to an epic (ranked against siblings, not the backlog)` + : ""; + return `
    + ${esc(r.meta.label)} + ${segs.map(g=> + ``).join("")} + ${n||""} +
    `; + }).join(""); + return ``; + }).join(""); + const srcs = ISSUES.map(i=>f._index.get(i.id).src); + const n = k => srcs.filter(x=>x===k).length; + const sum = k => stats.reduce((a,s)=>a+s[k], 0); + document.getElementById("themeProvenance").textContent = + `${sum("open")} open work items across ${stats.filter(s=>s.open).length} themes, plus ` + + `${sum("openEpics")} open epics counted separately (an epic is in progress because a ` + + `sub-issue is — counting both would double-count the same work). ` + + `Themed by: ${n("label")} fp label · ${n("inferred")} keyword match · ` + + `${n("inherited")} inherited from their epic · ${n("none")} unthemed. ` + + `Labels always win — label an issue to correct its tile.`; + // theme filter on the Issues tab follows the selected facet + const sel = document.getElementById("fTheme"); + sel.innerHTML = `` + + stats.map(s=>``).join(""); + sel.value = F.theme; +} +document.getElementById("tabThemesN").textContent = + FACETS.length ? themeStats(facetOf(facetKey)).filter(s=>s.open).length : 0; +document.getElementById("facetPicker").addEventListener("click", e=>{ + const b=e.target.closest(".facet"); if(!b) return; + facetKey=b.dataset.facet; F.theme=""; renderThemes(); renderTable(); +}); +// tap a tile → jump to the Issues tab, filtered to that theme +document.getElementById("tiles").addEventListener("click", e=>{ + const b=e.target.closest(".tile"); if(!b) return; + F.theme=b.dataset.theme; + document.getElementById("fTheme").value=F.theme; + showPanel("panel-issues"); + renderTable(); + document.getElementById("filters").scrollIntoView({behavior:"smooth", block:"start"}); +}); +document.getElementById("fTheme").onchange=e=>{F.theme=e.target.value;renderTable();}; +// "hide done" ships ticked, so apply its body class at load too — otherwise the Epics +// tab would still list done children while the box reads as checked. +document.body.classList.toggle("hide-done", document.getElementById("hideDone").checked); +if(FACETS.length) renderThemes(); else document.getElementById("panel-themes").hidden = true; +renderTable(); // first paint — after the theme index exists, so F.theme can be honoured + diff --git a/template/app/ui.js b/template/app/ui.js new file mode 100644 index 0000000..eb3e24f --- /dev/null +++ b/template/app/ui.js @@ -0,0 +1,114 @@ +// ---- tabs ---- +document.getElementById("tabIssuesN").textContent = openIssues.length; +function showPanel(id){ + document.querySelectorAll(".tab").forEach(t=>t.classList.toggle("active", t.dataset.panel===id)); + document.querySelectorAll(".panel").forEach(p=>p.toggleAttribute("hidden", p.id!==id)); +} +document.getElementById("tabs").addEventListener("click", e=>{ + const b = e.target.closest(".tab"); if(!b) return; + showPanel(b.dataset.panel); +}); +document.getElementById("hideDone").addEventListener("change", e=>{ + document.body.classList.toggle("hide-done", e.target.checked); + // the tiles recompute rather than CSS-hide: dropping done changes every bar's scale + showDone = !e.target.checked; + if(FACETS.length) renderThemes(); +}); + +// ---- footer + theme ---- +document.getElementById("foot").textContent = `Generated from live fp state · ${new Date(NOW).toISOString()}`; +const root=document.documentElement, tb=document.getElementById("themeBtn"); +tb.onclick=()=>{const cur=root.getAttribute("data-theme")|| (matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"); + root.setAttribute("data-theme", cur==="dark"?"light":"dark");}; + +// ---- right-click an issue row to copy its id ---- +let toastT=null; +function toast(msg){ + const t=document.getElementById("toast"); + t.textContent=msg; t.classList.add("show"); + clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove("show"), 1400); +} +function copyText(text){ + if(navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text); + // fallback if the Clipboard API is unavailable (non-secure context) + const ta=document.createElement("textarea"); + ta.value=text; ta.style.position="fixed"; ta.style.opacity="0"; + document.body.appendChild(ta); ta.select(); + let ok=false; try{ ok=document.execCommand("copy"); }catch(e){} + ta.remove(); return ok?Promise.resolve():Promise.reject(); +} +document.addEventListener("contextmenu", ev=>{ + const el=ev.target.closest("[data-id]"); if(!el) return; // native menu elsewhere + ev.preventDefault(); + const id=el.getAttribute("data-id"); + copyText(id).then(()=>toast(`Copied ${id}`), ()=>toast(`Couldn't copy — ${id}`)); +}); +function copyId(el){ + const holder=el.closest("[data-id]")||el; + const id=holder.getAttribute("data-id")||el.textContent.trim(); + copyText(id).then(()=>toast(`Copied ${id}`), ()=>toast(`Couldn't copy — ${id}`)); +} + +// ---- global search: every issue, matched on id / title / spec (description) ---- +// Space-separated terms are ANDed; id hits rank above title hits above spec hits. +// Serves the on-the-go use case: find a specced task fast, tap to read its spec. +const gq=document.getElementById("gq"), gqResults=document.getElementById("gqResults"), + gqMeta=document.getElementById("gqMeta"), gqClear=document.getElementById("gqClear"); +const SEARCH_LIMIT=60; +const reEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); +function mark(text, terms){ // highlight terms in already-escaped text + let out=esc(text); + for(const t of terms){ if(!t) continue; + out=out.replace(new RegExp("("+reEsc(t)+")","ig"), "$1"); } + return out; +} +function runSearch(){ + const raw=gq.value.trim(); + const terms=raw.toLowerCase().split(/\s+/).filter(Boolean); + gqClear.hidden = raw===""; + document.body.classList.toggle("searching", terms.length>0); + if(!terms.length){ gqResults.hidden=gqMeta.hidden=true; gqResults.innerHTML=""; return; } + const hits=[]; + for(const i of ISSUES){ + const id=(IDP+i.shortId).toLowerCase(), title=(i.title||"").toLowerCase(), desc=(i.description||"").toLowerCase(); + let ok=true, score=0; + for(const t of terms){ + const a=id.includes(t), b=title.includes(t), c=desc.includes(t); + if(!(a||b||c)){ ok=false; break; } + score += (a?6:0)+(b?4:0)+(c?1:0)+(title.startsWith(t)?2:0); + } + if(ok) hits.push({i, score: score+(i._open?2:0)}); + } + hits.sort((x,y)=> y.score-x.score + || (PRIO_ORDER[prioKey(x.i.priority)]-PRIO_ORDER[prioKey(y.i.priority)])); + const total=hits.length, shown=hits.slice(0,SEARCH_LIMIT); + gqMeta.hidden=false; + gqMeta.textContent=`${total} match${total===1?"":"es"}`+(total>SEARCH_LIMIT?` · showing first ${SEARCH_LIMIT}`:""); + gqResults.hidden=false; + gqResults.innerHTML = total ? shown.map(({i})=>{ + const d=(i.description||"").trim(); + const hasDesc = d && !/^\/\S+\.(md|txt|json)$/.test(d); // skip "spec is just a file path" stubs + return `
    +
    + ${IDP}${i.shortId} + ${prioBadge(i.priority)}${statusBadge(i.status)} + ${i.parent?`in ${esc(shortOf(i.parent))}`:""} +
    +
    ${mark(i.title||"(untitled)", terms)}
    + ${hasDesc?`
    ${mark(d, terms)}
    `:""} +
    `; + }).join("") : `
    No issues match “${esc(raw)}”.
    `; +} +gq.addEventListener("input", runSearch); +gqClear.addEventListener("click", ()=>{ gq.value=""; runSearch(); gq.focus(); }); +gq.addEventListener("keydown", e=>{ if(e.key==="Escape"){ gq.value=""; runSearch(); } }); + +// ---- unified tap handling: copy-chip (any device) · expand a search hit · touch row-copy ---- +const COARSE = matchMedia("(hover:none)").matches; +document.addEventListener("click", ev=>{ + const chip=ev.target.closest(".cid"); + if(chip){ ev.preventDefault(); ev.stopPropagation(); copyId(chip); return; } + const hit=ev.target.closest(".hit"); + if(hit){ hit.classList.toggle("open"); return; } // tap a result → read the full spec + if(COARSE){ const row=ev.target.closest("[data-id]"); if(row) copyId(row); } // touch: tap a row to copy its id +}); diff --git a/template/fp-report.template.html b/template/fp-report.template.html new file mode 100644 index 0000000..c5fa5e2 --- /dev/null +++ b/template/fp-report.template.html @@ -0,0 +1,114 @@ + + + + + +__REPORT_TITLE__ — __APP_NAME__ + + + +
    +
    +
    + + +
    +
    __APP_NAME__
    +

    __REPORT_TITLE__

    +
    +
    +
    + +
    + + + +
    +

    Signals

    +
    + +

    Open backlog composition

    +
    +
    By priority
    +
    By status
    +
    + + + +
    +

    Themes — work items by priority and status, epics counted separately · tick “hide done” to see live work only · click a tile to see its open issues

    +
    +
    +
    +
    + + + + +
    + +
    +
    +
    + + + + + diff --git a/template/styles/base.css b/template/styles/base.css new file mode 100644 index 0000000..da0d2ac --- /dev/null +++ b/template/styles/base.css @@ -0,0 +1,54 @@ + *{box-sizing:border-box} + body{margin:0;background:var(--page);background-image:var(--motif);background-attachment:fixed; + color:var(--ink); + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + font-size:14px;line-height:1.45} + a{color:var(--link);text-decoration:none} + .wrap{max-width:1280px;margin:0 auto;padding:20px 20px 80px} + header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:4px} + .brand{display:flex;align-items:center;gap:15px} + .brand-logo{height:52px;width:auto;display:block;flex:0 0 auto} + /* Theme-correct logo: dark wordmark on paper, white wordmark on navy. */ + .logo-dark{display:none} + .logo-light{display:block} + @media (prefers-color-scheme:dark){ + :root:not([data-theme="light"]) .logo-light{display:none} + :root:not([data-theme="light"]) .logo-dark{display:block} + } + :root[data-theme="dark"] .logo-light, html[data-theme="dark"] .logo-light{display:none} + :root[data-theme="dark"] .logo-dark, html[data-theme="dark"] .logo-dark{display:block} + :root[data-theme="light"] .logo-light{display:block} + :root[data-theme="light"] .logo-dark{display:none} + .appname{font-size:11.5px;font-weight:680;text-transform:uppercase;letter-spacing:.09em;color:var(--link);margin:0 0 2px;line-height:1} + h1{font-size:19px;margin:0;font-weight:700;color:var(--head);letter-spacing:-.01em} + .sub{color:var(--muted);font-size:12.5px} + .theme-btn{margin-left:auto;background:var(--surface);border:1px solid var(--border);color:var(--ink-2); + border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer} + h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted); + font-weight:650;margin:26px 0 10px} + /* tabs */ + .tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin:26px 0 14px} + .tab{appearance:none;background:none;border:0;border-bottom:2px solid transparent; + color:var(--ink-2);font:inherit;font-size:13.5px;font-weight:600;padding:9px 14px;cursor:pointer;margin-bottom:-1px} + .tab .n{color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums} + .tab:hover{color:var(--ink)} + .tab.active{color:var(--head);border-bottom-color:var(--link)} + .tab.active .n{color:var(--ink-2)} + .tabs label.chk{margin-left:auto;align-self:center;font-size:12.5px;color:var(--ink-2); + cursor:pointer;display:inline-flex;align-items:center;gap:6px} + body.hide-done .echild.is-done{display:none} + .panel[hidden]{display:none} + /* expandable epic */ + .epic{cursor:pointer} + .epic .caret{color:var(--muted);transition:transform .12s ease;display:inline-block;width:1em} + .epic.open .caret{transform:rotate(90deg)} + .epic.childless .caret{opacity:.4} /* "Epic"-titled issue with no sub-issues yet */ + .epic-children{background:var(--surface-2);border-top:1px solid var(--border)} + .epic-children[hidden]{display:none} + .echild{display:grid;grid-template-columns:118px 1fr auto auto 60px;gap:12px;align-items:center; + padding:6px 14px 6px 30px;border-top:1px solid var(--border);font-size:12.5px} + .echild:first-child{border-top:0} + .echild .eeid{color:var(--muted);font-variant-numeric:tabular-nums} + .echild .eet{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .echild .eeidle{text-align:right;color:var(--muted);font-variant-numeric:tabular-nums} + .card{background:var(--surface);border:1px solid var(--border);border-radius:12px} diff --git a/template/styles/components.css b/template/styles/components.css new file mode 100644 index 0000000..75262cb --- /dev/null +++ b/template/styles/components.css @@ -0,0 +1,93 @@ + /* ---- distribution bars ---- */ + .dist{display:grid;grid-template-columns:1fr 1fr;gap:10px} + @media(max-width:760px){.dist{grid-template-columns:1fr}} + .distbody{padding:12px 14px} + .brow{display:grid;grid-template-columns:96px 1fr 34px;align-items:center;gap:8px;margin:5px 0} + .brow .bl{font-size:12.5px;color:var(--ink-2);display:flex;align-items:center;gap:6px} + .track{height:9px;background:var(--track);border-radius:5px;overflow:hidden;display:block} + .fill{display:block;height:100%;border-radius:5px;min-width:2px} + .brow .bn{font-size:12px;color:var(--ink-2);text-align:right;font-variant-numeric:tabular-nums} + .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto} + /* ---- epics ---- */ + .epic{padding:11px 14px;border-top:1px solid var(--border);display:grid; + grid-template-columns:1fr 220px auto;gap:14px;align-items:center} + .epic:first-child{border-top:0} + .epic .et{font-weight:560;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .epic .eid{color:var(--muted);font-size:11.5px;font-variant-numeric:tabular-nums} + .pbar{height:10px;background:var(--track);border-radius:5px;overflow:hidden;display:flex} + .pbar > i{display:block;height:100%} + .epic .ec{font-size:11.5px;color:var(--muted);margin-top:3px;font-variant-numeric:tabular-nums} + .epic .er{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex-wrap:wrap} + /* ---- badges ---- */ + .badge{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:560; + padding:2px 8px;border-radius:999px;background:var(--surface-2);color:var(--ink-2);white-space:nowrap} + .badge .dot{width:8px;height:8px} + .badge.icon::before{font-size:11px;line-height:1} + /* ---- filters ---- */ + .filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px} + .filters select,.filters input{background:var(--surface);border:1px solid var(--border); + color:var(--ink);border-radius:8px;padding:6px 9px;font-size:12.5px;font-family:inherit} + .filters input[type=search]{min-width:190px} + .filters #fEpic{max-width:360px} /* epic titles are long — don't let the select dominate the row */ + /* checkbox toggles stay together as one block and wrap as a unit (never orphan a single box) */ + .toggles{display:flex;gap:12px;flex-wrap:wrap;align-items:center} + .chk{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;color:var(--ink-2);cursor:pointer;white-space:nowrap} + .count{color:var(--ink-2);font-size:12.5px;margin-left:auto;white-space:nowrap;font-variant-numeric:tabular-nums} + .count b{color:var(--head);font-weight:680} + /* ---- table ---- */ + .twrap{overflow-x:auto} + table{width:100%;border-collapse:collapse;font-size:12.8px} + th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top} + th{position:sticky;top:0;background:var(--surface);color:var(--muted);font-weight:600; + font-size:11px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;white-space:nowrap;z-index:1} + th.sorted::after{content:" ▾";opacity:.7} + th.sorted.asc::after{content:" ▴"} + td.num{font-variant-numeric:tabular-nums;text-align:right} + tr.blocked td.tt{opacity:.72} + .ttl{max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .flag{font-size:11px;color:var(--serious);font-weight:600} + .flag.stale{color:var(--warning)} + .twocol{display:grid;grid-template-columns:1fr 1fr;gap:16px} + @media(max-width:760px){.twocol{grid-template-columns:1fr}} + ol.deps{margin:0;padding:6px 0 6px 0;list-style:none} + ol.deps li{padding:6px 14px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:baseline} + ol.deps li:first-child{border-top:0} + ol.deps .n{font-variant-numeric:tabular-nums;font-weight:650;color:var(--ink);min-width:22px} + .empty{padding:12px 14px;color:var(--muted)} + /* right-click-to-copy affordance + confirmation toast */ + [data-id]{cursor:context-menu} + .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(8px); + background:var(--head);color:var(--surface);font-size:12.5px;font-weight:560; + padding:8px 14px;border-radius:8px;box-shadow:0 6px 22px rgba(0,0,0,.22); + opacity:0;pointer-events:none;transition:opacity .16s ease,transform .16s ease;z-index:50; + font-variant-numeric:tabular-nums} + .toast.show{opacity:1;transform:translateX(-50%) translateY(0)} + footer{margin-top:30px;color:var(--muted);font-size:11.5px;text-align:center} + + /* ---- global search (all issues: id · title · spec) ---- */ + .search{margin:16px 0 6px} + .search-box{position:relative;display:flex;align-items:center} + .search-box::before{content:"⌕";position:absolute;left:14px;font-size:19px;color:var(--muted);pointer-events:none} + #gq{width:100%;background:var(--surface);border:1px solid var(--border);color:var(--ink); + border-radius:10px;padding:12px 42px;font-size:15px;font-family:inherit} + #gq::placeholder{color:var(--muted)} + #gq:focus{outline:none;border-color:var(--link);box-shadow:0 0 0 3px var(--track)} + .search-clear{position:absolute;right:8px;background:var(--surface-2);border:1px solid var(--border); + color:var(--ink-2);border-radius:8px;width:30px;height:30px;font-size:17px;line-height:1; + cursor:pointer;display:flex;align-items:center;justify-content:center} + .search-hint{display:none} + .search-meta{color:var(--muted);font-size:12px;margin:9px 2px 0} + .search-results{display:grid;gap:10px;margin-top:10px} + .hit{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 14px;cursor:pointer} + .hit-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap} + .hit-id{font-weight:680;color:var(--link);font-variant-numeric:tabular-nums} + .hit-title{font-weight:600;color:var(--head);font-size:14px;line-height:1.35;margin-top:6px} + .hit-desc{margin-top:8px;color:var(--ink-2);font-size:12.8px;line-height:1.5;white-space:pre-wrap; + overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3} + .hit.open .hit-desc{-webkit-line-clamp:unset;display:block} + .hit mark{background:var(--warning);color:#1a1200;padding:0 2px;border-radius:3px} + .hit-empty{padding:14px;color:var(--muted)} + .cid{cursor:pointer} + @media (hover:hover){.cid:hover{text-decoration:underline}} + body.searching #dashboard{display:none} + diff --git a/template/styles/mobile.css b/template/styles/mobile.css new file mode 100644 index 0000000..615b4ca --- /dev/null +++ b/template/styles/mobile.css @@ -0,0 +1,49 @@ + /* ---- mobile (iPhone Pro ~390–430px) ---- */ + @media (max-width:480px){ + .wrap{padding:14px 12px 60px} + header.top{gap:10px} + .brand{gap:11px} + .brand-logo{height:40px} + h1{font-size:17px} + .appname{font-size:11px} + .search{position:sticky;top:0;z-index:6;background:var(--page);padding:8px 0;margin:8px 0 4px} + .search-hint{display:block;color:var(--muted);font-size:11.5px;margin:7px 2px 0} + .kpis{grid-template-columns:1fr 1fr} + .kpi .v{font-size:23px} + /* epic roadmap rows → stacked. Let long titles wrap: a nowrap .et makes the grid + item's min-width its full text width, which forces horizontal overflow and widens + the viewport (defeating this whole breakpoint). min-width:0 + wrapping fixes it. */ + .epic{grid-template-columns:1fr;gap:8px;min-width:0} + .epic > div{min-width:0} + .epic .et{white-space:normal;overflow:visible;text-overflow:clip} + .epic .er{justify-content:flex-start} + /* epic child rows → id+idle / title / badges */ + .echild{grid-template-columns:auto 1fr auto;row-gap:5px;column-gap:8px;padding:10px 12px 10px 16px;font-size:12.5px} + .echild .eeid{grid-column:1;grid-row:1} + .echild .eet{grid-column:1 / -1;grid-row:2;white-space:normal;overflow:visible;text-overflow:clip} + .echild > .badge:nth-child(3){grid-column:1;grid-row:3;justify-self:start} + .echild > .badge:nth-child(4){grid-column:2;grid-row:3;justify-self:start} + .echild .eeidle{grid-column:3;grid-row:1;text-align:right} + /* prioritisation table → stacked cards */ + section.card.twrap{background:transparent;border:0;border-radius:0} + .twrap{overflow:visible} + #tbl thead{display:none} + #tbl,#tbl tbody{display:block;width:100%} + #tbl tr{display:block;background:var(--surface);border:1px solid var(--border); + border-radius:12px;margin-bottom:10px;padding:10px 12px} + #tbl td{display:flex;justify-content:space-between;gap:12px;align-items:baseline; + border:0;padding:3px 0;text-align:left} + #tbl td::before{content:attr(data-label);color:var(--muted);font-size:10.5px;font-weight:600; + text-transform:uppercase;letter-spacing:.04em;flex:0 0 auto} + #tbl td.num{text-align:left} + #tbl td.tt{display:block;padding-bottom:7px;margin-bottom:5px;border-bottom:1px solid var(--border)} + #tbl td.tt::before{display:none} + #tbl td.tt .ttl{white-space:normal;max-width:none;font-weight:650;font-size:13.5px} + #tbl td:empty{display:none} + /* filters: give the in-panel search room, wrap comfortably (min-width:0 so a + select's long options can't force it past the viewport) */ + .filters{gap:8px 8px} + .filters input[type=search]{min-width:0;flex:1 1 100%} + .filters select{min-width:0;max-width:100%;flex:1 1 45%} + .filters #fEpic{max-width:none;flex:1 1 100%} + } diff --git a/template/styles/tiles.css b/template/styles/tiles.css new file mode 100644 index 0000000..8b6cf62 --- /dev/null +++ b/template/styles/tiles.css @@ -0,0 +1,49 @@ + /* ---- KPI tiles ---- */ + .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px} + .kpi{padding:12px 14px} + .kpi .v{font-size:27px;font-weight:680;letter-spacing:-.01em;line-height:1.1} + .kpi .l{color:var(--ink-2);font-size:12px;margin-top:3px} + .kpi.sig{border-left:3px solid var(--accent,var(--baseline))} + .kpi .note{font-size:11px;color:var(--muted);margin-top:2px} + /* ---- theme tiles ---- */ + .facets{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:0 0 12px} + .facet{appearance:none;background:var(--surface);border:1px solid var(--border);color:var(--ink-2); + font:inherit;font-size:12.5px;font-weight:600;padding:6px 13px;border-radius:999px;cursor:pointer} + .facet:hover{color:var(--ink)} + .facet.active{background:var(--link);border-color:var(--link);color:var(--surface)} + .facet-hint{color:var(--muted);font-size:12px;margin-left:6px} + .tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(296px,1fr));gap:12px} + .tile{text-align:left;font:inherit;color:inherit;cursor:pointer;width:100%; + background:var(--surface);border:1px solid var(--border);border-top:3px solid var(--tc); + border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:9px} + .tile:hover{border-color:var(--tc)} + .tile.is-empty{opacity:.55} + .tile-top{display:flex;align-items:baseline;gap:8px} + .tile-name{font-weight:650;color:var(--head);font-size:13.5px;line-height:1.25} + .tile-hot{margin-left:auto;font-size:11px;font-weight:650;color:var(--critical);white-space:nowrap} + .tile-v{display:flex;align-items:baseline;gap:7px;line-height:1} + .tile-v b{font-size:29px;font-weight:680;letter-spacing:-.01em;color:var(--tc);font-variant-numeric:tabular-nums} + .tile-v span{color:var(--ink-2);font-size:12px} + /* priority distribution inside a tile: one row per priority, segmented by status. + Rows scale against the tile's biggest row, so bar length reads as volume. */ + .prows{display:flex;flex-direction:column;gap:5px} + .prow{display:grid;grid-template-columns:66px 1fr 24px;align-items:center;gap:8px} + /* the label carries the PRIORITY colour, the bar segments carry STATUS colours — + one colour system per axis, so a dot next to the bar can't be read as a segment */ + .prow .pl{font-size:11.5px;font-weight:560;display:flex;align-items:center;gap:5px;min-width:0} + .prow .pl .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .ptrack{height:9px;background:var(--track);border-radius:5px;overflow:hidden;display:flex} + .ptrack > i{display:block;height:100%} + .prow .pn{font-size:11.5px;color:var(--ink-2);text-align:right;font-variant-numeric:tabular-nums} + .prow.zero{opacity:.38} + .legend{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-left:auto; + font-size:11.5px;color:var(--muted)} + .legend span{display:inline-flex;align-items:center;gap:5px;white-space:nowrap} + .tile-chips{display:flex;flex-wrap:wrap;gap:5px} + .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:560; + padding:2px 7px;border-radius:999px;background:var(--surface-2);color:var(--ink-2); + white-space:nowrap;font-variant-numeric:tabular-nums} + .chip .dot{width:7px;height:7px} + .tile-foot{color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums; + display:flex;gap:6px;flex-wrap:wrap} + .tile-src{color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums} diff --git a/test/assert_themes.js b/test/assert_themes.js new file mode 100644 index 0000000..52531fc --- /dev/null +++ b/test/assert_themes.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/* assert_themes.js — assert on the theme classifier inside a rendered fp-report. + * + * node test/assert_themes.js [args…] + * source theme provenance: label|inferred|inherited|none + * theme resolved theme key ("__unthemed" when none) + * sums every facet's tiles must sum to the whole backlog + * + * It evaluates the report's REAL injected script against a minimal DOM stub, so the + * assertions exercise the shipped classifier rather than a copy of its rules. + * Exits 0 on pass, 1 on mismatch. + */ +const fs = require("fs"); +const [html_path, check, ...args] = process.argv.slice(2); +const html = fs.readFileSync(html_path, "utf-8"); +const data = html.match(/',open(sys.argv[1],encoding='utf-8').read(),re.S)[-1]) PY node --check "$TMP/app.js"; } +# bad_include — a template referencing a file that isn't there must abort with a clear +# message, not silently emit a page with the directive still in it +bad_include(){ + local d out; d="$TMP/badinc"; mkdir -p "$d/template" + cp "$ROOT/fp-report.sh" "$d/"; cp -R "$ROOT/defaults" "$d/" + printf '\n\n\n' > "$d/template/fp-report.template.html" + mkconf "$d/c.conf" 'FP_PREFIX="FP"' 'APP_NAME="x"' + out="$("$d/fp-report.sh" -c "$d/c.conf" --issues-file "$FIX" -o "$d/o.html" --no-open 2>&1)" && return 1 + printf '%s' "$out" | grep -q "missing include" +} +# a directive that is NOT alone on its line can't be substituted — it must abort rather +# than ship a page with a literal '' comment where content should be +inline_include(){ + local d out; d="$TMP/inlineinc"; mkdir -p "$d/template" + cp "$ROOT/fp-report.sh" "$d/"; cp -R "$ROOT/defaults" "$d/" + printf '\n' > "$d/template/fp-report.template.html" + : > "$d/template/base.css" + mkconf "$d/c.conf" 'FP_PREFIX="FP"' 'APP_NAME="x"' + out="$("$d/fp-report.sh" -c "$d/c.conf" --issues-file "$FIX" -o "$d/o.html" --no-open 2>&1)" && return 1 + printf '%s' "$out" | grep -q "unresolved include" +} +# th [args…] — assert on the classifier in the rendered report (skips where node absent) +th(){ command -v node >/dev/null 2>&1 || return 0; node "$ROOT/test/assert_themes.js" "$@"; } echo "fp-report test suite" @@ -43,7 +68,7 @@ BASE="$TMP/base.html" mkconf "$TMP/base.conf" 'FP_PREFIX="FP"' 'PROJECT_NAME="proj"' 'APP_NAME="proj"' render "$BASE" "$FIX" "$TMP/base.conf" t "renders output" test -s "$BASE" -t "no unreplaced __PLACEHOLDER__" bash -c '! grep -oE "__(FP_DATA|STATUS_CONFIG|THEME_CSS|LOGO_LIGHT|LOGO_DARK|ID_PREFIX|PROJECT_NAME|APP_NAME|REPORT_TITLE|GENERATED_AT)__" "'"$BASE"'" | grep -q .' +t "no unreplaced __PLACEHOLDER__" bash -c '! grep -oE "__(FP_DATA|STATUS_CONFIG|THEMES_CONFIG|THEME_CSS|LOGO_LIGHT|LOGO_DARK|ID_PREFIX|PROJECT_NAME|APP_NAME|REPORT_TITLE|GENERATED_AT)__" "'"$BASE"'" | grep -q .' t "injected app JS parses" jsparse "$BASE" t "exactly two (no data breakout)" bash -c '[ "$(grep -c "" "'"$BASE"'")" -eq 2 ]' @@ -52,6 +77,51 @@ t "embedded fp-data has all 7 issues" python3 "$ROOT/test/assert_model.py" t "open count == 4 (todo+selected+in-progress)" python3 "$ROOT/test/assert_model.py" "$BASE" open 4 t "one blocked issue (unmet dep)" python3 "$ROOT/test/assert_model.py" "$BASE" blocked 1 +# ---- template assembly: parts on disk, one self-contained file out ---- +t "output is still ONE html file" test -f "$BASE" +t "no include directive survives" bash -c '! grep -q "#include" "'"$BASE"'"' +t "still exactly one