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:
parent
302caf8896
commit
3b602a787b
4 changed files with 385 additions and 0 deletions
43
src/components/Avatar.astro
Normal file
43
src/components/Avatar.astro
Normal 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>
|
||||||
240
src/components/DispatchesSection.astro
Normal file
240
src/components/DispatchesSection.astro
Normal 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
102
tests/dispatches.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue