project-bifrost-platform/src/components/admin/RoadmapTab.astro
Jonathan Hvid 1ec01a2257 feat(roadmap): 'In motion right now' strip + subtitle copy + admin helper
New <RoadmapInMotion> component renders between the dispatch banner
and the route's section header. Pulls the most-recent shipping item
(same selection rule the .rr-current marker uses) and prints the
first sentence of its description as a 18px serif italic line
preceded by an 'IN MOTION RIGHT NOW' tracked eyebrow.

A member who only spends 5 seconds on /roadmap now still walks away
with a sentence about what just shipped — no scroll, no hover.

firstSentenceOf() is the obvious regex against the first
[.!?](?=\s|$). Bails to the 200-char slice if no sentence boundary
fits (covers 'Dr.' / 'e.g.' confusables). Returns '' on null. The
strip hides itself entirely when there's no shipping item, or when
the shipping item has no description text.

Page subtitle: 'Hover any milestone for the full story.' →
'Tap or hover any milestone for the full story.' — touch devices
don't have hover, and the kind of detail that says we're paying
attention.

Admin description-field gains a helper note: 'For shipping items:
the first sentence appears on /roadmap as the "In motion right now"
line. Make it count.' Nudges good first-sentence writing without
adding a new field to maintain.

Banner margin under the dispatch banner reduces 56 → 40px because
the in-motion strip carries its own 36px bottom margin to the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:00:14 +02:00

203 lines
8.7 KiB
Text

---
import type { RoadmapItemWithAttribution, UserPublic } from '../../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
editing: RoadmapItemWithAttribution | null;
cabUsers: UserPublic[];
}
const { items, editing, cabUsers } = Astro.props;
const STATUS_LABEL = {
shipping: 'Shipping',
in_beta: 'In beta',
exploring: 'Exploring',
considering: 'Considering',
} as const;
const formAction = editing ? 'update_roadmap' : 'create_roadmap';
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display
type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
in_beta: items.filter(i => i.status === 'in_beta' ).sort((a,b) => a.display_order - b.display_order),
exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order),
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
};
---
<div class="tab-content">
<!-- ── Form ──────────────────────────────────────────────────── -->
<section class="section">
<h2 class="label-sm section-heading">{editing ? 'Edit roadmap item' : 'New roadmap item'}</h2>
<form method="POST" class="invite-form" novalidate>
<input type="hidden" name="action" value={formAction} />
{editing && <input type="hidden" name="roadmap_id" value={editing.id} />}
<div class="form-grid">
<div class="field">
<label for="title" class="label-sm field-label">Title</label>
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
</div>
<div class="field">
<label for="status" class="label-sm field-label">Status</label>
<select id="status" name="status" class="select body-md" required>
<option value="considering" selected={editing?.status === 'considering'}>Considering</option>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="in_beta" selected={editing?.status === 'in_beta'}>In beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
</select>
</div>
<div class="field">
<label for="target" class="label-sm field-label">Target (free-form, e.g. Q3 2026)</label>
<input type="text" id="target" name="target" class="input body-md" value={editing?.target ?? ''} />
</div>
<div class="field">
<label for="display_order" class="label-sm field-label">Order (within status)</label>
<input type="number" id="display_order" name="display_order" class="input body-md" value={editing?.display_order ?? 0} />
</div>
</div>
<div class="field">
<label for="description" class="label-sm field-label">Description</label>
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
<span class="body-sm muted">For shipping items: the first sentence appears on /roadmap as the "In motion right now" line. Make it count.</span>
</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">
<legend class="label-sm field-label">Attributed members (who shaped this)</legend>
{cabUsers.map(u => (
<label class="check-row">
<input type="checkbox" name="attributed_user_ids" value={u.id} checked={attributedSet.has(u.id)} />
<span class="body-sm">{u.name} <span class="muted">— {u.organisation}</span></span>
</label>
))}
{cabUsers.length === 0 && <span class="body-sm muted">No council members yet.</span>}
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create item'}</button>
{editing && <a href="/admin?tab=roadmap" class="action-link label-sm">Cancel</a>}
</div>
</form>
</section>
<!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','in_beta','exploring','considering'] as const).map(status => (
<section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? (
<p class="body-sm empty-msg">Nothing here yet.</p>
) : (
<table class="data-table">
<thead>
<tr>
<th class="label-sm">Title</th>
<th class="label-sm">Target</th>
<th class="label-sm">Attributed</th>
<th class="label-sm">Order</th>
<th class="label-sm">Actions</th>
</tr>
</thead>
<tbody>
{grouped[status].map((item, idx) => (
<tr>
<td class="body-sm">{item.title}</td>
<td class="body-sm muted">{item.target ?? '—'}</td>
<td class="body-sm muted">{item.attributed.length === 0 ? '—' : item.attributed.map(a => a.name.split(' ')[0]).join(', ')}</td>
<td class="body-sm muted">{item.display_order}</td>
<td class="action-cell">
{idx > 0 && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="move_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<input type="hidden" name="direction" value="up" />
<button type="submit" class="action-link label-sm" aria-label="Move up">↑</button>
</form>
)}
{idx < grouped[status].length - 1 && (
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="move_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<input type="hidden" name="direction" value="down" />
<button type="submit" class="action-link label-sm" aria-label="Move down">↓</button>
</form>
)}
<a href={`/admin?tab=roadmap&edit=${item.id}`} class="action-link label-sm">Edit</a>
<form method="POST" class="inline-form">
<input type="hidden" name="action" value="delete_roadmap" />
<input type="hidden" name="roadmap_id" value={item.id} />
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this roadmap item?')">Delete</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
))}
</div>
<style>
.attribution-grid {
border: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
.attribution-grid legend {
grid-column: 1 / -1;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
padding: 0;
margin-bottom: var(--space-2);
}
.check-row { display: flex; align-items: center; gap: var(--space-2); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.action-cell {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.action-link {
background: none;
border: none;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
font-family: var(--font-sans);
transition: color var(--duration-fast) var(--ease-standard);
padding: 0;
}
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.muted { color: var(--on-surface-muted); }
</style>