fix: nine test-note follow-ups

1. Markdown preview in the admin edit panel now re-renders from the
   textarea's current value on every toggle (dynamic-imports marked on
   the client). Previously the panel showed the server-rendered seed
   value forever, so new dispatches always previewed empty.

2. Pulse sub-form drops the opens_at field (opens on dispatch publish
   automatically) and changes closes_at to a date input — the chosen
   day is treated as end-of-day in the DB.

3. /dispatches/[slug] reading width widened 50% (720 → 1080px).

4. Roadmap display_order cascades on insert / update / delete:
   inserting at N bumps N..end up by 1, deleting N pulls N+1..end
   down by 1, moving from A to B shifts the intermediate range by 1
   in the appropriate direction. Order stays dense — no gaps, no
   collisions. All three transitions run in a transaction.

5. /roadmap always anchors at scrollLeft=0 on mount so the first
   milestone aligns with the content-column left edge. Previously
   the page jumped to the last-shipping milestone, which felt random
   once items past the viewport landed.

6. Events admin list shows the actual date (fmtDateTime) instead of
   "in 3 days" — easier to scan when planning across months.

7. duration_label is auto-computed from starts_at + ends_at on save
   (minutes < 90, hours < 4, "Half day", "Full day", "N days").
   The manual field is gone from the admin form; the column on the
   member-facing event pages keeps reading the stored value as before.

8. Pulse hero still skips office hours per the existing logic — no
   change. Confirmed via the test note's clarification.

9. Pulse "also coming up" strip relabeled to Previous + Upcoming.
   Previous = most recent past non-office-hours event. Upcoming =
   next non-office-hours event after the hero. Each card now carries
   a small terracotta eyebrow with the label.

Typecheck clean, build clean, 147/147 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 17:46:06 +02:00
parent 220f8e0290
commit 4aaf0957dd
8 changed files with 194 additions and 102 deletions

View file

