project-bifrost-platform/src/lib/format.ts
Jonathan Hvid 3b602a787b 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>
2026-05-11 15:57:52 +02:00

217 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(/[-]/g, '').replace(/\s+/g, ' ').trim())
.filter(s => s.length > 0)
.filter((s, i, arr) => arr.indexOf(s) === i)
.slice(0, 3)
.map(s => s.slice(0, 24));
}
/** Read a focus_tags JSON blob from the DB safely. Returns [] on any malformed input. */
export function readFocusTags(json: string | null): string[] {
if (!json) return [];
try {
const v = JSON.parse(json);
return Array.isArray(v) ? v.map(String).filter(Boolean) : [];
} catch { return []; }
}
// ── Dispatches ───────────────────────────────────────────────────
const KEBAB_SAFE = /[^a-z0-9]+/g;
/** {{id}}-{{kebab-cased-title}}. Stable on title rename only if the id leads. */
export function dispatchSlug(d: { id: number; title: string }): string {
const slug = d.title
.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(KEBAB_SAFE, '-')
.replace(/^-+|-+$/g, '');
return `${d.id}-${slug || 'dispatch'}`;
}
/** Pull the leading id out of a dispatch slug. Returns null if malformed. */
export function parseDispatchSlug(slug: string): number | null {
const m = slug.match(/^(\d+)(?:-|$)/);
if (!m) return null;
const n = Number(m[1]);
return Number.isInteger(n) && n > 0 ? n : null;
}
/** Title-case the kind value for display. */
export function dispatchKindLabel(kind: DispatchWithAuthor['kind']): string {
return ({
decision: 'Decision',
update: 'Update',
behind_the_scenes: 'Behind the scenes',
note: 'Note',
} as const)[kind];
}
/** Pigment token for a dispatch kind, used by pill backgrounds. */
export function dispatchKindPigment(kind: DispatchWithAuthor['kind']): string {
return ({
decision: 'var(--pigment-terracotta)',
update: 'var(--pigment-indigo)',
behind_the_scenes: 'var(--pigment-ochre)',
note: 'var(--pigment-heather)',
} as const)[kind];
}
/** Strip a small amount of markdown for body excerpts. */
export function stripMarkdownLight(md: string): string {
return md
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
.replace(/^>+\s?/gm, '')
.replace(/^#+\s?/gm, '')
.replace(/\n+/g, ' ')
.trim();
}
/** Two-paragraph dispatch excerpt: { lead, trail | null }. */
export function dispatchExcerptParas(d: { excerpt: string | null; body: string }): { lead: string; trail: string | null } {
const source = d.excerpt && d.excerpt.trim().length > 0
? d.excerpt.trim()
: stripMarkdownLight(d.body);
if (source.length <= 200) return { lead: source, trail: null };
// Find the nearest sentence boundary within ~200 chars
const window = source.slice(0, 240);
const lastDot = Math.max(
window.lastIndexOf('. '),
window.lastIndexOf('? '),
window.lastIndexOf('! '),
);
const cut = lastDot > 80 ? lastDot + 1 : 200;
return {
lead: source.slice(0, cut).trim(),
trail: source.slice(cut).trim().slice(0, 120).replace(/\s+\S*$/, '') + '…',
};
}
export function tickerItem(row: ActivityRow, subjectLabel: string | null): TickerItem {
const phrases: Record<ActivityKind, string> = {
voted: subjectLabel ? `weighed in on “${subjectLabel}` : 'cast a pulse vote',
rsvped: subjectLabel ? `is coming to ${subjectLabel}` : 'RSVPd to an event',
booked_office_hours: 'booked office hours',
roadmap_shipped: subjectLabel ? `shipped ${subjectLabel}` : 'shipped a roadmap item',
pulse_opened: subjectLabel ? `opened: “${subjectLabel}` : 'opened a new pulse',
};
return {
dotColor: dotForRole(row.actor_role),
name: redactName(row.actor_name),
phrase: phrases[row.kind],
relative: relativeTime(row.created_at),
};
}