Compare commits

...

2 commits

Author SHA1 Message Date
4aaf0957dd 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>
2026-05-12 17:46:06 +02:00
220f8e0290 style(roadmap): align first milestone with content column left edge
The first route item now starts where the dispatch banner's left edge
sits (the page's content-max column), instead of 60px from the
viewport edge. Looks intentional now — the route and the dispatch
banner share a vertical anchor.

- computeRouteLayout now accepts optional paddingLeft / paddingRight
  that override the symmetric paddingX. Existing call sites and
  tests are unchanged.
- RoadmapRoute SSR + client recompute set paddingLeft = max(60,
  (vw - 1152) / 2), so on viewports ≤ 1152px nothing moves (degrades
  gracefully) and on wider screens the start migrates inward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:06:30 +02:00
9 changed files with 221 additions and 111 deletions

View file

@ -225,16 +225,27 @@ const formAction = Astro.url.pathname + Astro.url.search;
}); });
// ── Markdown Write/Preview toggle ──────────────────────────────────────── // ── 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) => { document.querySelectorAll<HTMLElement>('.bs-md').forEach((root) => {
const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab'); const tabs = root.querySelectorAll<HTMLButtonElement>('.bs-md-tab');
const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input'); const input = root.querySelector<HTMLTextAreaElement>('.bs-md-input');
const preview = root.querySelector<HTMLElement>('.bs-md-preview'); const preview = root.querySelector<HTMLElement>('.bs-md-preview');
if (!input || !preview) return; if (!input || !preview) return;
tabs.forEach((tab) => { tabs.forEach((tab) => {
tab.addEventListener('click', () => { tab.addEventListener('click', async () => {
const mode = tab.getAttribute('data-md-mode'); const mode = tab.getAttribute('data-md-mode');
tabs.forEach((t) => t.classList.toggle('is-active', t === tab)); tabs.forEach((t) => t.classList.toggle('is-active', t === tab));
if (mode === 'preview') { 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; input.hidden = true;
preview.hidden = false; preview.hidden = false;
} else { } else {

View file

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

View file

@ -27,12 +27,6 @@ import type { FieldContext, Resource } from '../resource-types';
// ── Helpers ───────────────────────────────────────────────────────────────── // ── 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 { function nowSqlite(): string {
return new Date().toISOString().slice(0, 19).replace('T', ' '); 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', ' '); 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. */ /** Read pulse_* fields out of FormData. Returns null when no question was provided. */
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null { function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
const question = String(formData.get('pulse_question') ?? '').trim(); const question = String(formData.get('pulse_question') ?? '').trim();
@ -51,10 +51,14 @@ function extractPulseFromFormData(formData: FormData): DispatchPollInput | null
.filter(Boolean) .filter(Boolean)
.slice(0, 4); .slice(0, 4);
if (options.length < 2) return null; if (options.length < 2) return null;
const opens_at =
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite(); // Opens always = now (or the dispatch's publish moment). closes_at comes
const closes_at = // from a date input and snaps to 23:59:59 on the chosen day; missing →
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14); // 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 }; return { question, options, opens_at, closes_at };
} }

View file

@ -17,6 +17,7 @@ import {
type EventKind, type EventKind,
} from '../../lib/db'; } from '../../lib/db';
import { eventKindLabel } from '../../lib/format'; import { eventKindLabel } from '../../lib/format';
import { fmtDateTime } from '../../lib/markdown';
import type { Resource } from '../resource-types'; import type { Resource } from '../resource-types';
function slugify(s: string): string { function slugify(s: string): string {
@ -42,6 +43,30 @@ function toSqliteDatetime(s: string): string {
return s.replace('T', ' ') + (s.length === 16 ? ':00' : ''); 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> = { export const eventsResource: Resource<Event> = {
key: 'events', key: 'events',
label: 'Events', label: 'Events',
@ -79,10 +104,11 @@ export const eventsResource: Resource<Event> = {
}, },
{ {
key: 'starts_at', key: 'starts_at',
label: 'Starts', label: 'Date',
kind: 'relative-date', width: '180px',
width: '110px', render: (item) => ({
emptyFallback: '—', title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
}),
}, },
{ {
key: 'capacity', key: 'capacity',
@ -169,13 +195,6 @@ export const eventsResource: Resource<Event> = {
maxLength: 200, maxLength: 200,
helperText: 'Free-form note about who the event is for (e.g. "Council members only").', 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', key: 'action_label',
label: 'Action button label', label: 'Action button label',
@ -206,18 +225,20 @@ export const eventsResource: Resource<Event> = {
create: (data, ctx) => { create: (data, ctx) => {
const rawSlug = ((data.slug as string) ?? '').trim(); const rawSlug = ((data.slug as string) ?? '').trim();
const slug = uniqueEventSlug(rawSlug ? slugify(rawSlug) : slugify(String(data.title))); 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({ return createEvent({
slug, slug,
title: String(data.title), title: String(data.title),
kind: data.kind as EventKind, kind: data.kind as EventKind,
description: String(data.description), description: String(data.description),
location: String(data.location), location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)), starts_at: startsAt,
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null, ends_at: endsAt,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity), capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null, photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience 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, action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null, notes_url: ((data.notes_url as string) ?? '').trim() || null,
created_by: ctx.user.id, created_by: ctx.user.id,
@ -225,17 +246,19 @@ export const eventsResource: Resource<Event> = {
}, },
update: (id, data) => { update: (id, data) => {
const startsAt = toSqliteDatetime(String(data.starts_at));
const endsAt = data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null;
updateEvent(id, { updateEvent(id, {
title: String(data.title), title: String(data.title),
kind: data.kind as EventKind, kind: data.kind as EventKind,
description: String(data.description), description: String(data.description),
location: String(data.location), location: String(data.location),
starts_at: toSqliteDatetime(String(data.starts_at)), starts_at: startsAt,
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null, ends_at: endsAt,
capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity), capacity: data.capacity == null || data.capacity === '' ? null : Number(data.capacity),
photo_url: ((data.photo_url as string) ?? '').trim() || null, photo_url: ((data.photo_url as string) ?? '').trim() || null,
audience: ((data.audience 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, action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null, notes_url: ((data.notes_url as string) ?? '').trim() || null,
}); });

View file

@ -9,7 +9,17 @@ interface Props {
const { items, viewportWidth = 1100 } = Astro.props; const { items, viewportWidth = 1100 } = Astro.props;
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth }); // Align the first milestone with the left edge of the page's content column
// (matches the LatestDispatchBanner below). --content-max is 72rem = 1152px.
const CONTENT_MAX = 1152;
const DEFAULT_PADDING = 60;
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
const layout = computeRouteLayout({
itemCount: items.length,
viewportWidth,
paddingLeft,
});
const travelledStop = travelledStopFor(items.map(i => i.status)); const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = { const STATUS_LABEL: Record<RoadmapStatus, string> = {
@ -158,6 +168,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// amplitude doesn't change with viewport, only the horizontal spread). // amplitude doesn't change with viewport, only the horizontal spread).
const MIN_SPACING = 320; const MIN_SPACING = 320;
const PADDING_X = 60; const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
document.querySelectorAll<HTMLElement>('.route').forEach((section) => { document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll'); const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -180,14 +191,17 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const targetUsableWidth = vw * 0.80; const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING; const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth); const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + PADDING_X * 2; // Match the SSR offset — first item aligns with the content-column
// left edge so the route lines up with the dispatch banner below.
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
const trackWidth = paddingLeft + usableWidth + PADDING_X;
const itemX: number[] = []; const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) { for (let i = 0; i < itemCount; i += 1) {
itemX.push( itemX.push(
itemCount === 1 itemCount === 1
? PADDING_X + usableWidth / 2 ? paddingLeft + usableWidth / 2
: PADDING_X + (i / (itemCount - 1)) * usableWidth, : paddingLeft + (i / (itemCount - 1)) * usableWidth,
); );
} }
@ -354,16 +368,12 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120); resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
}); });
// Initial mount: recompute with the real viewport, then scroll the // Initial mount: recompute with the real viewport, then anchor at the
// 'you are here' milestone roughly 25% from the left. // 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(); recompute();
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current')); scroll.scrollLeft = 0;
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;
}
setTimeout(updateNav, 50); setTimeout(updateNav, 50);
updateNav(); updateNav();

View file

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

View file

@ -19,7 +19,9 @@ export interface LayoutOpts {
minSpacingX?: number; // default 320 minSpacingX?: number; // default 320
trackHeight?: number; // default 460 trackHeight?: number; // default 460
amplitude?: number; // default 120 amplitude?: number; // default 120
paddingX?: number; // default 60 paddingX?: number; // default 60 — symmetric leading + trailing padding
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
} }
export interface LayoutResult { export interface LayoutResult {
@ -35,7 +37,9 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const minSpacing = opts.minSpacingX ?? 320; const minSpacing = opts.minSpacingX ?? 320;
const trackHeight = opts.trackHeight ?? 420; const trackHeight = opts.trackHeight ?? 420;
const amplitude = opts.amplitude ?? 120; const amplitude = opts.amplitude ?? 120;
const padding = opts.paddingX ?? 60; const paddingDef = opts.paddingX ?? 60;
const paddingL = opts.paddingLeft ?? paddingDef;
const paddingR = opts.paddingRight ?? paddingDef;
const midY = trackHeight / 2; const midY = trackHeight / 2;
const itemCount = Math.max(0, opts.itemCount); const itemCount = Math.max(0, opts.itemCount);
@ -57,12 +61,12 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
const targetUsableWidth = opts.viewportWidth * 0.80; const targetUsableWidth = opts.viewportWidth * 0.80;
const dataDrivenWidth = (itemCount - 1) * minSpacing; const dataDrivenWidth = (itemCount - 1) * minSpacing;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth); const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + padding * 2; const trackWidth = paddingL + usableWidth + paddingR;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) => const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1 itemCount === 1
? padding + usableWidth / 2 ? paddingL + usableWidth / 2
: padding + (i / (itemCount - 1)) * usableWidth, : paddingL + (i / (itemCount - 1)) * usableWidth,
); );
// First item on the centreline; subsequent items alternate up/down with // First item on the centreline; subsequent items alternate up/down with

View file

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

View file

@ -4,7 +4,7 @@ import Avatar from '../components/Avatar.astro';
import EventHeroCard from '../components/EventHeroCard.astro'; import EventHeroCard from '../components/EventHeroCard.astro';
import RoadmapCarousel from '../components/RoadmapCarousel.astro'; import RoadmapCarousel from '../components/RoadmapCarousel.astro';
import { import {
getUpcomingEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getPastEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll, getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, castOrChangeVote, getAllCabMembers, getPulseById, castOrChangeVote,
@ -57,9 +57,19 @@ const memberNumberLabel = user.member_number != null
: null; : null;
// ── Events ───────────────────────────────────────────────────────── // ── 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 upcoming = getUpcomingEvents(20);
const hero = upcoming.find(e => e.kind !== 'office_hours') ?? upcoming[0] ?? null; 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 { function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -123,21 +133,32 @@ const members = getAllCabMembers();
/> />
</section> </section>
<!-- ── 'Also coming up' strip (plain text on cream) ─────────── --> <!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
<section class="cascade also-coming-up" aria-label="Also coming up"> <section class="cascade also-coming-up" aria-label="Surrounding events">
<div class="also-list"> <div class="also-list">
{comingUp.slice(0, 2).map((ev, i) => ( {previousEvent && (
<> <div class="also-item">
{i > 0 && <span class="also-divider" aria-hidden="true"></span>} <span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<div class="also-item"> <div class="also-meta-col">
<span class="also-day">{dayNum(ev.starts_at)}</span> <span class="also-eyebrow">Previous</span>
<div class="also-meta-col"> <span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span>
<span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span> <span class="also-title">{previousEvent.title}</span>
<span class="also-title">{ev.title}</span>
</div>
</div> </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> </div>
<a href="/events" class="also-link">All gatherings →</a> <a href="/events" class="also-link">All gatherings →</a>
</section> </section>
@ -380,6 +401,14 @@ const members = getAllCabMembers();
gap: 2px; gap: 2px;
min-width: 0; 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 { .also-month-kind {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 9px; font-size: 9px;