From 9800d0a448e4adb574b5582e9cb2df3b38a8db66 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 10:19:00 +0200 Subject: [PATCH] feat(pulse): two-box Fenja+poll, prominent hero, single-bg council, more air MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout (per the v4 follow-up spec): 1b. Latest from Fenja is now a two-box layout when there's an attached poll: article on the left (wider), poll widget on the right. Without a poll, the article box takes the full row. Both boxes are surfaced on --surface-card with the same generous padding so they read as sibling pieces. 1c. Featured excerpt is extended to ~720 chars (was ~520) via a wider threshold on dispatchLongPreview. Below the article+poll row, the next two most-recent published dispatches render as minimalist rows — just title + kind + relative time, separated by ghost borders. 2. Hero event: date column is now 150px wide (was 110px); grid uses align-items: center so the date+detail columns are vertically aligned rather than top-stuck. Day number scaled up to 3.5rem (was 2.75). Outer card padding bumped from --space-7 to --space-10. Hero title bumped to 2rem. 3. More air: page-level section gap --space-10 → --space-12. Each on-page card has been re-padded; outer page horizontal padding goes down to --space-16 from --space-20 to match the narrower canvas. 6. Council members no longer have individual card chrome. One outer --surface-card wraps the whole grid; each member cell is just an avatar + name + title + company stack with no background or border. Cells use a larger 6/8 grid gap so they don't crowd each other. Inline poll widget on /dispatches/[slug]: when a dispatch has an attached pulse, the article body is followed by a compact poll card matching the /pulse-side widget. Vote POST handled inline; the page re-renders with the locked + result-bar state. scripts/seed-demo.js: the existing 'Which milestone should we anchor Q3 around?' pulse now attaches to the decision dispatch ('We are deprioritising public-cloud parity for Q3') via pulse_id. Other dispatches stay poll-free. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/seed-demo.js | 30 ++- src/pages/dispatches/[slug].astro | 151 ++++++++++- src/pages/pulse.astro | 411 +++++++++++++++++++++++------- 3 files changed, 489 insertions(+), 103 deletions(-) diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js index dbd9286..aeab873 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -143,14 +143,17 @@ const nowIso = (offsetSeconds = 0) => { return d.toISOString().replace('T', ' ').slice(0, 19); }; -// ── Pulse: open now, closes in 5 days, 2 of 4 voted ──────────────── +// ── Poll attached to a dispatch (the decision) — open, 2/4 voted ── +// Polls are no longer standalone; they attach to a dispatch via pulse_id. +// We create the pulse first, capture its id, and stamp it on the dispatch +// when we INSERT it further down. const pulseOptions = [ 'Locking down on-prem deployment first', 'Pushing the traceability layer to GA', 'Going wide on document ingestion', 'Building the agentic query loop', ]; -const pulseId = db.prepare(` +const decisionPulseId = db.prepare(` INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) VALUES (?,?,?,?,?,?,?) `).run( @@ -160,11 +163,11 @@ const pulseId = db.prepare(` nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, ).lastInsertRowid; -// 2 votes from cabs[0] and cabs[1] +// 2 votes — Lars and Anna db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') - .run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600)); + .run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600)); db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') - .run(pulseId, cabs[1].id, 1, nowIso(-30 * 60)); + .run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60)); // ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ─────── const roadmap = [ @@ -249,14 +252,17 @@ It is not a blog. It is the studio talking to the room — short, dated, signed. const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all(); const insertDispatch = db.prepare(` - INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at) - VALUES (?,?,?,?,?,'published',?,?,?) + INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id) + VALUES (?,?,?,?,?,'published',?,?,?,?) `); for (let i = 0; i < dispatchSeed.length; i += 1) { const d = dispatchSeed[i]; const when = nowIso(-d.ageDays * 24 * 60 * 60); const authorId = fenjas[i % fenjas.length].id; - insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when); + // Attach the decision-pulse to the decision dispatch — this is the demo + // case for polls-as-articles. Other dispatches stay poll-free. + const attachedPulse = d.kind === 'decision' ? decisionPulseId : null; + insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse); } // ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past @@ -329,12 +335,12 @@ const insertActivity = db.prepare(` INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at) VALUES (?,?,?,?,?) `); -insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600)); -insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600)); -insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60)); +insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600)); +insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600)); +insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); -console.log(' pulse #' + pulseId + ' open, 2 of 4 voted'); +console.log(' pulse #' + decisionPulseId + ' open, 2 of 4 voted'); console.log(' roadmap: 1 shipping / 1 beta / 2 exploring'); console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)'); diff --git a/src/pages/dispatches/[slug].astro b/src/pages/dispatches/[slug].astro index e889cfa..41a3abb 100644 --- a/src/pages/dispatches/[slug].astro +++ b/src/pages/dispatches/[slug].astro @@ -1,7 +1,10 @@ --- import AppLayout from '../../layouts/AppLayout.astro'; import Avatar from '../../components/Avatar.astro'; -import { getDispatchById, getAdjacentDispatches } from '../../lib/db'; +import { + getDispatchWithPoll, getAdjacentDispatches, + getPulseById, getUserVote, castVote, recordActivity, countCabMembers, +} from '../../lib/db'; import { parseDispatchSlug, dispatchSlug, dispatchKindLabel, dispatchKindPigment, roleLabel, @@ -14,15 +17,39 @@ const id = parseDispatchSlug(slugParam); if (!id) return Astro.redirect('/dispatches'); -const d = getDispatchById(id); +// Vote POST — handled before main render so we can refresh state +if (Astro.request.method === 'POST') { + const data = await Astro.request.formData(); + const action = String(data.get('action') ?? ''); + if (action === 'vote') { + const pulseId = Number(data.get('pulse_id')); + const optionIndex = Number(data.get('option_index')); + const target = getPulseById(pulseId); + if (target && target.status === 'open' && Number.isInteger(optionIndex) + && optionIndex >= 0 && optionIndex < target.options.length + && getUserVote(pulseId, user.id) === null) { + castVote(pulseId, user.id, optionIndex); + recordActivity(user.id, 'voted', 'pulse', pulseId); + } + return Astro.redirect(Astro.url.pathname); + } +} + +const d = getDispatchWithPoll(id, user.id); if (!d || d.status !== 'published') return Astro.redirect('/dispatches'); // Canonical-redirect when the slug changes after a rename — id is the authority const canonical = dispatchSlug(d); if (slugParam !== canonical) return Astro.redirect(`/dispatches/${canonical}`); +const totalMembers = countCabMembers(); const { prev, next } = getAdjacentDispatches(d.id); +function closeDayLabel(closesAt: string): string { + const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z'); + return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed); +} + function parseUtc(s: string): Date { if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); return new Date(s.replace(' ', 'T') + 'Z'); @@ -63,6 +90,47 @@ const bodyHtml = renderMd(d.body);
+ {d.poll && ( + + )} +
+ + {moreDispatches.length > 0 && ( + + )} + + All updates from Fenja + )} @@ -214,26 +302,26 @@ const members = getAllCabMembers(); ))} - See the full roadmap → + See the full roadmap )} - + {members.length > 0 && (
    {members.map(m => ( -
  • +
  • -
    - {m.name} - {m.title && {m.title}} - {m.organisation} +
    + {m.name} + {m.title && {m.title}} + {m.organisation}
  • ))}
- See who our council is made up of → + See who our council is made up of
)} @@ -242,12 +330,12 @@ const members = getAllCabMembers();