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>
217 lines
8.5 KiB
TypeScript
217 lines
8.5 KiB
TypeScript
import type { ActivityKind, ActivityRow, DispatchWithAuthor, Role } from './db.js';
|
||
|
||
/** "2m ago" / "3h ago" / "5d ago" / "just now". */
|
||
export function relativeTime(iso: string, now: Date = new Date()): string {
|
||
const ms = now.getTime() - new Date(iso).getTime();
|
||
const m = Math.round(ms / 60_000);
|
||
if (m < 1) return 'just now';
|
||
if (m < 60) return `${m}m ago`;
|
||
const h = Math.round(m / 60);
|
||
if (h < 24) return `${h}h ago`;
|
||
const d = Math.round(h / 24);
|
||
return `${d}d ago`;
|
||
}
|
||
|
||
/** "Maya Rasmussen" → "Maya R." (single-word names unchanged). */
|
||
export function redactName(fullName: string): string {
|
||
const parts = fullName.trim().split(/\s+/);
|
||
if (parts.length === 1) return parts[0];
|
||
const last = parts[parts.length - 1][0] ?? '';
|
||
return `${parts[0]} ${last}.`;
|
||
}
|
||
|
||
/** Tenure as "2 years, 4 months" / "8 months" / "12 days". */
|
||
export function tenureSince(iso: string, now: Date = new Date()): string {
|
||
const start = new Date(iso);
|
||
const ms = now.getTime() - start.getTime();
|
||
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
||
if (days < 31) return `${days} day${days === 1 ? '' : 's'}`;
|
||
const months = Math.floor(days / 30.44);
|
||
if (months < 12) return `${months} month${months === 1 ? '' : 's'}`;
|
||
const years = Math.floor(months / 12);
|
||
const remMonths = months % 12;
|
||
if (remMonths === 0) return `${years} year${years === 1 ? '' : 's'}`;
|
||
return `${years} year${years === 1 ? '' : 's'}, ${remMonths} month${remMonths === 1 ? '' : 's'}`;
|
||
}
|
||
|
||
/** "MONDAY · 11 MAY" — uppercase day-of-week + day + short month. */
|
||
export function pulseDateLabel(now: Date = new Date()): string {
|
||
const day = new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(now);
|
||
const date = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'Europe/Copenhagen' }).format(now);
|
||
return `${day.toUpperCase()} · ${date.toUpperCase()}`;
|
||
}
|
||
|
||
/** Morning / afternoon / evening greeting based on Europe/Copenhagen hour. */
|
||
export function timeOfDay(now: Date = new Date()): 'morning' | 'afternoon' | 'evening' {
|
||
const hour = Number(new Intl.DateTimeFormat('en-GB', {
|
||
hour: 'numeric', hour12: false, timeZone: 'Europe/Copenhagen',
|
||
}).format(now));
|
||
if (hour < 12) return 'morning';
|
||
if (hour < 18) return 'afternoon';
|
||
return 'evening';
|
||
}
|
||
|
||
// ── Activity ticker mapping ────────────────────────────────────────
|
||
|
||
const ROLE_DOT: Record<Role, string> = {
|
||
pilot: 'var(--pigment-copper)',
|
||
cab: 'var(--pigment-terracotta)',
|
||
fenja: 'var(--pigment-indigo)',
|
||
};
|
||
|
||
export function dotForRole(role: Role): string {
|
||
return ROLE_DOT[role];
|
||
}
|
||
|
||
export interface TickerItem {
|
||
dotColor: string;
|
||
name: string;
|
||
phrase: string;
|
||
relative: string;
|
||
}
|
||
|
||
// ── Deterministic avatar pigment ─────────────────────────────────
|
||
|
||
/** Avatar pigment palette — the same five used elsewhere in the portal. */
|
||
export const AVATAR_PIGMENTS = [
|
||
{ name: 'terracotta', token: 'var(--pigment-terracotta)', hex: '#b96b58' },
|
||
{ name: 'copper', token: 'var(--pigment-copper)', hex: '#6d8c7c' },
|
||
{ name: 'walnut', token: 'var(--secondary)', hex: '#785f53' },
|
||
{ name: 'indigo', token: 'var(--pigment-indigo)', hex: '#5a6d83' },
|
||
{ name: 'heather', token: 'var(--pigment-heather)', hex: '#8d7a85' },
|
||
] as const;
|
||
|
||
export type AvatarPigment = (typeof AVATAR_PIGMENTS)[number];
|
||
|
||
/** Deterministic avatar pigment for a given user id. Same id → same pigment everywhere. */
|
||
export function pigmentForId(id: number): AvatarPigment {
|
||
return AVATAR_PIGMENTS[Math.abs(id) % AVATAR_PIGMENTS.length];
|
||
}
|
||
|
||
/** Initials from a name. "Mette Hansen" → "MH". "Jonathan" → "J". */
|
||
export function initialsFromName(name: string): string {
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length === 1) return (parts[0][0] ?? '').toUpperCase();
|
||
return ((parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')).toUpperCase();
|
||
}
|
||
|
||
// ── Focus tags parser ────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a comma-separated input string into a normalised tag list.
|
||
* Rules: trim, strip ASCII control chars, collapse internal whitespace to a
|
||
* single space, drop empties, dedupe (case-sensitive after normalisation),
|
||
* cap at 3 entries × 24 chars each. Deterministic; safe to call on every save.
|
||
*/
|
||
export function parseFocusTags(input: string): string[] {
|
||
if (!input) return [];
|
||
return input
|
||
.split(',')
|
||
// eslint-disable-next-line no-control-regex
|
||
.map(s => s.replace(/[ |