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:
parent
f659b70814
commit
d17d9b93a7
4 changed files with 40 additions and 13 deletions
6
migrations/0007_roadmap_metadata.sql
Normal file
6
migrations/0007_roadmap_metadata.sql
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,12 +200,13 @@ if (Astro.request.method === 'POST') {
|
||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue