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>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
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(/[ |