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>
207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
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(/[ |