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:
parent
6f23c47e7a
commit
39c9c805cd
2 changed files with 175 additions and 0 deletions
88
src/components/ActivityTicker.astro
Normal file
88
src/components/ActivityTicker.astro
Normal 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
87
src/lib/format.ts
Normal 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}` : '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),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue