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

View file

@ -27,6 +27,12 @@ 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', ' ');
}
@ -35,12 +41,6 @@ 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,14 +51,10 @@ function extractPulseFromFormData(formData: FormData): DispatchPollInput | null
.filter(Boolean)
.slice(0, 4);
if (options.length < 2) return null;
// 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);
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);
return { question, options, opens_at, closes_at };
}

View file

@ -17,7 +17,6 @@ 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 {
@ -43,30 +42,6 @@ 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',
@ -104,11 +79,10 @@ export const eventsResource: Resource<Event> = {
},
{
key: 'starts_at',
label: 'Date',
width: '180px',
render: (item) => ({
title: item.starts_at ? fmtDateTime(item.starts_at) : '—',
}),
label: 'Starts',
kind: 'relative-date',
width: '110px',
emptyFallback: '—',
},
{
key: 'capacity',
@ -195,6 +169,13 @@ 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',
@ -225,20 +206,18 @@ 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: startsAt,
ends_at: endsAt,
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
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: computeDurationLabel(startsAt, endsAt),
duration_label: ((data.duration_label as string) ?? '').trim() || null,
action_label: ((data.action_label as string) ?? '').trim() || null,
notes_url: ((data.notes_url as string) ?? '').trim() || null,
created_by: ctx.user.id,
@ -246,19 +225,17 @@ 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: startsAt,
ends_at: endsAt,
starts_at: toSqliteDatetime(String(data.starts_at)),
ends_at: data.ends_at ? toSqliteDatetime(String(data.ends_at)) : null,
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: computeDurationLabel(startsAt, endsAt),
duration_label: ((data.duration_label as string) ?? '').trim() || null,
action_label: ((data.action_label 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;
// 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 layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
const travelledStop = travelledStopFor(items.map(i => i.status));
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).
const MIN_SPACING = 320;
const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -191,17 +180,14 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
// 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 trackWidth = usableWidth + PADDING_X * 2;
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
itemX.push(
itemCount === 1
? paddingLeft + usableWidth / 2
: paddingLeft + (i / (itemCount - 1)) * usableWidth,
? PADDING_X + usableWidth / 2
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
);
}
@ -368,12 +354,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
});
// 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.)
// Initial mount: recompute with the real viewport, then scroll the
// 'you are here' milestone roughly 25% from the left.
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);
updateNav();

View file

@ -716,15 +716,6 @@ 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 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 (?,?,?,?,?,?,?)
@ -733,12 +724,11 @@ export function createRoadmapItem(data: {
data.description,
data.status,
data.target ?? null,
requestedOrder,
data.display_order ?? 0,
shipped_at,
data.metadata_text ?? null,
);
return Number(r.lastInsertRowid);
})();
}
/**
@ -753,8 +743,8 @@ export function updateRoadmapItem(id: number, data: {
display_order: number;
metadata_text?: string | null;
}): { shippedNow: boolean } {
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;
const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?')
.get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined;
if (!current) throw new Error(`Roadmap item ${id} not found`);
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', ' ')
: 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(`
UPDATE roadmap_items
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);
return { shippedNow };
})();
}
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);
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

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

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: 1080px;
max-width: 720px;
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, getPastEvents, getEventBySlug, getEventAttendees,
getUpcomingEvents, getEventBySlug, getEventAttendees,
getUserRsvp, setEventRsvp, recordActivity,
getAllRoadmapItems, getLatestPublishedDispatches, getDispatchWithPoll,
getAllCabMembers, getPulseById, castOrChangeVote,
@ -57,19 +57,9 @@ 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 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;
const comingUp = upcoming.filter(e => e.id !== hero?.id).slice(0, 4);
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -133,32 +123,21 @@ const members = getAllCabMembers();
/>
</section>
<!-- ── Previous + Upcoming strip (plain text on cream) ─────── -->
<section class="cascade also-coming-up" aria-label="Surrounding events">
<!-- ── 'Also coming up' strip (plain text on cream) ─────────── -->
<section class="cascade also-coming-up" aria-label="Also coming up">
<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">
<span class="also-day">{dayNum(previousEvent.starts_at)}</span>
<span class="also-day">{dayNum(ev.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>
<span class="also-month-kind">{monthShort(ev.starts_at)} · {eventKindLabel(ev.kind).toUpperCase()}</span>
<span class="also-title">{ev.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>
@ -401,14 +380,6 @@ 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;