From 2cfd953c369cdaa2096a75e12ab040204e9ebc80 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Sun, 19 Jul 2026 00:01:17 -0400 Subject: [PATCH 1/4] wherefore-dashboard: render the plan/ collection Add a plan content collection and read-side views for wherefore/plan/ (P-NNN forward-looking work items), so the dashboard covers plan alongside log and questions. Implements plan item P-002. - content.config.ts: plan collection globbing P-*.md (README excluded), id from the id: frontmatter, single area, transform splitting decision_ref. - src/lib/plan.ts: pure helpers for checklist progress, derived blocked (a todo/doing item pointing at a still-open question), and status ordering. - /plan browse: count strip, overall progress, status sections (doing/blocked/todo/done) with dropped behind a toggle, area + search filters. - /plan/[slug] detail: checklist progress, cross-refs to /log and /questions, dropped items kept (dimmed, reason, retired-by link). - index.astro rebuilt into the now view (in flight / blocked / up next / open questions + recently decided); Plan nav item added. - global.css: plan-status tokens (dark + light) and component styles, extending the existing brand tokens rather than swapping them. - tests + fixtures: plan render/filter coverage, a plan.ts unit test, and a fixture-check tripwire for plan frontmatter. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wherefore-dashboard/README.md | 8 + .../src/components/PlanRow.astro | 39 +++ .../wherefore-dashboard/src/content.config.ts | 41 ++- .../src/layouts/Base.astro | 1 + packages/wherefore-dashboard/src/lib/plan.ts | 58 ++++ .../wherefore-dashboard/src/pages/index.astro | 205 +++++++++++---- .../wherefore-dashboard/src/pages/plan.astro | 158 +++++++++++ .../src/pages/plan/[slug].astro | 108 ++++++++ .../wherefore-dashboard/src/styles/global.css | 247 ++++++++++++++++++ .../tests/fixture-check.test.mjs | 45 ++++ .../plan/P-001-checkout-rate-limiter.md | 17 ++ .../plan/P-002-paginate-catalog-search.md | 10 + .../plan/P-003-choose-rate-limit-key.md | 15 ++ .../plan/P-004-billing-webhook-retries.md | 16 ++ .../wherefore/plan/P-005-per-tenant-schema.md | 12 + .../wherefore/plan/P-006-spike-limit-key.md | 15 ++ .../tests/fixtures/wherefore/plan/README.md | 4 + .../wherefore-dashboard/tests/plan.test.mjs | 57 ++++ .../wherefore-dashboard/tests/render.test.mjs | 123 ++++++++- 19 files changed, 1127 insertions(+), 52 deletions(-) create mode 100644 packages/wherefore-dashboard/src/components/PlanRow.astro create mode 100644 packages/wherefore-dashboard/src/lib/plan.ts create mode 100644 packages/wherefore-dashboard/src/pages/plan.astro create mode 100644 packages/wherefore-dashboard/src/pages/plan/[slug].astro create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md create mode 100644 packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md create mode 100644 packages/wherefore-dashboard/tests/plan.test.mjs diff --git a/packages/wherefore-dashboard/README.md b/packages/wherefore-dashboard/README.md index 5d32659..b8e4784 100644 --- a/packages/wherefore-dashboard/README.md +++ b/packages/wherefore-dashboard/README.md @@ -100,6 +100,14 @@ See the [hosting guide](https://github.com/DustinVK/wherefore/blob/main/packages The CLI reads your `wherefore/` directory in place and emits a static [Astro](https://astro.build) site. Your decision log stays as plain markdown in your repo; the dashboard is just a renderer you point at it. +It renders these views: + +- **Now** (home) -- where the project stands: plan items in flight, anything blocked on an open question, what is queued up next, plus open questions and recent decisions. +- **Log** -- the decision log (`wherefore/log/`), filterable by area, topic, and status, with a reading view per entry. +- **Plan** -- forward-looking work items (`wherefore/plan/P-*.md`) grouped by status (doing / blocked / todo / done, with dropped items kept behind a toggle). `blocked` is derived: an item is blocked while it points at a still-open question. Each item has a detail page with its checklist progress and links to the decisions and questions it references. +- **Questions** -- open and resolved questions (`wherefore/questions/`). +- **Tags** -- the area and topic vocabulary with per-tag decision counts. + The `wherefore/` directory is produced by the [wherefore plugin](https://github.com/DustinVK/wherefore) for Claude Code. ## License diff --git a/packages/wherefore-dashboard/src/components/PlanRow.astro b/packages/wherefore-dashboard/src/components/PlanRow.astro new file mode 100644 index 0000000..8a8456f --- /dev/null +++ b/packages/wherefore-dashboard/src/components/PlanRow.astro @@ -0,0 +1,39 @@ +--- +// The plan collection's list-row unit: status dot, title, optional milestone tag, +// slim progress bar + n/m, right-aligned status label. Reused across the /plan +// browse sections and the now view's "Up next" list. `blocked` is derived (an +// open question_ref), so it overrides the dot and label without being a status. +interface Props { + id: string; + status: 'todo' | 'doing' | 'done' | 'dropped'; + blocked: boolean; + title: string; + area: string | null; + milestone: string | null; + done: number; + total: number; +} + +const { id, status, blocked, title, area, milestone, done, total } = Astro.props; +const dotClass = blocked ? 'is-blocked' : `is-${status}`; +const label = blocked ? 'blocked' : status; +const pct = total > 0 ? Math.round((done / total) * 100) : 0; +--- +
  • + + {title} + {milestone && {milestone}} + {total > 0 && ( + + + {done}/{total} + + )} + {label} +
  • diff --git a/packages/wherefore-dashboard/src/content.config.ts b/packages/wherefore-dashboard/src/content.config.ts index e6dfdf0..5e21bc8 100644 --- a/packages/wherefore-dashboard/src/content.config.ts +++ b/packages/wherefore-dashboard/src/content.config.ts @@ -55,4 +55,43 @@ const questions = defineCollection({ }), }); -export const collections = { log, questions }; +const plan = defineCollection({ + // Glob P-*.md (not *.md) so plan/README.md and any other non-item doc are skipped. + // ID derives from the id: frontmatter (P-NNN is authoritative), same as questions. + loader: glob({ + pattern: 'P-*.md', + base: `${SRC}/plan`, + generateId: ({ entry, data }) => + data.id ? String(data.id) : entry.replace(/\.md$/, ''), + }), + schema: z.object({ + title: z.string(), + status: z.enum(['todo', 'doing', 'done', 'dropped']).default('todo'), + created: yamlDate, + updated: yamlDateNullable, + area: z.string().nullable().default(null), // single area, unlike log/questions' areas[] + topics: z.array(z.string()).default([]), + milestone: z.string().nullable().default(null), + decision_ref: z.string().nullable().default(null), + question_ref: z.string().nullable().default(null), + answers: z.string().nullable().default(null), + dropped_reason: z.string().nullable().default(null), + }).transform(d => ({ + title: d.title, + status: d.status, + created: d.created, + updated: d.updated, + area: d.area || null, + topics: d.topics, + milestone: d.milestone || null, + // decision_ref may be a single slug or a comma-separated list, mirroring supersedes. + decisionRefs: d.decision_ref + ? d.decision_ref.split(',').map(s => s.trim()).filter(Boolean) + : [], + questionRef: d.question_ref || null, + answers: d.answers || null, + droppedReason: d.dropped_reason || null, + })), +}); + +export const collections = { log, questions, plan }; diff --git a/packages/wherefore-dashboard/src/layouts/Base.astro b/packages/wherefore-dashboard/src/layouts/Base.astro index 783a10d..3b83f84 100644 --- a/packages/wherefore-dashboard/src/layouts/Base.astro +++ b/packages/wherefore-dashboard/src/layouts/Base.astro @@ -15,6 +15,7 @@ const ogImage = `${siteBase}/og-card.png`; const nav = [ { href: '/log', label: 'Log' }, + { href: '/plan', label: 'Plan' }, { href: '/questions', label: 'Questions' }, { href: '/tags', label: 'Tags' }, ]; diff --git a/packages/wherefore-dashboard/src/lib/plan.ts b/packages/wherefore-dashboard/src/lib/plan.ts new file mode 100644 index 0000000..109bf96 --- /dev/null +++ b/packages/wherefore-dashboard/src/lib/plan.ts @@ -0,0 +1,58 @@ +// Pure, Astro-free helpers for the plan collection. Kept framework-free so the +// unit tests import this TypeScript source directly (Node native type stripping). +// +// Two derived facts live here, not in frontmatter: +// - checklist progress, counted from GFM task lines in the body +// - "blocked", true when a todo/doing item points at a still-open question +// and the status ordering the now view and browse page sort rows by. + +export type PlanStatus = 'todo' | 'doing' | 'done' | 'dropped'; + +/** Status buckets in display order; `blocked` is derived, so it is not one of these. */ +export const PLAN_STATUS_ORDER: PlanStatus[] = ['doing', 'todo', 'done', 'dropped']; + +export interface PlanFacet { + status: PlanStatus; + blocked: boolean; +} + +/** + * Checklist progress from a plan item body. Counts GFM task list lines: + * `- [ ]` / `- [x]` (and `*` bullets). Returns { done: 0, total: 0 } for + * prose-only bodies, which callers use to hide the progress bar. + */ +export function taskProgress(body: string): { done: number; total: number } { + const total = body.match(/^[ \t]*[-*] \[[ xX]\]/gm) ?? []; + const done = body.match(/^[ \t]*[-*] \[[xX]\]/gm) ?? []; + return { done: done.length, total: total.length }; +} + +/** + * Derived blocked state: an item is blocked only while it is todo/doing AND its + * single question_ref points at a question that is still open. Done/dropped items + * are never blocked, and an item carrying `answers` (a spike) is not blocked here + * because that relationship lives on a different field. + */ +export function isBlocked( + item: { status: PlanStatus; questionRef: string | null }, + questionStatusById: Map, +): boolean { + if (item.status !== 'todo' && item.status !== 'doing') return false; + if (!item.questionRef) return false; + return questionStatusById.get(item.questionRef) === 'open'; +} + +const STATUS_RANK: Record = { doing: 0, todo: 2, done: 3, dropped: 4 }; + +/** + * Priority rank: doing (0) -> blocked (1) -> todo (2) -> done (3) -> dropped (4). + * Active work floats up, blocked sits just under in-flight, dropped sinks. + */ +export function planRank(facet: PlanFacet): number { + return facet.blocked ? 1 : STATUS_RANK[facet.status]; +} + +/** Comparator over PlanFacet implementing the priority order above. */ +export function byPlanOrder(a: PlanFacet, b: PlanFacet): number { + return planRank(a) - planRank(b); +} diff --git a/packages/wherefore-dashboard/src/pages/index.astro b/packages/wherefore-dashboard/src/pages/index.astro index b76463e..4b4d9a6 100644 --- a/packages/wherefore-dashboard/src/pages/index.astro +++ b/packages/wherefore-dashboard/src/pages/index.astro @@ -1,71 +1,178 @@ --- -import { getCollection } from 'astro:content'; +import { getCollection, render } from 'astro:content'; import Base from '../layouts/Base.astro'; +import PlanRow from '../components/PlanRow.astro'; +import { taskProgress, isBlocked } from '../lib/plan'; -const allEntries = await getCollection('log'); +const allPlan = await getCollection('plan'); +const allLog = await getCollection('log'); const allQuestions = await getCollection('questions'); -const activeEntries = allEntries.filter(e => e.data.status === 'active'); +const questionStatusById = new Map(allQuestions.map(q => [q.id, q.data.status])); +const questionById = new Map(allQuestions.map(q => [q.id, q])); + +const cards = allPlan.map(p => ({ + entry: p, + id: p.id, + data: p.data, + blocked: isBlocked({ status: p.data.status, questionRef: p.data.questionRef }, questionStatusById), + progress: taskProgress(p.body ?? ''), +})); +type Card = (typeof cards)[number]; + +const recency = (c: Card) => c.data.updated ?? c.data.created; +const byRecency = (a: Card, b: Card) => recency(b).localeCompare(recency(a)); + +const inFlight = cards.filter(c => c.data.status === 'doing' && !c.blocked).sort(byRecency); +const blocked = cards.filter(c => c.blocked).sort(byRecency); +const upNext = cards.filter(c => c.data.status === 'todo' && !c.blocked).sort(byRecency); + +// The checklist is the live todo list for an in-flight item, so render its body. +const inFlightRendered = await Promise.all( + inFlight.map(async c => ({ ...c, Content: (await render(c.entry)).Content })), +); + const openQuestions = allQuestions .filter(q => q.data.status === 'open') .sort((a, b) => b.data.asked_date.localeCompare(a.data.asked_date)); -const recentEntries = activeEntries +const recentDecisions = allLog + .filter(e => e.data.status === 'active') .sort((a, b) => b.data.date.localeCompare(a.data.date)) .slice(0, 5); - -const retiredCount = allEntries.filter(e => e.data.status !== 'active').length; -const parts = [ - `${activeEntries.length} decisions`, - retiredCount > 0 ? `${retiredCount} retired` : null, - `${openQuestions.length} open questions`, -].filter(Boolean); --- -

    {parts.join(' · ')}

    +
    + {inFlight.length} in flight + {blocked.length} blocked + {upNext.length} queued + {openQuestions.length} open +
    -
    -
    - Recent decisions - All decisions → + {inFlight.length > 0 && ( +
    +
    + In flight + All plan items → +
    +
    + {inFlightRendered.map(c => { + const { Content } = c; + const pct = c.progress.total > 0 ? Math.round((c.progress.done / c.progress.total) * 100) : 0; + return ( +
    +
    + doing + {c.data.milestone && {c.data.milestone}} +
    + {c.data.title} + {c.progress.total > 0 && ( +
    + + {c.progress.done} of {c.progress.total} +
    + )} +
    +
    + ); + })} +
    - {recentEntries.length === 0 ? ( -

    No entries yet.

    - ) : ( -
      - {recentEntries.map(e => ( -
    • - {e.data.title} -
    • + ); + })} +
    +
    + )} + + {upNext.length > 0 && ( +
    +
    Up next
    +
      + {upNext.map(c => ( + ))}
    - )} -
    + + )} -
    -
    - Open questions - All questions → + diff --git a/packages/wherefore-dashboard/src/pages/plan.astro b/packages/wherefore-dashboard/src/pages/plan.astro new file mode 100644 index 0000000..f26aedb --- /dev/null +++ b/packages/wherefore-dashboard/src/pages/plan.astro @@ -0,0 +1,158 @@ +--- +import { getCollection } from 'astro:content'; +import Base from '../layouts/Base.astro'; +import PlanRow from '../components/PlanRow.astro'; +import { taskProgress, isBlocked } from '../lib/plan'; + +const allPlan = await getCollection('plan'); +const allQuestions = await getCollection('questions'); + +const questionStatusById = new Map(allQuestions.map(q => [q.id, q.data.status])); + +const cards = allPlan.map(p => ({ + id: p.id, + data: p.data, + blocked: isBlocked({ status: p.data.status, questionRef: p.data.questionRef }, questionStatusById), + progress: taskProgress(p.body ?? ''), +})); +type Card = (typeof cards)[number]; + +// Newest first within a section: `updated` when present (bumped on any write), +// else `created`. IDs are sequential too, so this also reads as newest-item-first. +const recency = (c: Card) => c.data.updated ?? c.data.created; +const byRecency = (a: Card, b: Card) => recency(b).localeCompare(recency(a)); + +const inFlight = cards.filter(c => c.data.status === 'doing' && !c.blocked).sort(byRecency); +const blocked = cards.filter(c => c.blocked).sort(byRecency); +const upNext = cards.filter(c => c.data.status === 'todo' && !c.blocked).sort(byRecency); +const done = cards.filter(c => c.data.status === 'done').sort(byRecency); +const dropped = cards.filter(c => c.data.status === 'dropped').sort(byRecency); + +const nonDropped = cards.filter(c => c.data.status !== 'dropped'); +const overallPct = nonDropped.length ? Math.round((done.length / nonDropped.length) * 100) : 0; + +const allAreas = [...new Set(allPlan.map(p => p.data.area).filter((a): a is string => !!a))].sort(); + +const sections = [ + { key: 'in-flight', head: 'In flight', items: inFlight, dropped: false }, + { key: 'blocked', head: 'Blocked', items: blocked, dropped: false }, + { key: 'up-next', head: 'Up next', items: upNext, dropped: false }, + { key: 'done', head: 'Done', items: done, dropped: false }, + { key: 'dropped', head: 'Dropped', items: dropped, dropped: true }, +].filter(s => s.items.length > 0); +--- + + {allPlan.length === 0 ? ( +

    No plan items yet.

    + ) : ( + <> +
    + {inFlight.length} doing + {blocked.length} blocked + {upNext.length} todo + {done.length} done + {dropped.length > 0 && ( + {dropped.length} dropped + )} +
    + +
    + + {done.length} of {nonDropped.length} done · dropped excluded +
    + +
    + {allAreas.length > 0 && ( + + )} + + {dropped.length > 0 && ( + <> + + + + )} +
    + +

    No matching plan items.

    + + {sections.map(section => ( + + ))} + + )} + + + diff --git a/packages/wherefore-dashboard/src/pages/plan/[slug].astro b/packages/wherefore-dashboard/src/pages/plan/[slug].astro new file mode 100644 index 0000000..231f7c1 --- /dev/null +++ b/packages/wherefore-dashboard/src/pages/plan/[slug].astro @@ -0,0 +1,108 @@ +--- +import { getCollection, render } from 'astro:content'; +import Base from '../../layouts/Base.astro'; +import { taskProgress, isBlocked } from '../../lib/plan'; + +export async function getStaticPaths() { + const items = await getCollection('plan'); + return items.map(e => ({ params: { slug: e.id }, props: { entry: e } })); +} + +const { entry } = Astro.props; +const { Content } = await render(entry); + +const allLog = await getCollection('log'); +const allQuestions = await getCollection('questions'); +const logTitleById = new Map(allLog.map(e => [e.id, e.data.title])); +const questionById = new Map(allQuestions.map(q => [q.id, q])); +const questionStatusById = new Map(allQuestions.map(q => [q.id, q.data.status])); + +const d = entry.data; +const blocked = isBlocked({ status: d.status, questionRef: d.questionRef }, questionStatusById); +const progress = taskProgress(entry.body ?? ''); +const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0; + +const dotClass = blocked ? 'is-blocked' : `is-${d.status}`; +const label = blocked ? 'blocked' : d.status; + +const decisions = d.decisionRefs.map(slug => ({ slug, title: logTitleById.get(slug) ?? slug })); +const blockingQuestion = d.questionRef ? questionById.get(d.questionRef) ?? null : null; +const answeredQuestion = d.answers ? questionById.get(d.answers) ?? null : null; +const decisionLabel = d.status === 'dropped' ? 'Retired by' : 'Decision'; +--- + + back to plan + + {d.status === 'dropped' && ( +
    + Dropped{d.updated ? ` on ${d.updated}` : ''}. Kept for history, not current. +
    + )} + + {blocked && ( +
    + Blocked on {d.questionRef}{blockingQuestion ? `: ${blockingQuestion.data.question}` : ''} +
    + )} + +
    +
    + +

    {entry.data.title}

    + {label} +
    + +
    + {d.area && {d.area}} + {d.topics.map(t => {t})} + {d.milestone && {d.milestone}} + created {d.created}{d.updated ? ` · updated ${d.updated}` : ''} +
    + + {progress.total > 0 && ( +
    + + {progress.done} of {progress.total} done +
    + )} + + {entry.body?.trim() && ( +
    + )} + + {d.droppedReason && ( +
    +
    Dropped reason
    +

    {d.droppedReason}

    +
    + )} + + {(decisions.length > 0 || blockingQuestion || answeredQuestion || d.questionRef || d.answers) && ( +
    +
    References
    +
      + {decisions.map(dec => ( +
    • + {decisionLabel} + {dec.title} +
    • + ))} + {d.questionRef && ( +
    • + Blocked by + {d.questionRef} + {blockingQuestion && {blockingQuestion.data.question}} +
    • + )} + {d.answers && ( +
    • + Answers + {d.answers} + {answeredQuestion && {answeredQuestion.data.question}} +
    • + )} +
    +
    + )} +
    + diff --git a/packages/wherefore-dashboard/src/styles/global.css b/packages/wherefore-dashboard/src/styles/global.css index e38a541..1034b7f 100644 --- a/packages/wherefore-dashboard/src/styles/global.css +++ b/packages/wherefore-dashboard/src/styles/global.css @@ -883,3 +883,250 @@ html[data-theme="light"] .theme-toggle::before { content: '●'; } padding:3px 8px; border-radius:6px; } .wf-q .txt { font-size:14px; line-height:1.55; color:var(--wf-soft); } + +/* ============================================================ + Plan collection (/plan browse, /plan/[slug] detail, now view) + Extends the brand tokens above with plan-status colors; the + status vocabulary (todo/doing/done/dropped + derived blocked) + is separate from the log's active/superseded/obsolete. + ============================================================ */ + +:root { + --plan-doing: var(--teal-bright); + --plan-blocked: #c48a6a; /* warm, desaturated marker (not a 2nd brand color) */ + --plan-blocked-tint: rgba(196,138,106,.12); + --plan-blocked-bd: rgba(196,138,106,.32); + --plan-track: #0f1014; /* progress-bar track */ +} + +@media (prefers-color-scheme: light) { + html:not([data-theme="dark"]) { + --plan-doing: var(--teal-dk); + --plan-blocked: #a8623f; + --plan-blocked-tint: rgba(168,98,63,.12); + --plan-blocked-bd: rgba(168,98,63,.34); + --plan-track: #ece9e2; + } +} + +html[data-theme="light"] { + --plan-doing: var(--teal-dk); + --plan-blocked: #a8623f; + --plan-blocked-tint: rgba(168,98,63,.12); + --plan-blocked-bd: rgba(168,98,63,.34); + --plan-track: #ece9e2; +} + +/* ── Count strip (browse header + now-view counts) ───────────── */ +.count-strip { + display: flex; + flex-wrap: wrap; + gap: 18px; + font-family: var(--mono); + font-size: 12px; + color: var(--faint); + margin-bottom: 16px; +} +.count { display: inline-flex; align-items: center; gap: 6px; } +.count b { color: var(--soft); font-weight: 500; } +.count.is-dropped { opacity: .6; } +.now-counts { margin-bottom: 24px; } + +/* ── Status dot ──────────────────────────────────────────────── */ +.status-dot { + width: 8px; height: 8px; border-radius: 50%; + flex: none; display: inline-block; + background: var(--faint2); +} +.status-dot.is-doing { background: var(--plan-doing); } +.status-dot.is-blocked { background: var(--plan-blocked); } +.status-dot.is-todo { background: transparent; box-shadow: inset 0 0 0 1.5px var(--faint); } +.status-dot.is-done { background: var(--faint); } +.status-dot.is-dropped { background: transparent; box-shadow: inset 0 0 0 1.5px var(--faint2); } +.status-dot.is-open { background: var(--muted); } + +/* ── Overall progress bar (browse header) ────────────────────── */ +.overall { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } +.overall-track { + flex: 1; max-width: 320px; height: 5px; border-radius: 3px; + background: var(--plan-track); overflow: hidden; +} +.overall-fill { display: block; height: 100%; background: var(--plan-doing); border-radius: 3px; } +.overall-label { font-family: var(--mono); font-size: 11.5px; color: var(--faint); } + +/* ── Sections + list ─────────────────────────────────────────── */ +.plan-section { margin-top: 26px; } +.plan-section[hidden] { display: none; } +.plan-list { list-style: none; } + +/* ── List row (density unit) ─────────────────────────────────── */ +.plan-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-top: 1px solid var(--border-soft); +} +.plan-list > .plan-row:first-child { border-top: none; } +.plan-row[hidden] { display: none; } + +.plan-row-title { + flex: 1 1 auto; + min-width: 0; + font-family: var(--display); + font-size: 15px; + font-weight: 500; + color: var(--text); + letter-spacing: -.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.plan-row-title:hover { color: var(--teal-tag); } + +.row-dropped { opacity: .5; } +.row-dropped .plan-row-title { + text-decoration: line-through; + text-decoration-color: var(--faint2); + color: var(--muted); +} + +/* ── Milestone tag ───────────────────────────────────────────── */ +.milestone-tag { + flex: none; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + background: var(--surface-faint); + border-radius: 4px; + padding: 2px 7px; +} + +/* ── Progress bar (row + card) ───────────────────────────────── */ +.plan-progress { flex: none; display: inline-flex; align-items: center; gap: 8px; } +.plan-progress-track { + width: 64px; height: 4px; border-radius: 2px; + background: var(--plan-track); overflow: hidden; +} +.plan-progress-fill { display: block; height: 100%; background: var(--plan-doing); border-radius: 2px; } +.progress-count { font-family: var(--mono); font-size: 11px; color: var(--faint); } + +/* ── Status label (right-aligned on rows) ────────────────────── */ +.plan-status-label { + flex: none; + font-family: var(--mono); + font-size: 11px; + letter-spacing: .04em; + color: var(--faint); + min-width: 58px; + text-align: right; +} +.plan-status-label.is-doing { color: var(--plan-doing); } +.plan-status-label.is-blocked { color: var(--plan-blocked); } + +/* ── In-flight cards (now view) ──────────────────────────────── */ +.plan-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; } +.plan-card { + border: 1px solid var(--border-soft); + border-left: 2px solid var(--plan-doing); + border-radius: var(--radius); + background: var(--surface-faint); + padding: 16px 18px; +} +.plan-card-eyebrow { + display: flex; align-items: center; gap: 8px; + font-family: var(--mono); font-size: 10.5px; + text-transform: uppercase; letter-spacing: .12em; + color: var(--plan-doing); + margin-bottom: 8px; +} +.plan-card-title { + display: block; + font-family: var(--display); + font-size: 16px; font-weight: 500; + color: var(--text); letter-spacing: -.01em; +} +.plan-card-title:hover { color: var(--teal-tag); } + +.detail-progress { display: flex; align-items: center; gap: 10px; margin: 12px 0; } +.detail-progress .plan-progress-track { width: 140px; } + +.plan-body { max-width: none; } +.plan-card-body { margin-top: 6px; } +.plan-card-body p, .plan-card-body li { font-size: 14px; } + +/* GFM task lists rendered from plan bodies */ +.plan-body ul.contains-task-list { list-style: none; padding-left: 0; } +.plan-body .task-list-item { display: flex; align-items: baseline; gap: 8px; } +.plan-body .task-list-item input[type="checkbox"] { margin: 0; accent-color: var(--teal); } +.plan-body .task-list-item:has(input:checked) { + color: var(--faint); + text-decoration: line-through; + text-decoration-color: var(--faint2); +} + +/* ── Blocked cards (now view) ────────────────────────────────── */ +.blocked-cards { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; } +.blocked-card { + border: 1px solid var(--border-soft); + border-left: 2px solid var(--plan-blocked); + border-radius: var(--radius); + padding: 14px 16px; +} +.blocked-card-head { display: flex; align-items: center; gap: 10px; } +.blocked-q { display: flex; gap: 10px; margin-top: 10px; padding: 10px 12px; background: var(--surface-faint); border-radius: 7px; } +.q-marker { flex: none; font-family: var(--mono); font-weight: 600; color: var(--plan-blocked); } +.q-text-inline { font-size: 14px; color: var(--soft); line-height: 1.5; } +.q-meta-inline { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 3px; } + +/* ── Now-view footer (two columns) ───────────────────────────── */ +.now-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } + +/* ── Plan detail page ────────────────────────────────────────── */ +.plan-detail { max-width: 660px; } +.plan-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.plan-detail-head .detail-title { margin-bottom: 0; } +.plan-detail.is-dropped { opacity: .72; } +.plan-detail.is-dropped .detail-title { + text-decoration: line-through; + text-decoration-color: var(--faint2); + color: var(--muted); +} + +.callout-blocked { + border-color: var(--plan-blocked-bd); + background: var(--plan-blocked-tint); + color: var(--plan-blocked); +} +.callout-blocked a { color: var(--text); text-decoration: underline; } + +.detail-sub-label { + font-family: var(--mono); font-size: 11px; + text-transform: uppercase; letter-spacing: .08em; + color: var(--faint); margin: 22px 0 6px; +} +.dropped-reason { margin-top: 20px; padding: 12px 16px; background: var(--surface-faint); border-radius: var(--radius); } +.dropped-reason p { font-size: 14px; color: var(--soft); line-height: 1.55; } + +.cross-refs { margin-top: 26px; } +.ref-list { list-style: none; margin-top: 10px; } +.ref-list li { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; + padding: 8px 0; + border-top: 1px solid var(--border-soft); + font-size: 14px; +} +.ref-kind { + font-family: var(--mono); font-size: 11px; + text-transform: uppercase; letter-spacing: .06em; + color: var(--faint); min-width: 84px; +} +.ref-list a { color: var(--teal-tag); } +.ref-list a:hover { text-decoration: underline; } +.ref-text { color: var(--soft); } + +/* ── Plan responsive ─────────────────────────────────────────── */ +@media (max-width: 640px) { + .plan-cards, .now-footer { grid-template-columns: 1fr; } + .plan-row-title { white-space: normal; } +} diff --git a/packages/wherefore-dashboard/tests/fixture-check.test.mjs b/packages/wherefore-dashboard/tests/fixture-check.test.mjs index 9651c7b..13dac6f 100644 --- a/packages/wherefore-dashboard/tests/fixture-check.test.mjs +++ b/packages/wherefore-dashboard/tests/fixture-check.test.mjs @@ -77,12 +77,57 @@ test('question fixtures -- status values are valid', () => { } }); +const planItemFiles = () => + readdirSync(resolve(FIXTURE, 'plan')).filter(f => f.startsWith('P-') && f.endsWith('.md')); + +test('plan fixtures -- required frontmatter keys', () => { + const required = ['id', 'title', 'status', 'created']; + const files = planItemFiles(); + + assert.ok(files.length >= 4, `Expected >= 4 plan items, got ${files.length}`); + + for (const file of files) { + const keys = frontmatterKeys(readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8')); + for (const key of required) { + assert.ok(keys.includes(key), `${file}: missing frontmatter key "${key}"`); + } + } +}); + +test('plan fixtures -- status values are valid', () => { + const valid = new Set(['todo', 'doing', 'done', 'dropped']); + for (const file of planItemFiles()) { + const content = readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8'); + const match = content.match(/^status:\s*(.+)$/m); + assert.ok(match, `${file}: no status line found`); + const status = match[1].trim(); + assert.ok(valid.has(status), `${file}: invalid status "${status}" (expected todo|doing|done|dropped)`); + } +}); + +test('plan fixtures -- dropped item carries a reason or decision_ref', () => { + for (const file of planItemFiles()) { + const content = readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8'); + const status = content.match(/^status:\s*(.+)$/m)?.[1].trim(); + if (status !== 'dropped') continue; + assert.ok( + /^dropped_reason:\s*\S/m.test(content) || /^decision_ref:\s*\S/m.test(content), + `${file}: a dropped item needs a dropped_reason or decision_ref`, + ); + } +}); + test('fixture counts match expected', () => { const logFiles = readdirSync(resolve(FIXTURE, 'log')).filter(f => f.endsWith('.md')); const qFiles = readdirSync(resolve(FIXTURE, 'questions')).filter(f => f.endsWith('.md')); assert.equal(logFiles.length, 4, `Expected 4 log entries`); assert.equal(qFiles.length, 3, `Expected 3 questions`); + assert.equal(planItemFiles().length, 6, `Expected 6 plan items`); + + // plan/README.md exists but must be excluded from the collection by the P-*.md glob. + const planAll = readdirSync(resolve(FIXTURE, 'plan')).filter(f => f.endsWith('.md')); + assert.ok(planAll.includes('README.md'), 'plan/README.md present (excluded by the P-*.md glob)'); const statuses = logFiles.map(f => { const content = readFileSync(resolve(FIXTURE, 'log', f), 'utf-8'); diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md new file mode 100644 index 0000000..c5ae052 --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md @@ -0,0 +1,17 @@ +--- +id: P-001 +title: Wire up the checkout rate limiter +status: doing +created: 2026-01-02 +updated: 2026-01-05 +area: checkout +topics: [api-design] +milestone: M1 +--- + +Rolling out the per-user limiter behind a flag. + +- [x] add the token-bucket helper +- [x] gate it behind a flag +- [ ] backfill the config for staging +- [ ] flip it on in production diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md new file mode 100644 index 0000000..d76000b --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md @@ -0,0 +1,10 @@ +--- +id: P-002 +title: Paginate the catalog search endpoint +status: todo +created: 2026-01-03 +area: catalog +topics: [performance] +--- + +Cursor pagination for the search results list. Not started yet. diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md new file mode 100644 index 0000000..118a128 --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md @@ -0,0 +1,15 @@ +--- +id: P-003 +title: Choose the checkout rate-limit key +status: doing +created: 2026-01-03 +updated: 2026-01-04 +area: checkout +topics: [api-design] +question_ref: Q-001 +--- + +Blocked until we settle per-user vs per-IP. + +- [ ] pick the key dimension +- [ ] document the limits diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md new file mode 100644 index 0000000..2227df0 --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md @@ -0,0 +1,16 @@ +--- +id: P-004 +title: Ship the billing webhook retries +status: done +created: 2026-01-01 +updated: 2026-01-04 +area: billing +topics: [auth] +decision_ref: 2026-01-01-active-example +--- + +Exponential backoff on webhook delivery. + +- [x] add the retry queue +- [x] cap attempts at 6 +- [x] alert on the dead-letter queue diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md new file mode 100644 index 0000000..820bd6a --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md @@ -0,0 +1,12 @@ +--- +id: P-005 +title: Build a bespoke per-tenant schema +status: dropped +created: 2026-01-02 +updated: 2026-01-06 +area: catalog +decision_ref: 2026-01-03-replacement-example +dropped_reason: Operationally heavy; row-level security gives the same isolation. +--- + +Abandoned in favor of a shared schema with row-level security. diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md new file mode 100644 index 0000000..0229f15 --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md @@ -0,0 +1,15 @@ +--- +id: P-006 +title: Spike per-user vs per-IP limiting +status: doing +created: 2026-01-04 +updated: 2026-01-05 +area: checkout +topics: [performance] +answers: Q-001 +--- + +A spike to answer Q-001; the checkboxes are the questions to settle, not steps. + +- [x] measure the per-IP false-positive rate on shared NAT +- [ ] measure the per-user cost at the session store diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md new file mode 100644 index 0000000..34ff68a --- /dev/null +++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md @@ -0,0 +1,4 @@ +# Plan collection (fixture) + +Fixture data for the dashboard tests. This file must never become a route: the +loader globs `P-*.md`, so `README.md` is deliberately excluded. diff --git a/packages/wherefore-dashboard/tests/plan.test.mjs b/packages/wherefore-dashboard/tests/plan.test.mjs new file mode 100644 index 0000000..bd4a2f4 --- /dev/null +++ b/packages/wherefore-dashboard/tests/plan.test.mjs @@ -0,0 +1,57 @@ +// Unit tests for the pure plan helpers. Imports the TypeScript source directly; +// Node's native type stripping (>=22.18) handles it, no build. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + taskProgress, + isBlocked, + planRank, + byPlanOrder, + PLAN_STATUS_ORDER, +} from '../src/lib/plan.ts'; + +test('taskProgress counts GFM task lines (checked / total)', () => { + const body = [ + 'intro prose', + '- [x] done one', + '- [ ] todo one', + '- [X] done two (capital X)', + '* [ ] star-bullet todo', + '- regular bullet, not a task', + ].join('\n'); + assert.deepEqual(taskProgress(body), { done: 2, total: 4 }); +}); + +test('taskProgress returns zero for a prose-only body', () => { + assert.deepEqual(taskProgress('just prose, no checkboxes here'), { done: 0, total: 0 }); +}); + +test('isBlocked: only a todo/doing item with a still-open question_ref', () => { + const q = new Map([['Q-001', 'open'], ['Q-002', 'resolved']]); + assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-001' }, q), true); + assert.equal(isBlocked({ status: 'todo', questionRef: 'Q-001' }, q), true); + assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-002' }, q), false, 'resolved question does not block'); + assert.equal(isBlocked({ status: 'done', questionRef: 'Q-001' }, q), false, 'done is never blocked'); + assert.equal(isBlocked({ status: 'dropped', questionRef: 'Q-001' }, q), false, 'dropped is never blocked'); + assert.equal(isBlocked({ status: 'doing', questionRef: null }, q), false); + assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-404' }, q), false, 'unknown question does not block'); +}); + +test('planRank / byPlanOrder: doing -> blocked -> todo -> done -> dropped', () => { + const facets = [ + { status: 'dropped', blocked: false }, + { status: 'done', blocked: false }, + { status: 'todo', blocked: false }, + { status: 'doing', blocked: true }, // blocked outranks a plain todo + { status: 'doing', blocked: false }, + ]; + const ordered = [...facets].sort(byPlanOrder).map(f => (f.blocked ? 'blocked' : f.status)); + assert.deepEqual(ordered, ['doing', 'blocked', 'todo', 'done', 'dropped']); + assert.equal(planRank({ status: 'doing', blocked: false }), 0); + assert.equal(planRank({ status: 'doing', blocked: true }), 1); +}); + +test('PLAN_STATUS_ORDER excludes the derived blocked state', () => { + assert.deepEqual(PLAN_STATUS_ORDER, ['doing', 'todo', 'done', 'dropped']); +}); diff --git a/packages/wherefore-dashboard/tests/render.test.mjs b/packages/wherefore-dashboard/tests/render.test.mjs index 78222d4..e2d2d59 100644 --- a/packages/wherefore-dashboard/tests/render.test.mjs +++ b/packages/wherefore-dashboard/tests/render.test.mjs @@ -38,9 +38,12 @@ test('build emits no duplicate-id warnings', () => { // ---- static render --------------------------------------------------------- -test('index: summary math and active-only lists', () => { +test('index (now view): counts and active/open-only footers', () => { const d = parse('index.html'); - assert.equal(text(d.querySelector('.summary-line')), '2 decisions · 2 retired · 1 open questions'); + + // Header count strip: in flight (doing not blocked)=2, blocked=1, queued (todo)=1, open=1 + const counts = [...d.querySelectorAll('.now-counts .count')].map(text); + assert.deepEqual(counts, ['2 in flight', '1 blocked', '1 queued', '1 open']); const titles = [...d.querySelectorAll('.entry-list .entry-title')].map(text); assert.ok(titles.includes('Active example decision')); @@ -53,6 +56,22 @@ test('index: summary math and active-only lists', () => { assert.ok(!qids.some((t) => t.startsWith('Q-002')), 'resolved question must not be listed'); }); +test('index (now view): in-flight cards and blocked card with inline question', () => { + const d = parse('index.html'); + + const cardTitles = [...d.querySelectorAll('.plan-card-title')].map(text); + assert.ok(cardTitles.includes('Wire up the checkout rate limiter'), 'doing item is in flight'); + assert.ok(cardTitles.includes('Spike per-user vs per-IP limiting'), 'spike (answers) is in flight, not blocked'); + assert.ok(cardTitles.includes('Choose the checkout rate-limit key'), 'blocked item shown in its own card'); + + const blockedCard = d.querySelector('.blocked-card'); + assert.match(text(blockedCard), /Choose the checkout rate-limit key/); + assert.match(text(blockedCard.querySelector('.blocked-q')), /rate-limit the checkout API/, 'blocking question rendered inline'); + + const upNext = [...d.querySelectorAll('.plan-list .plan-row-title')].map(text); + assert.ok(upNext.includes('Paginate the catalog search endpoint'), 'todo item queued under Up next'); +}); + test('log entry page: superseded callout links to its replacement', () => { const d = parse('log/2026-01-02-superseded-example/index.html'); const callout = d.querySelector('.callout'); @@ -144,6 +163,86 @@ test('tags page: counts exclude retired, zero-count tags shown', () => { assert.deepEqual(countIn('.tags-col-topics', 'auth'), { count: 0, zero: true }); }); +// ---- plan collection ------------------------------------------------------- + +test('plan browse: count strip, overall progress, and section membership', () => { + const d = parse('plan/index.html'); + + const counts = [...d.querySelectorAll('.count-strip .count')].map(text); + assert.deepEqual(counts, ['2 doing', '1 blocked', '1 todo', '1 done', '1 dropped']); + + // Overall progress excludes dropped: 1 done of the 5 non-dropped items. + assert.match(text(d.querySelector('.overall-label')), /1 of 5 done/); + + const sectionTitles = (key) => { + const sec = d.querySelector(`.plan-section[data-section="${key}"]`); + return [...sec.querySelectorAll('.plan-row-title')].map(text).sort(); + }; + assert.deepEqual(sectionTitles('in-flight'), + ['Spike per-user vs per-IP limiting', 'Wire up the checkout rate limiter']); + assert.deepEqual(sectionTitles('blocked'), ['Choose the checkout rate-limit key']); + assert.deepEqual(sectionTitles('up-next'), ['Paginate the catalog search endpoint']); + assert.deepEqual(sectionTitles('done'), ['Ship the billing webhook retries']); + assert.deepEqual(sectionTitles('dropped'), ['Build a bespoke per-tenant schema']); +}); + +test('plan browse: blocked derived from open question_ref; answers does not block', () => { + const d = parse('plan/index.html'); + const rowByTitle = (t) => + [...d.querySelectorAll('.plan-row')].find((r) => text(r.querySelector('.plan-row-title')) === t); + + // P-003: status doing but question_ref -> open Q-001, so it derives blocked. + const p3 = rowByTitle('Choose the checkout rate-limit key'); + assert.equal(p3.dataset.blocked, 'true'); + assert.ok(p3.querySelector('.status-dot').classList.contains('is-blocked')); + assert.equal(text(p3.querySelector('.plan-status-label')), 'blocked'); + + // P-006: answers Q-001 (a spike) is the opposite relation, not blocked. + const p6 = rowByTitle('Spike per-user vs per-IP limiting'); + assert.equal(p6.dataset.blocked, 'false'); + assert.ok(p6.querySelector('.status-dot').classList.contains('is-doing')); + + // Checklist progress renders n/m from the body. + assert.equal(text(rowByTitle('Wire up the checkout rate limiter').querySelector('.progress-count')), '2/4'); +}); + +test('plan browse: README is excluded by the P-*.md glob', () => { + const d = parse('plan/index.html'); + const rows = [...d.querySelectorAll('.plan-row')]; + assert.equal(rows.length, 6, 'exactly the 6 P-*.md items, plan/README.md not collected'); + assert.ok(!rows.map((r) => text(r.querySelector('.plan-row-title'))).some((t) => /readme|fixture/i.test(t))); +}); + +test('plan detail: progress, metadata, and decision cross-reference', () => { + const d = parse('plan/P-004/index.html'); + assert.equal(text(d.querySelector('.detail-title')), 'Ship the billing webhook retries'); + assert.match(text(d.querySelector('.progress-count')), /3 of 3 done/); + assert.ok(d.querySelector('.plan-detail-head .status-dot').classList.contains('is-done')); + + const ref = d.querySelector('.ref-list a'); + assert.equal(ref.getAttribute('href'), '/log/2026-01-01-active-example'); + assert.equal(text(ref), 'Active example decision', 'decision_ref resolves to the log title'); +}); + +test('plan detail: blocked item callout links its open question', () => { + const d = parse('plan/P-003/index.html'); + const callout = d.querySelector('.callout-blocked'); + assert.ok(callout, 'blocked callout present'); + assert.match(text(callout), /Q-001/); + assert.match(text(callout), /rate-limit the checkout API/, 'question text surfaced inline'); +}); + +test('plan detail: dropped item shown (not hidden) with reason and retired-by link', () => { + const d = parse('plan/P-005/index.html'); + assert.ok(d.querySelector('.plan-detail.is-dropped'), 'dropped items are dimmed, not removed'); + assert.ok(d.querySelector('.callout.callout-obs'), 'dropped callout'); + assert.match(text(d.querySelector('.dropped-reason')), /row-level security/); + + const kinds = [...d.querySelectorAll('.ref-kind')].map(text); + assert.ok(kinds.includes('Retired by'), 'decision_ref reads as "Retired by" on a dropped item'); + assert.equal(d.querySelector('.ref-list a').getAttribute('href'), '/log/2026-01-03-replacement-example'); +}); + // ---- client-side filters (scripts executed by jsdom) ----------------------- test('questions filter: resolved tab and no-results search', () => { @@ -208,3 +307,23 @@ test('log filter: ?area= URL param pre-selects and actually filters', () => { assert.ok(shown[0].dataset.areas.split(',').includes('catalog')); assert.equal(shown[0].dataset.status, 'active'); }); + +test('plan filter: dropped hidden by default, shown via toggle, and area narrows', () => { + const { window } = new JSDOM(html('plan/index.html'), { runScripts: 'dangerously' }); + const d = window.document; + + const droppedSection = d.querySelector('.plan-section[data-section="dropped"]'); + assert.ok(droppedSection.hidden, 'dropped section hidden on load'); + + const toggle = d.getElementById('dropped-toggle'); + toggle.checked = true; + toggle.dispatchEvent(new window.Event('input', { bubbles: true })); + assert.ok(!droppedSection.hidden, 'dropped section shown after toggling show-dropped'); + + const area = d.getElementById('plan-area'); + area.value = 'billing'; + area.dispatchEvent(new window.Event('input', { bubbles: true })); + const shown = visible([...d.querySelectorAll('.plan-row')]); + assert.equal(shown.length, 1, 'only the billing item matches'); + assert.equal(text(shown[0].querySelector('.plan-row-title')), 'Ship the billing webhook retries'); +}); From 1cffdb4ae3f6c4b537ae687899ad58b3104dfe43 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Sun, 19 Jul 2026 00:32:07 -0400 Subject: [PATCH 2/4] wherefore: record the dashboard plan-views decision Dogfood the plan collection while building it. - log: capture 2026-07-19-dashboard-plan-collection (shipped-schema over the handoff's, build-time blocked derivation, status grouping, extend-tokens). - plan: P-001 and P-002 advanced to done; P-002 decision_ref fixed (dropped a dangling slug, linked the originating decision and the new record); open P-011 for the deferred milestone grouping. - questions: register Q-012 (adopt the handoff visual system vs extend tokens); resolve Q-001 (Astro builds clean with content base and output both outside the root, reconfirmed by an out-of-repo build). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-19-dashboard-plan-collection.md | 46 +++++++++++++++++++ wherefore/plan/P-001-ship-0-1-1-patch.md | 4 +- wherefore/plan/P-002-add-plan-collection.md | 5 +- wherefore/plan/P-011-milestone-grouping.md | 20 ++++++++ .../Q-001-astro-build-external-dirs.md | 6 +-- .../Q-012-adopt-handoff-visual-system.md | 10 ++++ 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 wherefore/log/2026-07-19-dashboard-plan-collection.md create mode 100644 wherefore/plan/P-011-milestone-grouping.md create mode 100644 wherefore/questions/Q-012-adopt-handoff-visual-system.md diff --git a/wherefore/log/2026-07-19-dashboard-plan-collection.md b/wherefore/log/2026-07-19-dashboard-plan-collection.md new file mode 100644 index 0000000..554875a --- /dev/null +++ b/wherefore/log/2026-07-19-dashboard-plan-collection.md @@ -0,0 +1,46 @@ +--- +date: 2026-07-19 +title: "Dashboard plan views on the shipped schema" +areas: [dashboard] +topics: [ui, data-model, visual-identity] +stories: [] +status: active +supersedes: +superseded_by: +superseded_date: +--- + +## Summary +Built the dashboard's read-side views for the plan collection (P-002), driven by the +`design_handoff_wherefore_dashboard/` hifi design. The design was drawn against a generic, +idealized schema, so the work was as much reconciliation as rendering: map the design onto +the shipped `wherefore/plan/` contract, and keep the plan files the single source of truth. + +## Decisions / outcomes +- Build against the shipped `wherefore/plan/` schema, not the handoff's idealized one. Map the design's `decision`, `blocks`, and `retired_by` to the real `decision_ref`, `question_ref`, and `dropped_reason` plus `decision_ref`; decision links point at existing `log/` entries, since there is no separate ADR collection. +- Derive `blocked` at build time by joining plan to questions. An item is blocked when its `question_ref` points at a still-open question; it is never stored. +- Group the Plan browse by status, not milestone. Sections run doing, blocked, todo, done, with dropped behind a toggle. Milestone grouping is deferred until items carry milestones. +- Extend the existing dashboard token system rather than adopt the handoff's palette and IBM Plex Mono. Add plan-status tokens (teal doing, warm blocked, muted todo/done/dropped) on top of the current brand tokens and fonts. +- Mirror the questions collection for the loader: glob `plan/P-*.md` so `README.md` is excluded, key identity off the `id:` frontmatter, and treat `area` as a single string. +- Rebuild the home page into a now view: in flight, blocked, up next, then open questions and recent decisions. + +## Why +The plan collection is already dogfooded (P-001 through P-010) and specified in +`plan/README.md`, so its schema was fixed before this work. The handoff predates that +schema; shipping its literal fields would have forked a second, incompatible contract. +Deriving blocked keeps the plan files honest: it stays a view over `question_ref` plus the +referenced question's status, never a stored flag that drifts after a resolve, matching the +slate skill's read rule. Status grouping fits the current data, where almost no item sets a +milestone, so milestone sections would collapse to a single backlog group. Extending the +existing tokens keeps the four pages native to the shipped dashboard and avoids a site-wide +restyle; the handoff's full hifi system stays available if a later pass wants pixel fidelity. + +## Alternatives considered +- Implement the handoff schema verbatim (a separate `decisions/` ADR collection, `blocks`/`retired_by`). Rejected: it forks the data contract from the shipped `wherefore/plan/` spec. +- Group the browse by milestone as the design shows. Rejected for now: real items rarely set a milestone, so it collapses to one backlog group. +- Adopt the handoff's ground/ink palette and IBM Plex Mono across the pages. Rejected for this pass: a full re-theme is out of scope. See Q-012. + +## Open questions / follow-ups +- Q-012: Should the dashboard adopt the handoff's full visual system (dark-default palette plus IBM Plex Mono UI), or is extending the existing tokens the durable choice? +- Milestone grouping and a per-milestone roadmap view are deferred until plan items carry milestones. +- Implements P-002 and the dashboard side of 2026-07-03-plan-directory; extends 2026-06-24-dashboard-schema-and-ui. diff --git a/wherefore/plan/P-001-ship-0-1-1-patch.md b/wherefore/plan/P-001-ship-0-1-1-patch.md index 067d506..878de91 100644 --- a/wherefore/plan/P-001-ship-0-1-1-patch.md +++ b/wherefore/plan/P-001-ship-0-1-1-patch.md @@ -1,9 +1,9 @@ --- id: P-001 title: Ship 0.1.1 patch -status: doing +status: done created: 2026-06-20 -updated: 2026-07-03 +updated: 2026-07-19 area: dashboard topics: [release] --- diff --git a/wherefore/plan/P-002-add-plan-collection.md b/wherefore/plan/P-002-add-plan-collection.md index b50ed85..c5b1d7a 100644 --- a/wherefore/plan/P-002-add-plan-collection.md +++ b/wherefore/plan/P-002-add-plan-collection.md @@ -1,11 +1,12 @@ --- id: P-002 title: Add plan collection to the dashboard -status: todo +status: done created: 2026-07-03 +updated: 2026-07-19 area: dashboard topics: [astro, schema] -decision_ref: 2026-07-03-companion-plan-collection +decision_ref: 2026-07-03-plan-directory, 2026-07-19-dashboard-plan-collection --- Astro loader plus route for wherefore/plan/, mirroring the questions collection. Derive diff --git a/wherefore/plan/P-011-milestone-grouping.md b/wherefore/plan/P-011-milestone-grouping.md new file mode 100644 index 0000000..49d9026 --- /dev/null +++ b/wherefore/plan/P-011-milestone-grouping.md @@ -0,0 +1,20 @@ +--- +id: P-011 +title: Group the Plan browse by milestone +status: todo +created: 2026-07-19 +area: dashboard +topics: [ui] +decision_ref: 2026-07-19-dashboard-plan-collection +--- + +Milestone grouping was deferred when the Plan views shipped with status-section grouping, +because almost no live item carries a milestone yet. Pick this up once items do. The design +in `design_handoff_wherefore_dashboard/` specifies the target shape. + +- [ ] load milestone IDs and titles from `wherefore/ROADMAP.md` at build time +- [ ] group the Plan browse by milestone, collecting un-milestoned items in a `backlog` group +- [ ] add a per-group header: milestone title, progress summary (n/m done, doing/blocked counts), collapse chevron +- [ ] order items within a group by status priority (doing, blocked, todo, done, dropped) +- [ ] decide how milestone grouping composes with the status sections (toggle between them, or status filter chips on top) +- [ ] extend fixtures and render tests to cover milestone grouping and the backlog group diff --git a/wherefore/questions/Q-001-astro-build-external-dirs.md b/wherefore/questions/Q-001-astro-build-external-dirs.md index 3d700e0..d2a7181 100644 --- a/wherefore/questions/Q-001-astro-build-external-dirs.md +++ b/wherefore/questions/Q-001-astro-build-external-dirs.md @@ -1,12 +1,12 @@ --- id: Q-001 question: "Will Astro build cleanly with content base and output dir both outside the Astro project root?" -status: open +status: resolved areas: [dashboard] asked_date: 2026-06-24 asked_slug: 2026-06-24-dashboard-build-tool -resolution: -resolution_slug: +resolution: "Yes. The dashboard builds cleanly with the content base and output dir both outside the Astro root; reconfirmed by an out-of-repo build with external --src and --out." +resolution_slug: 2026-07-19-dashboard-plan-collection --- ## Context diff --git a/wherefore/questions/Q-012-adopt-handoff-visual-system.md b/wherefore/questions/Q-012-adopt-handoff-visual-system.md new file mode 100644 index 0000000..9e51faf --- /dev/null +++ b/wherefore/questions/Q-012-adopt-handoff-visual-system.md @@ -0,0 +1,10 @@ +--- +id: Q-012 +question: "Should the dashboard adopt the handoff's full visual system (dark-default palette plus IBM Plex Mono UI), or is extending the existing tokens the durable choice?" +status: open +areas: [dashboard] +asked_date: 2026-07-19 +asked_slug: 2026-07-19-dashboard-plan-collection +resolution: +resolution_slug: +--- From a9ddb85847578d6d21694906b3a87d0bb1df5c2d Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Sun, 19 Jul 2026 12:13:52 -0400 Subject: [PATCH 3/4] wherefore: reconcile roadmap milestones after plan layer landed Flip M2 (0.1.1 shipped per npm) and M4 (plan layer shipped) to done, and fix the stale "plan skill" references left in plan/README.md by the slate rename. Records the reconciliation as a log entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- wherefore/ROADMAP.md | 16 +++---- .../log/2026-07-19-roadmap-m2-m4-done.md | 44 +++++++++++++++++++ wherefore/plan/README.md | 8 ++-- 3 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 wherefore/log/2026-07-19-roadmap-m2-m4-done.md diff --git a/wherefore/ROADMAP.md b/wherefore/ROADMAP.md index 622d800..b7f971c 100644 --- a/wherefore/ROADMAP.md +++ b/wherefore/ROADMAP.md @@ -33,11 +33,10 @@ Status: done. Serves: G2. 0.1.0 published (MIT) and verified working from a clean external install. ### M2: 0.1.1 polish patch -Status: active. Serves: G2. +Status: done. Serves: G2. README rewrite, build-command preview-locally hint, Vite `server.fs.allow` fix for the -cross-directory dev errors, tsconfig plus @types/node (done). Deferred: blurry 16px -favicon. Confirm against `npm view` whether this has already shipped, and flip to done -if so. +cross-directory dev errors, tsconfig plus @types/node. Shipped: 0.1.1 published to npm +(0.1.2 and 0.2.0 followed). Deferred: blurry 16px favicon. ### M3: Thin launcher published Status: planned. Serves: G1. @@ -46,10 +45,11 @@ runs the real tool while `@dustinvk/wherefore-dashboard` stays the versioned sou truth. ### M4: Plan layer shipped -Status: active. Serves: G2. -The `P-NNN` plan collection, the `/wherefore:slate` verb, dashboard rendering of plan -items, and this roadmap. Currently dogfooding the frontmatter contract by hand before -wiring the loader and the skill. +Status: done. Serves: G2. +The `P-NNN` plan collection, the `/wherefore:slate` verb, and dashboard rendering of +plan items all shipped (slate in plugin 0.2.0; plan views on the current dashboard), +plus this roadmap as the milestone layer. Deferred: milestone grouping in the dashboard +(P-011), picked up once items carry milestones. ### M5: Live demo at wherefore.dev Status: planned. Serves: G3. diff --git a/wherefore/log/2026-07-19-roadmap-m2-m4-done.md b/wherefore/log/2026-07-19-roadmap-m2-m4-done.md new file mode 100644 index 0000000..d0926f4 --- /dev/null +++ b/wherefore/log/2026-07-19-roadmap-m2-m4-done.md @@ -0,0 +1,44 @@ +--- +date: 2026-07-19 +title: "Flip roadmap M2 and M4 to done" +areas: [dashboard, plugin] +topics: [docs, publishing] +stories: [] +status: active +supersedes: +superseded_by: +superseded_date: +--- + +## Summary +A drift review after the plan layer landed found the roadmap trailing reality. M2 (0.1.1 +polish patch) and M4 (plan layer shipped) were still `active` though both had shipped, and +the slate rename had left stale "plan skill" references in `plan/README.md`. Reconciled all +three. + +## Decisions / outcomes +- M2 flipped to `done`. `npm view @dustinvk/wherefore-dashboard` shows 0.1.0, 0.1.1, 0.1.2, + and 0.2.0 published, so the 0.1.1 patch shipped. The blurry 16px favicon stays deferred as + a carried note, not a milestone blocker. +- M4 flipped to `done`. The `P-NNN` collection, the `/wherefore:slate` verb, and the + dashboard plan views all shipped (slate in plugin 0.2.0). Milestone grouping (P-011) is a + deferred follow-on, not core delivery, so it does not hold the milestone open. +- `plan/README.md` now names the `slate` skill in all five spots. The rename decision moved + the verb to `slate` but the collection's own README still said `plan` skill. + +## Why +The roadmap is the source of truth for milestone status, so a milestone reading `active` +after it shipped is exactly the rot the dashboard is meant to surface. Verifying M2 against +npm rather than flipping it on assumption keeps the record honest. Treating a deferred +enhancement (favicon, P-011 grouping) as a carried note rather than a blocker matches how M1 +already handles its follow-ups: a milestone is done when its core delivery ships, with +deferrals tracked as their own items. + +## Alternatives considered +- Leave M4 `active` until milestone grouping (P-011) lands. Rejected: it gates the whole + milestone on a deferred enhancement whose own trigger is "once items carry milestones," + which would keep M4 open indefinitely while its substance has shipped. + +## Open questions / follow-ups +- None. P-011 tracks the remaining milestone-grouping work; Q-012 tracks the visual-system + question, both independent of this reconciliation. diff --git a/wherefore/plan/README.md b/wherefore/plan/README.md index 30e2c33..118474d 100644 --- a/wherefore/plan/README.md +++ b/wherefore/plan/README.md @@ -35,7 +35,7 @@ superseded / obsolete here, and do not put todo / doing / done / dropped on deci | `answers` | no | `Q-NNN` | a single question this item is the work of answering (spike)| | `dropped_reason`| no | string | lightweight why, used when `status: dropped` | -Body: freeform. The `plan` skill breaks the work into `- [ ]` checkboxes by default +Body: freeform. The `slate` skill breaks the work into `- [ ]` checkboxes by default (steps concrete enough to check off); prose stays valid, and older items may be prose-only. @@ -70,14 +70,14 @@ the link. ## Boundaries -- The `plan` skill writes only `wherefore/plan/*`. Never `log/`, never `questions/`, +- The `slate` skill writes only `wherefore/plan/*`. Never `log/`, never `questions/`, never `ROADMAP.md`. - Durable why belongs in the decision layer: when a plan item's rationale is really a decision, that is a `capture`; when a direction changes for a reason worth keeping, that is a `supersede` on the decision, and the plan item flips to `dropped` carrying `decision_ref` to it. - `capture` never creates plan items and never writes plan status directly; it may hand - off to the `plan` skill. The `plan` skill records that something changed; the decision + off to the `slate` skill. The `slate` skill records that something changed; the decision layer owns the durable why. -Maintained by the [wherefore](https://github.com/DustinVK/wherefore) plan skill. +Maintained by the [wherefore](https://github.com/DustinVK/wherefore) slate skill. From a94119f84029b3261c79b1a90a57d9df1d79cfd2 Mon Sep 17 00:00:00 2001 From: Dustin VanKrimpen Date: Sun, 19 Jul 2026 12:14:52 -0400 Subject: [PATCH 4/4] wherefore-dashboard: fix plan checklist rendering Plan-item checklists rendered each step as a flex row, which made every text run and inline code chip its own shrinking flex item and shredded steps into columns. Render each task row as a block with a hanging indent and the checkbox in the left margin, so text, code, and links flow as one wrapped line. Also calm the inline code chips (neutral, not accent teal) and add a "Checklist" divider above the list on the detail page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wherefore-dashboard/src/styles/global.css | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/wherefore-dashboard/src/styles/global.css b/packages/wherefore-dashboard/src/styles/global.css index 1034b7f..d1c3ea1 100644 --- a/packages/wherefore-dashboard/src/styles/global.css +++ b/packages/wherefore-dashboard/src/styles/global.css @@ -1055,16 +1055,56 @@ html[data-theme="light"] { .plan-card-body { margin-top: 6px; } .plan-card-body p, .plan-card-body li { font-size: 14px; } -/* GFM task lists rendered from plan bodies */ +/* GFM task lists rendered from plan bodies. + Each item is a normal block with a hanging indent and the checkbox in the left + margin, so text, inline code, and links flow as one wrapped line. (A flex row here + makes every text run and code chip its own shrinking flex item and shreds the step + into columns.) */ .plan-body ul.contains-task-list { list-style: none; padding-left: 0; } -.plan-body .task-list-item { display: flex; align-items: baseline; gap: 8px; } -.plan-body .task-list-item input[type="checkbox"] { margin: 0; accent-color: var(--teal); } +.plan-body ul.contains-task-list ul.contains-task-list { padding-left: 1.5em; margin: 4px 0; } +.plan-body .task-list-item { + display: block; + position: relative; + padding-left: 1.7em; + margin: 7px 0; + line-height: 1.6; +} +.plan-body .task-list-item input[type="checkbox"] { + position: absolute; + left: 0; + top: 0.34em; + margin: 0; + accent-color: var(--teal); +} .plan-body .task-list-item:has(input:checked) { color: var(--faint); text-decoration: line-through; text-decoration-color: var(--faint2); } +/* Calmer inline code inside plan bodies: neutral, not the teal accent, so a step + dense with code spans stays scannable. Later than .entry-body code, so it wins. */ +.plan-body code { + color: var(--soft); + background: var(--surface-faint); + font-size: 0.86em; + padding: 0.5px 4px; + border-radius: 4px; +} + +/* "Checklist" divider above the task list, detail page only (scoped off the compact + now-view cards and off nested lists; shows only when a task list exists). */ +.plan-detail .plan-body > ul.contains-task-list::before { + content: "Checklist"; + display: block; + font-family: var(--mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--faint); + margin: 4px 0 10px; +} + /* ── Blocked cards (now view) ────────────────────────────────── */ .blocked-cards { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; } .blocked-card {