feat(component): DispatchesSection + reusable Avatar + tests

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 15:57:52 +02:00
parent 302caf8896
commit 3b602a787b
4 changed files with 385 additions and 0 deletions

View file

@ -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;`;
---
<span
class="avatar"
style={style}
data-pigment={pigment.name}
aria-hidden="true"
title={name}
>{initialsFromName(name)}</span>
<style>
.avatar {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
background: var(--avatar-bg);
color: var(--on-primary);
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-serif);
font-style: italic;
font-size: calc(var(--avatar-size) * 0.40);
line-height: 1;
flex-shrink: 0;
user-select: none;
}
</style>

View file

@ -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 && (
<section class="d-card" aria-label="Latest from the studio">
<header class="d-head">
<p class="label-sm d-eyebrow">Latest from the studio</p>
<a href="/dispatches" class="d-all">All dispatches →</a>
</header>
<article class="d-featured">
<header class="d-byline">
<Avatar id={featured.author_id} name={featured.author_name} size={26} />
<span class="d-byline-name">{featured.author_name}</span>
{featured.author_title && <span class="d-byline-title">· {featured.author_title}</span>}
<span class="d-byline-time label-sm">{relativeTime(featured.published_at ?? featured.created_at)}</span>
<span class="d-kind-pill" style={`--pill: ${dispatchKindPigment(featured.kind)}`}>
{dispatchKindLabel(featured.kind)}
</span>
</header>
<h3 class="d-title">{featured.title}</h3>
{(() => {
const { lead, trail } = dispatchExcerptParas(featured);
return (
<div class="d-excerpt">
<p class="d-lead">{lead}</p>
{trail && <p class="d-trail">{trail}</p>}
</div>
);
})()}
<a href={`/dispatches/${dispatchSlug(featured)}`} class="d-read">Read the full dispatch →</a>
</article>
{earlier.length > 0 && (
<>
<hr class="d-divider" />
<p class="label-sm d-eyebrow d-earlier-label">Earlier</p>
<ul class="d-earlier-list">
{earlier.map(d => (
<li class="d-earlier-row">
<a href={`/dispatches/${dispatchSlug(d)}`} class="d-earlier-link">
<Avatar id={d.author_id} name={d.author_name} size={22} />
<span class="d-earlier-title">{d.title}</span>
<span class="d-earlier-time label-sm">{relativeTime(d.published_at ?? d.created_at)}</span>
</a>
</li>
))}
</ul>
</>
)}
</section>
)}
<style>
.d-card {
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.d-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.d-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
margin: 0;
}
.d-all {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.d-all:hover {
color: var(--pigment-terracotta);
border-bottom: none;
opacity: 0.85;
}
.d-featured {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.d-byline {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
font-size: var(--text-body-sm);
}
.d-byline-name {
font-weight: 600;
color: var(--on-surface);
}
.d-byline-title {
color: var(--on-surface-variant);
}
.d-byline-time {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.d-kind-pill {
background: color-mix(in oklab, var(--pill) 14%, transparent);
color: var(--pill);
padding: 2px 9px;
border-radius: 999px;
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide);
font-weight: 600;
}
.d-title {
font-family: var(--font-serif);
font-style: italic;
font-weight: 400;
font-size: 1.25rem;
line-height: 1.3;
color: var(--on-surface);
margin: 0;
}
.d-excerpt {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.d-lead {
margin: 0;
color: var(--on-surface);
line-height: var(--leading-relaxed);
}
.d-trail {
margin: 0;
color: var(--secondary);
line-height: var(--leading-relaxed);
}
.d-read {
align-self: flex-start;
font-family: var(--font-sans);
font-size: var(--text-label-md);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--pigment-terracotta);
text-decoration: none;
border-bottom: none;
}
.d-read:hover { color: var(--pigment-terracotta); opacity: 0.85; border-bottom: none; }
.d-divider {
height: 1px;
background: var(--surface-card-border);
border: none;
margin: var(--space-3) 0 var(--space-2);
}
.d-earlier-label { margin-bottom: var(--space-2); }
.d-earlier-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.d-earlier-row {
padding: 0;
}
.d-earlier-link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.d-earlier-link:hover {
opacity: 0.7;
border-bottom: none;
}
.d-earlier-title {
font-family: var(--font-serif);
font-style: italic;
font-size: var(--text-body-md);
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.d-earlier-time {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
}
</style>

Binary file not shown.

102
tests/dispatches.test.ts Normal file
View file

@ -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();
});
});