diff --git a/src/admin/components/ResourceEditPanel.astro b/src/admin/components/ResourceEditPanel.astro index 7b70fb0..cee234c 100644 --- a/src/admin/components/ResourceEditPanel.astro +++ b/src/admin/components/ResourceEditPanel.astro @@ -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('.bs-md').forEach((root) => { const tabs = root.querySelectorAll('.bs-md-tab'); const input = root.querySelector('.bs-md-input'); const preview = root.querySelector('.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 = '

Nothing to preview yet.

'; + } 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 { diff --git a/src/admin/embeds/PulseSubForm.astro b/src/admin/embeds/PulseSubForm.astro index 4b78b95..6211c5b 100644 --- a/src/admin/embeds/PulseSubForm.astro +++ b/src/admin/embeds/PulseSubForm.astro @@ -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 {

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.

@@ -76,26 +76,18 @@ function toDatetimeLocal(v: string | null | undefined): string {
-
-
- - -
-
- - -
+
+ + +

+ The pulse closes at the end of the chosen day. Opens automatically when the + dispatch is published. +

diff --git a/src/admin/resources/dispatches.ts b/src/admin/resources/dispatches.ts index 4057d23..1109610 100644 --- a/src/admin/resources/dispatches.ts +++ b/src/admin/resources/dispatches.ts @@ -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 }; } diff --git a/src/admin/resources/events.ts b/src/admin/resources/events.ts index 483a49c..9fdcc2b 100644 --- a/src/admin/resources/events.ts +++ b/src/admin/resources/events.ts @@ -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 = { key: 'events', label: 'Events', @@ -79,10 +104,11 @@ export const eventsResource: Resource = { }, { 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 = { 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 = { 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 = { }, 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, }); diff --git a/src/components/RoadmapRoute.astro b/src/components/RoadmapRoute.astro index e2507ef..53e490e 100644 --- a/src/components/RoadmapRoute.astro +++ b/src/components/RoadmapRoute.astro @@ -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(); diff --git a/src/lib/db.ts b/src/lib/db.ts index 173214b..cf9ff94 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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 { diff --git a/src/pages/dispatches/[slug].astro b/src/pages/dispatches/[slug].astro index c6c6675..56728a0 100644 --- a/src/pages/dispatches/[slug].astro +++ b/src/pages/dispatches/[slug].astro @@ -167,7 +167,7 @@ const bodyHtml = renderMd(d.body);