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>
203 lines
8.7 KiB
Text
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>
|