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