From 39c9c805cd413ebe9783a45125375a2fb1301b1a Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 14:50:10 +0200 Subject: [PATCH] feat(component): ActivityTicker + format helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityTicker — pure presentational. Renders a horizontal strip of items that scrolls right→left over 38s, with CSS mask fades on both edges and pause-on-hover. Items are duplicated so translateX(-50%) wraps seamlessly. Hidden entirely when empty (no "no activity" placeholder). format.ts — small set of formatters used by the ticker and the upcoming /pulse greeting: - relativeTime (2m / 3h / 5d / just now) - redactName ("Maya Rasmussen" → "Maya R.") - tenureSince (e.g. "2 years, 4 months") - pulseDateLabel ("MONDAY · 11 MAY", Europe/Copenhagen) - timeOfDay (morning/afternoon/evening, Europe/Copenhagen) - tickerItem (ActivityRow + subject label → display struct, with role-dot colours mapped to existing pigments: copper for pilot, terracotta for cab, indigo for fenja) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ActivityTicker.astro | 88 +++++++++++++++++++++++++++++ src/lib/format.ts | 87 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/components/ActivityTicker.astro create mode 100644 src/lib/format.ts 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), + }; +}