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..d1c3ea1 100644 --- a/packages/wherefore-dashboard/src/styles/global.css +++ b/packages/wherefore-dashboard/src/styles/global.css @@ -883,3 +883,290 @@ 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. + 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 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 { + 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'); +}); 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-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/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/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/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. 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: +---