import type { ActivityKind, ActivityRow, DispatchWithAuthor, EventKind, 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'; } // ── Event kind labels + default action labels ────────────────────── /** Human display label for an event kind. office_hours surfaces as 'Studio hours'. */ export function eventKindLabel(kind: EventKind): string { return ({ dinner: 'Dinner', office_hours: 'Studio hours', summit: 'Summit', virtual: 'Virtual', working_session: 'Working session', } as const)[kind]; } /** Default action label per event kind, used when event.action_label is null. */ export function defaultActionLabel(kind: EventKind): string { return ({ dinner: 'Save your seat →', office_hours: 'Book a slot →', summit: 'RSVP →', virtual: 'Join →', working_session: 'RSVP →', } as const)[kind]; } // ── Vote-count copy ──────────────────────────────────────────────── /** * "{votes} of {total} council member(s) ha(s|ve) weighed in." * Handles 0 (no members), 1 (singular), and 2+ (plural) correctly. */ export function voteCountSentence(votes: number, total: number): string { if (total === 0) return `${votes} of 0 council members have weighed in.`; if (total === 1) return `${votes} of 1 council member has weighed in.`; return `${votes} of ${total} council members have weighed in.`; } // ── Activity ticker mapping ──────────────────────────────────────── const ROLE_DOT: Record = { 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]; } /** Friendly role label, falls back to title-case role name. */ export function roleLabel(role: Role): string { return ({ pilot: 'Pilot', cab: 'Council', fenja: 'Fenja team' } as const)[role]; } /** 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(); } /** Longer single-paragraph preview (for /pulse hero placement). Defaults to ~520 chars cut on the nearest sentence boundary. */ export function dispatchLongPreview(d: { excerpt: string | null; body: string }, maxChars = 520): string { const source = stripMarkdownLight(d.body); if (source.length <= maxChars) return source; const window = source.slice(0, maxChars + 100); const dotPos = Math.max( window.lastIndexOf('. '), window.lastIndexOf('? '), window.lastIndexOf('! '), ); return (dotPos > maxChars - 120 ? window.slice(0, dotPos + 1) : window.slice(0, maxChars)).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 = { voted: subjectLabel ? `weighed in on “${subjectLabel}”` : 'cast a pulse vote', rsvped: subjectLabel ? `is coming to ${subjectLabel}` : 'RSVP’d 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), }; }