project-bifrost-platform/src/lib/format.ts
Jonathan Hvid 368ce3ac8c feat(db): dispatches + member-number allocation + focus-tags parser
db.ts:
  - User type gains pull_quote, member_number, focus_tags; all SELECT lists
    updated. getAllCabMembers (member_number asc) and countCabMembers (used
    by /pulse denominator) added.
  - createUser allocates a member_number in-transaction when role=cab.
  - updateUserRole returns { allocated: number | null } so admin can surface
    the assignment; allocation is one-way: pilot→cab→pilot→cab keeps the
    original number.
  - allocateMemberNumber: MAX(member_number)+1, idempotent, never reuses.
  - updateUserAdminFields: title / pull_quote / focus_tags (parsed array).
  - createEvent / updateEvent extended for audience, duration_label,
    action_label, notes_url.
  - Dispatch CRUD: create / update / publish (stamps published_at) /
    archive / delete. getDispatchById, getLatestPublishedDispatches,
    getAllDispatchesForAdmin, getAdjacentDispatches (prev/next in published
    order).
  - getEventAttendees(slug, status) backs the upcoming-event avatar pile.

format.ts:
  - AVATAR_PIGMENTS (terracotta/copper/walnut/indigo/heather) + pigmentForId
    (id % palette, deterministic).
  - parseFocusTags: trim, strip ASCII control chars (\x00-\x1F\x7F),
    collapse internal whitespace, dedupe, cap 3 × 24.
  - readFocusTags (safe JSON.parse for display).
  - dispatchSlug / parseDispatchSlug: {id}-{kebab(title)}; renames don't
    break links because the id leads.
  - dispatchKindLabel, stripMarkdownLight, dispatchExcerptParas (two-paragraph
    excerpt with sentence-boundary cut).

Tests: member-number allocation (idempotent, never reuses, allocates on
role transition) and focus_tags parser (control chars, whitespace collapse,
dedupe, cap). 24/24 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:55:35 +02:00

207 lines
8.1 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];
}
/** 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),
};
}