From 3b602a787b53837661f7bf569d581012561926df Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 15:57:52 +0200 Subject: [PATCH] feat(component): DispatchesSection + reusable Avatar + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DispatchesSection (white-card, used on /pulse): - Header row: LATEST FROM THE STUDIO + All dispatches → link - Featured: 26px avatar byline (name · title · relative time + kind pill), serif italic title, two-paragraph excerpt (lead in body tone, trail in --secondary with ellipsis) cut on the nearest sentence boundary, then the terracotta uppercase 'Read the full dispatch →' link - Earlier: 1px divider + EARLIER label + up to 3 rows with 22px avatar, serif italic title (single-line ellipsis), relative time - Hidden entirely when zero published dispatches; divider + earlier list omitted when exactly one published dispatch exists Avatar component (src/components/Avatar.astro) — pure presentational, takes id + name + size, paints a deterministic-pigment circle with serif italic initials. Reused by DispatchesSection now and by /members, /events, and RecentlyFromTheCouncil in the next commits. format.ts: adds dispatchKindPigment (decision→terracotta, update→indigo, behind_the_scenes→ochre, note→heather) for pill backgrounds. Tests: 9 cases covering create-as-draft vs published, publishDispatch idempotency (re-publish preserves published_at), archive preserves published_at, the published feed excludes drafts/archived, adjacent prev/next, and slug round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Avatar.astro | 43 +++++ src/components/DispatchesSection.astro | 240 +++++++++++++++++++++++++ src/lib/format.ts | Bin 8335 -> 8719 bytes tests/dispatches.test.ts | 102 +++++++++++ 4 files changed, 385 insertions(+) create mode 100644 src/components/Avatar.astro create mode 100644 src/components/DispatchesSection.astro create mode 100644 tests/dispatches.test.ts diff --git a/src/components/Avatar.astro b/src/components/Avatar.astro new file mode 100644 index 0000000..ec582ae --- /dev/null +++ b/src/components/Avatar.astro @@ -0,0 +1,43 @@ +--- +import { pigmentForId, initialsFromName } from '../lib/format'; + +interface Props { + id: number; + name: string; + size?: number; + tinted?: boolean; +} + +const { id, name, size = 32, tinted = true } = Astro.props; +const pigment = pigmentForId(id); + +const style = tinted + ? `--avatar-bg: ${pigment.token}; --avatar-size: ${size}px;` + : `--avatar-bg: var(--surface-container); --avatar-size: ${size}px;`; +--- + + + diff --git a/src/components/DispatchesSection.astro b/src/components/DispatchesSection.astro new file mode 100644 index 0000000..2be0e2c --- /dev/null +++ b/src/components/DispatchesSection.astro @@ -0,0 +1,240 @@ +--- +import Avatar from './Avatar.astro'; +import { getLatestPublishedDispatches } from '../lib/db'; +import { + dispatchSlug, dispatchKindLabel, dispatchKindPigment, + dispatchExcerptParas, relativeTime, +} from '../lib/format'; + +interface Props { + limit?: number; +} + +const { limit = 4 } = Astro.props; +const dispatches = getLatestPublishedDispatches(limit); + +const featured = dispatches[0] ?? null; +const earlier = dispatches.slice(1); +--- +{featured && ( +
+
+

Latest from the studio

+ All dispatches → +
+ + + + {earlier.length > 0 && ( + <> +
+

Earlier

+ + + )} +
+)} + + diff --git a/src/lib/format.ts b/src/lib/format.ts index 8d64ccc5507553378fb1310e42cc1728a3a0f73c..5795cf85ee1111ab8e6c83a3e46faa88bca2cf3c 100644 GIT binary patch delta 200 zcmeBo?04DlRl+(TGd(vouSB6FKRY!~AuYd1AyFYEv$!C!BsoJNJ2NjuN1?PhHANw* zQlTI-Cr2SEF*!TED8Do>rFe26v*_eM;ymn7GbZy&1Wi`rk(S_60D{tjl*E!$D-aU~ zCL3}|P41D9bSq0N($Li{fLN`YpPW&Ys;RC6)|Qu#rU|H79aVisYGO%7YLVvVYZ5!z E0q_AnXaE2J delta 12 TcmeBo>37`lRbq3g)GT%YB`O6( diff --git a/tests/dispatches.test.ts b/tests/dispatches.test.ts new file mode 100644 index 0000000..03798a4 --- /dev/null +++ b/tests/dispatches.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { + createDispatch, publishDispatch, archiveDispatch, + getDispatchById, getLatestPublishedDispatches, getAdjacentDispatches, + createUser, +} from '../src/lib/db.js'; +import { dispatchSlug, parseDispatchSlug } from '../src/lib/format.js'; + +const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db!; + +let admin: number; + +beforeEach(() => { + db.exec(` + DELETE FROM votes; + DELETE FROM pulses; + DELETE FROM activity; + DELETE FROM dispatches; + DELETE FROM users; + `); + admin = createUser({ email: 'a@x.test', password_hash: 'x', name: 'Admin', organisation: 'Fenja', role: 'fenja' }); +}); + +describe('Dispatch status transitions', () => { + it('draft create leaves published_at null', () => { + const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' }); + const d = getDispatchById(id)!; + expect(d.status).toBe('draft'); + expect(d.published_at).toBeNull(); + }); + + it('published create stamps published_at', () => { + const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'decision', author_id: admin, status: 'published' }); + const d = getDispatchById(id)!; + expect(d.status).toBe('published'); + expect(d.published_at).not.toBeNull(); + }); + + it('publishDispatch on a draft stamps published_at', () => { + const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' }); + publishDispatch(id); + const d = getDispatchById(id)!; + expect(d.status).toBe('published'); + expect(d.published_at).not.toBeNull(); + }); + + it('publishDispatch is idempotent — re-publishing keeps the original published_at', () => { + const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' }); + publishDispatch(id); + const first = getDispatchById(id)!.published_at; + // Force a tick so a re-stamp would visibly differ + publishDispatch(id); + const second = getDispatchById(id)!.published_at; + expect(second).toBe(first); + }); + + it('archiveDispatch preserves published_at and only flips status', () => { + const id = createDispatch({ title: 'T', body: 'B', excerpt: null, kind: 'update', author_id: admin, status: 'published' }); + const before = getDispatchById(id)!.published_at; + archiveDispatch(id); + const d = getDispatchById(id)!; + expect(d.status).toBe('archived'); + expect(d.published_at).toBe(before); + }); + + it('getLatestPublishedDispatches excludes drafts and archived', () => { + createDispatch({ title: 'A', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'draft' }); + const pubId = createDispatch({ title: 'B', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' }); + const archId = createDispatch({ title: 'C', body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' }); + archiveDispatch(archId); + const out = getLatestPublishedDispatches(10); + expect(out.map(d => d.id)).toEqual([pubId]); + }); + + it('getAdjacentDispatches returns prev/next in published order', () => { + const ids: number[] = []; + for (let i = 0; i < 3; i += 1) { + const id = createDispatch({ title: `T${i}`, body: 'B', excerpt: null, kind: 'note', author_id: admin, status: 'published' }); + // Backdate so ordering is deterministic + db.prepare("UPDATE dispatches SET published_at = datetime('now', ?) WHERE id = ?").run(`-${(3 - i)} days`, id); + ids.push(id); + } + const { prev, next } = getAdjacentDispatches(ids[1]); + expect(prev?.id).toBe(ids[0]); + expect(next?.id).toBe(ids[2]); + }); +}); + +describe('dispatch slug', () => { + it('produces {id}-kebab(title)', () => { + expect(dispatchSlug({ id: 7, title: 'A Decision About Telemetry' })).toBe('7-a-decision-about-telemetry'); + expect(dispatchSlug({ id: 12, title: 'Behind the Scenes: April' })).toBe('12-behind-the-scenes-april'); + }); + + it('parseDispatchSlug pulls the leading id', () => { + expect(parseDispatchSlug('7-a-decision-about-telemetry')).toBe(7); + expect(parseDispatchSlug('99')).toBe(99); + expect(parseDispatchSlug('not-a-slug')).toBeNull(); + expect(parseDispatchSlug('')).toBeNull(); + }); +});