diff --git a/src/components/ActivityTicker.astro b/src/components/ActivityTicker.astro
new file mode 100644
index 0000000..1e6edfd
--- /dev/null
+++ b/src/components/ActivityTicker.astro
@@ -0,0 +1,88 @@
+---
+import type { TickerItem } from '../lib/format';
+
+interface Props {
+ items: TickerItem[];
+}
+
+const { items } = Astro.props;
+// Duplicate the list so the keyframe translateX(-50%) lands at the start of
+// the second copy, giving a seamless wrap. If empty, render nothing — the
+// page hides the strip entirely rather than showing "no activity".
+const doubled = items.length > 0 ? [...items, ...items] : [];
+---
+{items.length > 0 && (
+
+
+ {doubled.map((it) => (
+
+
+ {it.name}
+ {it.phrase}
+ {it.relative}
+
+ ))}
+
+
+)}
+
+
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..3e2c4b3
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,87 @@
+import type { ActivityKind, ActivityRow, 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 = {
+ 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;
+}
+
+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),
+ };
+}