project-bifrost-platform/src/lib/format.ts
Jonathan Hvid 637055a73e feat(pulse): events box lighter + bundled coming-up, unboxed Fenja, horizontal roadmap, bigger council cards
Events card (--ink):
- 'Next up · Members only' and 'Invitation by hand' eyebrows removed.
- All ink-card text uses cream tones (rgba(232,224,208,...) at 92/75/70/65%)
  instead of the warm tan --ink-muted; the previous low-contrast labels
  read 'dark' on the indigo and now read uniformly light.
- Italic font removed everywhere on the card (hero day number, hero title,
  coming-up titles, etc.) — italic is reserved for the Bifrost wordmark
  and section-links only.
- Past gatherings dropped from /pulse entirely; the listing lives on
  /events and /events/past.
- 'Also coming up' is now a grid of small bundled sub-cards inside the
  blue surface (auto-fit minmax 220px). Each card shows date + title +
  meta only — no RSVP action, no per-row submit form.
- 'See all events →' section-link replaces the old past-gatherings
  'View all →' as the sole bottom-of-block link to /events.

Latest from Fenja (unboxed):
- Card surface dropped. Article sits on the cream page background.
- Excerpt extended via new dispatchLongPreview(d, 520) helper —
  sentence-boundary cut at ~520 chars (was ~200). Title in serif regular,
  not italic.
- 'Read the full dispatch →' section-link at the bottom.

Roadmap (horizontal):
- Three roadmap items become a 3-column grid of small white cards instead
  of a vertical list. Each card has status dot + title + status blurb
  with consistent min-height.
- 'See the full roadmap →' section-link at the bottom.

Council members (larger cards):
- Was a flowing pill row, now an auto-fit grid (minmax 260px) of larger
  white cards. Each card has a 56px avatar + name + title + company,
  with generous padding for whitespace. Company name is the new field.
- 'See who our council is made up of →' section-link at the bottom.

General (eyebrows + italics): all uppercase tracked eyebrow labels gone
from /pulse — date label, 'Latest from Fenja', 'From the roadmap', 'The
council', etc. Italic body text removed throughout — greeting, titles,
member names, dispatch title, roadmap titles. The Bifrost wordmark in the
header and the .section-link utility class are the only remaining italics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:47:44 +02:00

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