@ -225,16 +225,27 @@ const formAction = Astro.url.pathname + Astro.url.search;
});
// ── Markdown Write/Preview toggle ────────────────────────────────────────
// Re-renders preview from the textarea's *current* value on every toggle.
// The server-side initial render is only valid for the seed value — once
// the admin types, only client-side rendering reflects what's there.
document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
const preview = root.querySelector<HTMLElement>('.bs-md-preview');
if (!input || !preview) return;
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tab.addEventListener('click', async () => {
const mode = tab.getAttribute('data-md-mode');
tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
if (mode === 'preview') {
const value = input.value;
if (value.trim() === '') {
preview.innerHTML = '<p class="bs-md-empty">Nothing to preview yet.</p>';
} else {
const { marked } = await import('marked');
marked.setOptions({ breaks: true, gfm: true });
preview.innerHTML = await marked.parse(value);
}
input.hidden = true;
preview.hidden = false;
} else {

View file

@ -26,10 +26,10 @@ const question = pulse?.question ?? '';
const initialOptions: string[] = pulse?.options ? [...pulse.options] : ['', ''];
while (initialOptions.length < 2) initialOptions.push('');
function toDatetimeLocal(v: string | null | undefined): string {
function toDateOnly(v: string | null | undefined): string {
if (!v) return '';
const s = String(v).replace(' ', 'T');
const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
const s = String(v);
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : '';
}
---
@ -37,8 +37,8 @@ function toDatetimeLocal(v: string | null | undefined): string {
<div class="bs-pulse-embed">
<p class="bs-helper">
Attach a pulse by filling in the question and at least two options.
Leaving the question blank means no pulse on this dispatch.
Pulses open when the dispatch is published and close when it's archived.
Leaving the question blank means no pulse on this dispatch. Pulses open
when the dispatch is published and close at the end of the chosen day.
</p>
<div class="bs-field">
@ -76,26 +76,18 @@ function toDatetimeLocal(v: string | null | undefined): string {
</div>
</div>
<div class="bs-field-row">
<div class="bs-field">
<label class="bs-label" for="pulse_opens_at">Opens</label>
<input
type="datetime-local"
id="pulse_opens_at"
name="pulse_opens_at"
class="bs-input"
value={toDatetimeLocal(pulse?.opens_at)}
/>
</div>
<div class="bs-field">
<label class="bs-label" for="pulse_closes_at">Closes</label>
<input
type="datetime-local"
id="pulse_closes_at"
name="pulse_closes_at"
class="bs-input"
value={toDatetimeLocal(pulse?.closes_at)}
/>
</div>
<div class="bs-field">
<label class="bs-label" for="pulse_closes_at">Closes on</label>
<input
type="date"
id="pulse_closes_at"
name="pulse_closes_at"
class="bs-input"
value={toDateOnly(pulse?.closes_at)}
/>
<p class="bs-helper">
The pulse closes at the end of the chosen day. Opens automatically when the
dispatch is published.
</p>
</div>
</div>

View file

@ -27,12 +27,6 @@ import type { FieldContext, Resource } from '../resource-types';
// ── Helpers ─────────────────────────────────────────────────────────────────
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
function toSqliteDatetime(s: string): string {
if (!s) return '';
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
}
function nowSqlite(): string {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
@ -41,6 +35,12 @@ function plusDaysSqlite(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
}
/** "YYYY-MM-DD" → "YYYY-MM-DD 23:59:59" (end-of-day in DB local time). */
function endOfDaySqlite(dateStr: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return '';
return `${dateStr} 23:59:59`;
}
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
const question = String(formData.get('pulse_question') ?? '').trim();
@ -51,10 +51,14 @@ function extractPulseFromFormData(formData: FormData): DispatchPollInput | null
.filter(Boolean)
.slice(0, 4);
if (options.length < 2) return null;
const opens_at =
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
const closes_at =
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
// Opens always = now (or the dispatch's publish moment). closes_at comes
// from a date input and snaps to 23:59:59 on the chosen day; missing →
// default to 14 days out.
const opens_at = nowSqlite();
const closesRaw = String(formData.get('pulse_closes_at') ?? '').trim();
const closes_at = endOfDaySqlite(closesRaw) || plusDaysSqlite(14);
return { question, options, opens_at, closes_at };
}

View file

@ -17,6 +17,7 @@ import {
type EventKind,
} from '../../lib/db';
import { eventKindLabel } from '../../lib/format';
import { fmtDateTime } from '../../lib/markdown';
import type { Resource } from '../resource-types';
function slugify(s: string): string {
@ -42,6 +43,30 @@ function toSqliteDatetime(s: string): string {
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
}
/** Compute a human duration label from start + end SQLite datetimes.
* Returns null when ends_at is missing (open-ended event). */
function computeDurationLabel(startsAt: string, endsAt: string | null): string | null {
if (!endsAt) return null;
const start = new Date(startsAt.replace(' ', 'T') + 'Z').getTime();
const end = new Date(endsAt.replace(' ', 'T') + 'Z').getTime();
const ms = end - start;
if (!Number.isFinite(ms) || ms <= 0) return null;
const minutes = Math.round(ms / 60_000);
if (minutes < 90) return `${minutes} min`;
const hours = ms / 3_600_000;
if (hours < 4) {
const rounded = Math.round(hours * 2) / 2; // nearest half hour
return Number.isInteger(rounded) ? `${rounded} hr` : `${rounded} hr`;
}
if (hours < 7) return 'Half day';
if (hours < 10) return 'Full day';
const days = Math.round(hours / 24);
return days === 1 ? '1 day' : `${days} days`;
}
export const eventsResource: Resource<Event> = {
key: 'events',
label: 'Events',
@ -79,10 +104,11 @@ export const eventsResource: Resource<Event> = {
},
{
key: 'starts_at',
label: 'Starts',
kind: 'relative-date',
width: '110px',
emptyFallback: '—',
label: 'Date',
width: '180px',
render: (item) => ({
title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
}),
},
{
key: 'capacity',
@ -169,13 +195,6 @@ export const eventsResource: Resource<Event> = {
maxLength: 200,
helperText: 'Free-form note about who the event is for (e.g. "Council members only").',
},
{
key: 'duration_label',
label: 'Duration label',
kind: 'text',
maxLength: 40,
helperText: 'Optional display label like "90 min" or "Half day".',
},
{
key: 'action_label',
label: 'Action button label',
@ -206,18 +225,20 @@ export const eventsResource: Resource<Event> = {
create: (data, ctx) => {
const rawSlug = ((data.slug as string) ?? '').trim();
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title)));
const startsAt = toSqliteDatetime(String(data.starts_at));
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
return createEvent({
slug,
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
starts_at: startsAt,
ends_at: endsAt,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience as string) ?? '').trim() || null,
duration_label: ((data.duration_label as string) ?? '').trim() || null,
duration_label: computeDurationLabel(startsAt, endsAt),
action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null,
created_by: ctx.user.id,
@ -225,17 +246,19 @@ export const eventsResource: Resource<Event> = {
},
update: (id, data) => {
const startsAt = toSqliteDatetime(String(data.starts_at));
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
updateEvent(id, {
title: String(data.title),
kind: data.kind as EventKind,
description: String(data.description),
location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
starts_at: startsAt,
ends_at: endsAt,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience as string) ?? '').trim() || null,
duration_label: ((data.duration_label as string) ?? '').trim() || null,
duration_label: computeDurationLabel(startsAt, endsAt),
action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null,
});

View file

@ -368,16 +368,12 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
});
// Initial mount: recompute with the real viewport, then scroll the
// 'you are here' milestone roughly 25% from the left.
// Initial mount: recompute with the real viewport, then anchor at the
// start so the first milestone aligns with the content-column left edge.
// (The "you are here" highlight on the most-recent shipping milestone is
// still visible — but it's no longer the scroll anchor.)
recompute();
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
if (initialX) {
const x = parseFloat(initialX.style.left) || 0;
const max = scroll.scrollWidth - scroll.clientWidth;
const target = Math.max(0, Math.min(max, x - scroll.clientWidth * 0.25));
scroll.scrollLeft = target;
}
scroll.scrollLeft = 0;
setTimeout(updateNav, 50);
updateNav();

View file

@ -716,19 +716,29 @@ export function createRoadmapItem(data: {
metadata_text?: string | null;
}): number {
const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null;
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
`).run(
data.title,
data.description,
data.status,
data.target ?? null,
data.display_order ?? 0,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
const requestedOrder = data.display_order ?? 0;
return db.transaction(() => {
// Cascade: insert at position N shifts every existing item at or after N
// down by one, keeping the order dense.
db.prepare(
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?'
).run(requestedOrder);
const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?)
`).run(
data.title,
data.description,
data.status,
data.target ?? null,
requestedOrder,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
})();
}
/**
@ -743,8 +753,8 @@ export function updateRoadmapItem(id: number, data: {
display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } {
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | undefined;
if (!current) throw new Error(`Roadmap item ${id} not found`);
const shippedNow = data.status === 'shipping' && current.shipped_at === null;
@ -752,18 +762,45 @@ export function updateRoadmapItem(id: number, data: {
? new Date().toISOString().slice(0, 19).replace('T', ' ')
: current.shipped_at;
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return db.transaction(() => {
// Cascade neighbours when display_order changes.
// Moving forward (A → B, B > A): rows in (A, B] shift down by 1.
// Moving back (A → B, B < A): rows in [B, A) shift up by 1.
const from = current.display_order;
const to = data.display_order;
if (to > from) {
db.prepare(
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE id != ? AND display_order > ? AND display_order <= ?'
).run(id, from, to);
} else if (to < from) {
db.prepare(
'UPDATE roadmap_items SET display_order = display_order + 1 WHERE id != ? AND display_order >= ? AND display_order < ?'
).run(id, to, from);
}
return { shippedNow };
db.prepare(`
UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
shipped_at = ?, metadata_text = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return { shippedNow };
})();
}
export function deleteRoadmapItem(id: number): void {
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
db.transaction(() => {
const row = db.prepare('SELECT display_order FROM roadmap_items WHERE id = ?')
.get(id) as { display_order: number } | undefined;
db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id);
if (row) {
// Cascade: every row after the deleted slot shifts up by 1.
db.prepare(
'UPDATE roadmap_items SET display_order = display_order - 1 WHERE display_order > ?'
).run(row.display_order);
}
})();
}
export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null {

View file

@ -167,7 +167,7 @@ const bodyHtml = renderMd(d.body);
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: 720px;
max-width: 1080px;
margin: 0 auto;
display: flex;
flex-direction: column;

View file

@ -4,7 +4,7 @@ import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import {
getUpcomingEvents, getEventBySlug, getEventAttendees,
getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, castOrChangeVote,
@ -57,9 +57,19 @@ const memberNumberLabel = user.member_number != null
: null;
// ── Events ─────────────────────────────────────────────────────────
// Hero = next upcoming non-office-hours event (falls back to first upcoming
// of any kind if everything in the queue is office hours).
// Below: the most recent past event ("Previous") and the next non-office-hours
// upcoming event AFTER the hero ("Upcoming").
const upcoming = getUpcomingEvents(20);
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null;
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
const upcomingAfterHero = upcoming
.filter(e => e.id !== hero?.id && e.kind !== 'office_hours')[0]
?? upcoming.filter(e => e.id !== hero?.id)[0]
?? null;
const previousEvent = getPastEvents(20).find(e => e.kind !== 'office_hours')
?? getPastEvents(1)[0]
?? null;
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -123,21 +133,32 @@ const members = getAllCabMembers();
/>
</section>
<!-- ── 'Also coming up' strip (plain text on cream) ─────────── -->
<section class="cascade also-coming-up" aria-label="Also coming up">
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
<section class="cascade also-coming-up" aria-label="Surrounding events">
<div class="also-list">
{comingUp.slice(0, 2).map((ev, i) => (
<>
{i > 0 && <span class="also-divider" aria-hidden="true"></span>}
<div class="also-item">
<span class="also-day">{dayNum(ev.starts_at)}</span>
<div class="also-meta-col">
<span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span>
<span class="also-title">{ev.title}</span>
</div>
{previousEvent && (
<div class="also-item">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<div class="also-meta-col">
<span class="also-eyebrow">Previous</span>
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
<span class="also-title">{previousEvent.title}</span>
</div>
</>
))}
</div>
)}
{previousEvent && upcomingAfterHero && (
<span class="also-divider" aria-hidden="true"></span>
)}
{upcomingAfterHero && (
<div class="also-item">
<span class="also-day">{dayNum(upcomingAfterHero.starts_at)}</span>
<div class="also-meta-col">
<span class="also-eyebrow">Upcoming</span>
<span class="also-month-kind">{monthShort(upcomingAfterHero.starts_at)} · {eventKindLabel(upcomingAfterHero.kind).toUpperCase()}</span>
<span class="also-title">{upcomingAfterHero.title}</span>
</div>
</div>
)}
</div>
<a href="/events" class="also-link">All gatherings →</a>
</section>
@ -380,6 +401,14 @@ const members = getAllCabMembers();
gap: 2px;
min-width: 0;
}
.also-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
font-weight: 600;
}
.also-month-kind {
font-family: var(--font-sans);
font-size: 9px;