feat(component): ActivityTicker + format helpers

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:50:10 +02:00
parent 6f23c47e7a
commit 39c9c805cd
2 changed files with 175 additions and 0 deletions

View file

@ -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 && (
<div class="ticker" aria-label="Recent council activity">
<div class="ticker-track">
{doubled.map((it) => (
<span class="ticker-item">
<span class="ticker-dot" style={`background:${it.dotColor}`} aria-hidden="true"></span>
<span class="ticker-name">{it.name}</span>
<span class="ticker-phrase">{it.phrase}</span>
<span class="ticker-time label-sm">{it.relative}</span>
</span>
))}
</div>
</div>
)}
<style>
.ticker {
position: relative;
overflow: hidden;
padding: var(--space-3) 0;
-webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
}
.ticker-track {
display: inline-flex;
gap: var(--space-6);
white-space: nowrap;
animation: ticker-scroll 38s linear infinite;
will-change: transform;
}
.ticker:hover .ticker-track {
animation-play-state: paused;
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.ticker-item {
display: inline-flex;
align-items: baseline;
gap: var(--space-2);
font-size: var(--text-body-sm);
color: var(--on-surface-variant);
}
.ticker-dot {
align-self: center;
width: 6px;
height: 6px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.ticker-name {
font-weight: 600;
color: var(--on-surface);
}
.ticker-phrase {
color: var(--on-surface-variant);
}
.ticker-time {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
}
@media (prefers-reduced-motion: reduce) {
.ticker-track { animation: none; }
}
</style>

87
src/lib/format.ts Normal file
View file

@ -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<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;
}
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),
};
}