Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/wherefore-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/wherefore-dashboard/src/components/PlanRow.astro
Original file line number Diff line number Diff line change
@@ -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;
---
<li
class:list={['plan-row', status === 'dropped' && 'row-dropped']}
data-status={status}
data-blocked={blocked ? 'true' : 'false'}
data-area={area ?? ''}
data-title={title.toLowerCase()}
>
<span class:list={['status-dot', dotClass]} aria-hidden="true"></span>
<a class="plan-row-title" href={`/plan/${id}`}>{title}</a>
{milestone && <span class="milestone-tag">{milestone}</span>}
{total > 0 && (
<span class="plan-progress" title={`${done} of ${total} done`}>
<span class="plan-progress-track"><span class="plan-progress-fill" style={`width:${pct}%`}></span></span>
<span class="progress-count">{done}/{total}</span>
</span>
)}
<span class:list={['plan-status-label', dotClass]}>{label}</span>
</li>
41 changes: 40 additions & 1 deletion packages/wherefore-dashboard/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
1 change: 1 addition & 0 deletions packages/wherefore-dashboard/src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];
Expand Down
58 changes: 58 additions & 0 deletions packages/wherefore-dashboard/src/lib/plan.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): 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<PlanStatus, number> = { 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);
}
205 changes: 156 additions & 49 deletions packages/wherefore-dashboard/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -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);
---
<Base>
<p class="summary-line">{parts.join(' · ')}</p>
<div class="count-strip now-counts">
<span class="count"><span class="status-dot is-doing"></span><b>{inFlight.length}</b> in flight</span>
<span class="count"><span class="status-dot is-blocked"></span><b>{blocked.length}</b> blocked</span>
<span class="count"><span class="status-dot is-todo"></span><b>{upNext.length}</b> queued</span>
<span class="count"><span class="status-dot is-open"></span><b>{openQuestions.length}</b> open</span>
</div>

<div class="page-section">
<div class="page-section-head">
Recent decisions
<a href="/log">All decisions &rarr;</a>
{inFlight.length > 0 && (
<div class="page-section">
<div class="page-section-head">
In flight
<a href="/plan">All plan items &rarr;</a>
</div>
<div class="plan-cards">
{inFlightRendered.map(c => {
const { Content } = c;
const pct = c.progress.total > 0 ? Math.round((c.progress.done / c.progress.total) * 100) : 0;
return (
<article class="plan-card">
<div class="plan-card-eyebrow">
<span>doing</span>
{c.data.milestone && <span class="milestone-tag">{c.data.milestone}</span>}
</div>
<a class="plan-card-title" href={`/plan/${c.id}`}>{c.data.title}</a>
{c.progress.total > 0 && (
<div class="detail-progress">
<span class="plan-progress-track"><span class="plan-progress-fill" style={`width:${pct}%`}></span></span>
<span class="progress-count">{c.progress.done} of {c.progress.total}</span>
</div>
)}
<div class="entry-body plan-body plan-card-body"><Content /></div>
</article>
);
})}
</div>
</div>
{recentEntries.length === 0 ? (
<p class="empty">No entries yet.</p>
) : (
<ul class="entry-list">
{recentEntries.map(e => (
<li class="row">
<a class="entry-title" href={`/log/${e.id}`}>{e.data.title}</a>
<div class="entry-meta">
<span class="dt">{e.data.date}</span>
{e.data.areas.map(a => <span class="tag tag-area">{a}</span>)}
{e.data.topics.map(t => <span class="tag tag-topic">{t}</span>)}
)}

{blocked.length > 0 && (
<div class="page-section">
<div class="page-section-head">Blocked</div>
<div class="blocked-cards">
{blocked.map(c => {
const q = c.data.questionRef ? questionById.get(c.data.questionRef) : null;
return (
<div class="blocked-card">
<div class="blocked-card-head">
<span class="status-dot is-blocked" aria-hidden="true"></span>
<a class="plan-card-title" href={`/plan/${c.id}`}>{c.data.title}</a>
{c.data.milestone && <span class="milestone-tag">{c.data.milestone}</span>}
</div>
{q && (
<div class="blocked-q">
<span class="q-marker" aria-hidden="true">?</span>
<div>
<span class="q-text-inline">{q.data.question}</span>
<div class="q-meta-inline">{c.data.questionRef} &middot; open</div>
</div>
</div>
)}
</div>
</li>
);
})}
</div>
</div>
)}

{upNext.length > 0 && (
<div class="page-section">
<div class="page-section-head">Up next</div>
<ul class="plan-list">
{upNext.map(c => (
<PlanRow
id={c.id}
status={c.data.status}
blocked={c.blocked}
title={c.data.title}
area={c.data.area}
milestone={c.data.milestone}
done={c.progress.done}
total={c.progress.total}
/>
))}
</ul>
)}
</div>
</div>
)}

<div class="page-section">
<div class="page-section-head">
Open questions
<a href="/questions">All questions &rarr;</a>
<div class="now-footer">
<div class="page-section">
<div class="page-section-head">
Open questions
<a href="/questions">All questions &rarr;</a>
</div>
{openQuestions.length === 0 ? (
<p class="empty">No open questions.</p>
) : (
<ul class="question-list">
{openQuestions.map(q => (
<li class="row">
<div class="q-id-date">{q.id.toUpperCase()} &middot; {q.data.asked_date}</div>
<span class="q-text">{q.data.question}</span>
<div class="entry-meta">
{q.data.areas.map(a => <span class="tag tag-area">{a}</span>)}
</div>
</li>
))}
</ul>
)}
</div>

<div class="page-section">
<div class="page-section-head">
Recently decided
<a href="/log">All decisions &rarr;</a>
</div>
{recentDecisions.length === 0 ? (
<p class="empty">No decisions yet.</p>
) : (
<ul class="entry-list">
{recentDecisions.map(e => (
<li class="row">
<a class="entry-title" href={`/log/${e.id}`}>{e.data.title}</a>
<div class="entry-meta">
<span class="dt">{e.data.date}</span>
{e.data.areas.map(a => <span class="tag tag-area">{a}</span>)}
{e.data.topics.map(t => <span class="tag tag-topic">{t}</span>)}
</div>
</li>
))}
</ul>
)}
</div>
{openQuestions.length === 0 ? (
<p class="empty">No open questions.</p>
) : (
<ul class="question-list">
{openQuestions.map(q => (
<li class="row">
<div class="q-id-date">{q.id.toUpperCase()} · {q.data.asked_date}</div>
<span class="q-text">{q.data.question}</span>
<div class="entry-meta">
{q.data.areas.map(a => <span class="tag tag-area">{a}</span>)}
</div>
</li>
))}
</ul>
)}
</div>
</Base>
Loading
Loading