Compare commits

..

No commits in common. "4aaf0957dd1773526b91e5207173a621d413687c" and "8bbf8568f4a775f12a59f7b1b394860256ebe4df" have entirely different histories.

9 changed files with 111 additions and 221 deletions

View file

@ -225,27 +225,16 @@ 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', async () => { tab.addEventListener('click', () => {
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 toDateOnly(v: string | null | undefined): string { function toDatetimeLocal(v: string | null | undefined): string {
if (!v) return ''; if (!v) return '';
const s = String(v); const s = String(v).replace(' ', 'T');
const m = s.match(/^(\d{4}-\d{2}-\d{2})/); const m = s.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
return m ? m[1] : ''; return m ? m[1] : '';
} }
--- ---
@ -37,8 +37,8 @@ function toDateOnly(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. Pulses open Leaving the question blank means no pulse on this dispatch.
when the dispatch is published and close at the end of the chosen day. Pulses open when the dispatch is published and close when it's archived.
</p> </p>
<div class="bs-field"> <div class="bs-field">
@ -76,18 +76,26 @@ function toDateOnly(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_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" id="pulse_closes_at"
name="pulse_closes_at" name="pulse_closes_at"
class="bs-input" class="bs-input"
value={toDateOnly(pulse?.closes_at)} value={toDatetimeLocal(pulse?.closes_at)}
/> />
<p class="bs-helper"> </div>
The pulse closes at the end of the chosen day. Opens automatically when the
dispatch is published.
</p>
</div> </div>
</div> </div>

View file

@ -27,6 +27,12 @@ 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', ' ');
} }
@ -35,12 +41,6 @@ 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,14 +51,10 @@ 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 =
// Opens always = now (or the dispatch's publish moment). closes_at comes toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
// from a date input and snaps to 23:59:59 on the chosen day; missing → const closes_at =
// default to 14 days out. toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
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,7 +17,6 @@ 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 {
@ -43,30 +42,6 @@ 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',
@ -104,11 +79,10 @@ export const eventsResource: Resource<Event> = {
}, },
{ {
key: 'starts_at', key: 'starts_at',
label: 'Date', label: 'Starts',
width: '180px', kind: 'relative-date',
render: (item) => ({ width: '110px',
title: item.starts_at ? fmtDateTime(item.starts_at) : '—', emptyFallback: '—',
}),
}, },
{ {
key: 'capacity', key: 'capacity',
@ -195,6 +169,13 @@ 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',
@ -225,20 +206,18 @@ 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: startsAt, starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: endsAt, ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
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: computeDurationLabel(startsAt, endsAt), duration_label: ((data.duration_label as string) ?? '').trim() || null,
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,
@ -246,19 +225,17 @@ 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: startsAt, starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: endsAt, ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
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: computeDurationLabel(startsAt, endsAt), duration_label: ((data.duration_label as string) ?? '').trim() || null,
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,17 +9,7 @@ interface Props {
const { items, viewportWidth = 1100 } = Astro.props; const { items, viewportWidth = 1100 } = Astro.props;
// Align the first milestone with the left edge of the page's content column const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
// (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> = {
@ -168,7 +158,6 @@ 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');
@ -191,17 +180,14 @@ 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);
// Match the SSR offset — first item aligns with the content-column const trackWidth = usableWidth + PADDING_X * 2;
// 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
? paddingLeft + usableWidth / 2 ? PADDING_X + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth, : PADDING_X + (i / (itemCount - 1)) * usableWidth,
); );
} }
@ -368,12 +354,16 @@ 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 anchor at the // Initial mount: recompute with the real viewport, then scroll the
// start so the first milestone aligns with the content-column left edge. // 'you are here' milestone roughly 25% from the left.
// (The "you are here" highlight on the most-recent shipping milestone is
// still visible — but it's no longer the scroll anchor.)
recompute(); recompute();
scroll.scrollLeft = 0; 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;
}
setTimeout(updateNav, 50); setTimeout(updateNav, 50);
updateNav(); updateNav();

View file

@ -716,15 +716,6 @@ 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 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(` const r = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text) INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text)
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
@ -733,12 +724,11 @@ export function createRoadmapItem(data: {
data.description, data.description,
data.status, data.status,
data.target ?? null, data.target ?? null,
requestedOrder, data.display_order ?? 0,
shipped_at, shipped_at,
data.metadata_text ?? null, data.metadata_text ?? null,
); );
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
})();
} }
/** /**
@ -753,8 +743,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, display_order 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; display_order: number } | undefined; .get(id) as { status: RoadmapStatus; shipped_at: string | null } | 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;
@ -762,22 +752,6 @@ 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;
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);
}
db.prepare(` db.prepare(`
UPDATE roadmap_items UPDATE roadmap_items
SET title = ?, description = ?, status = ?, target = ?, display_order = ?, SET title = ?, description = ?, status = ?, target = ?, display_order = ?,
@ -786,21 +760,10 @@ export function updateRoadmapItem(id: number, data: {
`).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id); `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, id);
return { shippedNow }; return { shippedNow };
})();
} }
export function deleteRoadmapItem(id: number): void { export function deleteRoadmapItem(id: number): void {
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); 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,9 +19,7 @@ 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 — symmetric leading + trailing padding paddingX?: number; // default 60
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
} }
export interface LayoutResult { export interface LayoutResult {
@ -37,9 +35,7 @@ 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 paddingDef = opts.paddingX ?? 60; const padding = 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);
@ -61,12 +57,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 = paddingL + usableWidth + paddingR; const trackWidth = usableWidth + padding * 2;
const itemX: number[] = Array.from({ length: itemCount }, (_, i) => const itemX: number[] = Array.from({ length: itemCount }, (_, i) =>
itemCount === 1 itemCount === 1
? paddingL + usableWidth / 2 ? padding + usableWidth / 2
: paddingL + (i / (itemCount - 1)) * usableWidth, : padding + (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: 1080px; max-width: 720px;
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, getPastEvents, getEventBySlug, getEventAttendees, getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity, getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll, getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, castOrChangeVote, getAllCabMembers, getPulseById, castOrChangeVote,
@ -57,19 +57,9 @@ 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 upcomingAfterHero = upcoming const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
.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);
@ -133,32 +123,21 @@ const members = getAllCabMembers();
/> />
</section> </section>
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── --> <!-- ── 'Also coming up' strip (plain text on cream) ─────────── -->
<section class="cascade also-coming-up" aria-label="Surrounding events"> <section class="cascade also-coming-up" aria-label="Also coming up">
<div class="also-list"> <div class="also-list">
{previousEvent && ( {comingUp.slice(0, 2).map((ev, i) => (
<>
{i > 0 && <span class="also-divider" aria-hidden="true"></span>}
<div class="also-item"> <div class="also-item">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span> <span class="also-day">{dayNum(ev.starts_at)}</span>
<div class="also-meta-col"> <div class="also-meta-col">
<span class="also-eyebrow">Previous</span> <span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span>
<span class="also-month-kind">{monthShort(previousEvent.starts_at)} · {eventKindLabel(previousEvent.kind).toUpperCase()}</span> <span class="also-title">{ev.title}</span>
<span class="also-title">{previousEvent.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>
@ -401,14 +380,6 @@ 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;