feat(db): roadmap_items.metadata_text + admin field

Migration 0007 (spec said 0006 but 0006 was already roadmap_considering)
adds a single nullable metadata_text column to roadmap_items — a short
admin-set narrative cue shown on hover in the route cards. ~60 chars
suggested in admin helper text. Hidden in the UI when NULL.

db.ts: RoadmapItem type gains the field. createRoadmapItem + updateRoadmapItem
accept an optional metadata_text parameter. moveRoadmapItem passes it through
when swapping display_order between siblings so the helper preserves it.

Admin: /admin?tab=roadmap edit form gets a new 'Hover note' input under
the description, with the helper text and a 120-char hard cap. Empty
string saves as NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:39:18 +02:00
parent f659b70814
commit d17d9b93a7
4 changed files with 40 additions and 13 deletions

View file

@ -0,0 +1,6 @@
-- Roadmap items gain an optional metadata_text field — a short admin-set
-- narrative cue shown in the route card's hover expansion. Free-form,
-- ~60 chars suggested in admin helper text. NULL when not set; UI hides
-- the line in that case.
ALTER TABLE roadmap_items ADD COLUMN metadata_text TEXT;

View file

@ -66,6 +66,20 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea> <textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
</div> </div>
<div class="field">
<label for="metadata_text" class="label-sm field-label">Hover note (~60 chars)</label>
<input
type="text"
id="metadata_text"
name="metadata_text"
class="input body-md"
value={editing?.metadata_text ?? ''}
placeholder="e.g. Open question on key custody · Council input wanted"
maxlength="120"
/>
<span class="body-sm muted">A short narrative cue shown on hover in /roadmap. Optional.</span>
</div>
<fieldset class="attribution-grid"> <fieldset class="attribution-grid">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend> <legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => ( {cabUsers.map(u => (
@ -183,4 +197,6 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
padding: 0; padding: 0;
} }
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; } .action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style> </style>

View file

@ -675,6 +675,7 @@ export interface RoadmapItem {
target: string | null; target: string | null;
display_order: number; display_order: number;
shipped_at: string | null; shipped_at: string | null;
metadata_text: string | null; // short narrative cue shown on hover in /roadmap
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -689,11 +690,12 @@ export function createRoadmapItem(data: {
status: RoadmapStatus; status: RoadmapStatus;
target?: string | null; target?: string | null;
display_order?: number; display_order?: number;
metadata_text?: string | null;
}): number { }): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const r = db.prepare(` const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
`).run( `).run(
data.title, data.title,
data.description, data.description,
@ -701,6 +703,7 @@ export function createRoadmapItem(data: {
data.target ?? null, data.target ?? null,
data.display_order ?? 0, data.display_order ?? 0,
shipped_at, shipped_at,
data.metadata_text ?? null,
); );
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
} }
@ -715,6 +718,7 @@ export function updateRoadmapItem(id: number, data: {
status: RoadmapStatus; status: RoadmapStatus;
target: string | null; target: string | null;
display_order: number; display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } { }): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?') const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined; .get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
@ -728,9 +732,9 @@ export function updateRoadmapItem(id: number, data: {
db.prepare(` db.prepare(`
UPDATE roadmap_items UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?, SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, updated_at = datetime('now') shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id); `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return { shippedNow }; return { shippedNow };
} }

View file

@ -195,17 +195,18 @@ if (Astro.request.method === 'POST') {
// ── Roadmap ────────────────────────────────────────────────── // ── Roadmap ──────────────────────────────────────────────────
} else if (action === 'create_roadmap' || action === 'update_roadmap') { } else if (action === 'create_roadmap' || action === 'update_roadmap') {
const title = String(data.get('title') ?? '').trim(); const title = String(data.get('title') ?? '').trim();
const description = String(data.get('description') ?? '').trim(); const description = String(data.get('description') ?? '').trim();
const status = String(data.get('status') ?? '') as RoadmapStatus; const status = String(data.get('status') ?? '') as RoadmapStatus;
const target = String(data.get('target') ?? '').trim() || null; const target = String(data.get('target') ?? '').trim() || null;
const displayOrder = Number(data.get('display_order') ?? 0); const displayOrder = Number(data.get('display_order') ?? 0);
const metadataText = String(data.get('metadata_text') ?? '').trim() || null;
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) { if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
formError = 'Title and status are required.'; formError = 'Title and status are required.';
} else if (action === 'create_roadmap') { } else if (action === 'create_roadmap') {
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder }); const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder, metadata_text: metadataText });
setRoadmapAttributions(id, attributedIds); setRoadmapAttributions(id, attributedIds);
if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); if (status === 'shipping') recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created'); return Astro.redirect('/admin?tab=roadmap&msg=roadmap_created');
@ -213,7 +214,7 @@ if (Astro.request.method === 'POST') {
const id = Number(data.get('roadmap_id')); const id = Number(data.get('roadmap_id'));
if (id) { if (id) {
const { shippedNow } = updateRoadmapItem(id, { const { shippedNow } = updateRoadmapItem(id, {
title, description, status, target, display_order: displayOrder, title, description, status, target, display_order: displayOrder, metadata_text: metadataText,
}); });
setRoadmapAttributions(id, attributedIds); setRoadmapAttributions(id, attributedIds);
if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id); if (shippedNow) recordActivity(user.id, 'roadmap_shipped', 'roadmap', id);
@ -284,11 +285,11 @@ function moveRoadmapItem(id: number, dir: 'up' | 'down'): void {
const other = sameStatus[swapIdx]; const other = sameStatus[swapIdx];
updateRoadmapItem(item.id, { updateRoadmapItem(item.id, {
title: item.title, description: item.description, status: item.status, title: item.title, description: item.description, status: item.status,
target: item.target, display_order: other.display_order, target: item.target, display_order: other.display_order, metadata_text: item.metadata_text,
}); });
updateRoadmapItem(other.id, { updateRoadmapItem(other.id, {
title: other.title, description: other.description, status: other.status, title: other.title, description: other.description, status: other.status,
target: other.target, display_order: item.display_order, target: other.target, display_order: item.display_order, metadata_text: other.metadata_text,
}); });
} }