feat(pulse): full member-portal landing at /pulse

Sections (top to bottom, all wrapped in a cascade-entry animation):
- greeting block: MONDAY · 11 MAY label, serif-italic "Good morning, X."
  greeting (server time, Europe/Copenhagen), tenure line that uses
  cab_joined_date for cab members and falls back to created_at
- ActivityTicker fed by getRecentActivity (hidden when empty)
- this-week Pulse card: live breathing dot + time-left countdown, italic
  serif question, 2×2 grid of option buttons that submit a hidden-action
  vote form. Once voted, options lock and show a distribution bar fill;
  the user's choice keeps a terracotta border. Empty state when no pulse
  is open.
- 2-col roadmap preview + council mark: three most-recently-updated items
  with status dot (copper/ochre/muted), and the user's CouncilMark md with
  the "{n} of 12 quarters" stat
- members-in-the-room: 4 chips for other CAB members seen within 5 min,
  +N overflow chip; pulsing copper dot for the "online now" indicator
- two-column event row: dark indigo "members-only" card (next dinner or
  summit) + light "office hours" card (next office_hours event). Hidden
  per-card when no matching upcoming event exists.

Vote POST uses the existing admin form pattern (hidden action field,
redirect on success). Activity row for 'voted' is written inline here;
step 11 covers the other activity sources.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:52:51 +02:00
parent 39c9c805cd
commit 351d90a3e8

View file

@ -1,14 +1,312 @@
---
import AppLayout from '../layouts/AppLayout.astro';
import ActivityTicker from '../components/ActivityTicker.astro';
import CouncilMark from '../components/CouncilMark.astro';
import {
getOpenPulse, getPulseWithCounts, castVote, recordActivity,
getRecentActivity, getPulseById, getEventById, getRoadmapItem,
getAllRoadmapItems, getUpcomingEvents, getAllUsersPublic,
getLitQuarters, countShippedAttributions, getUserVote,
} from '../lib/db';
import { tickerItem, pulseDateLabel, timeOfDay, tenureSince, redactName } from '../lib/format';
const user = Astro.locals.user;
// ── POST: cast vote ────────────────────────────────────────────────
if (Astro.request.method === 'POST') {
const data = await Astro.request.formData();
const action = String(data.get('action') ?? '');
if (action === 'vote') {
const pulseId = Number(data.get('pulse_id'));
const optionIndex = Number(data.get('option_index'));
const target = getPulseById(pulseId);
if (target && target.status === 'open' && Number.isInteger(optionIndex)
&& optionIndex >= 0 && optionIndex < target.options.length) {
const existing = getUserVote(pulseId, user.id);
if (existing === null) {
castVote(pulseId, user.id, optionIndex);
recordActivity(user.id, 'voted', 'pulse', pulseId);
}
}
return Astro.redirect('/pulse');
}
}
// ── Greeting ───────────────────────────────────────────────────────
const firstName = user.name.split(' ')[0];
const greeting = `Good ${timeOfDay()}, ${firstName}.`;
const dateLabel = pulseDateLabel();
const tenureAnchor = user.role === 'cab' && user.cab_joined_date
? user.cab_joined_date
: user.created_at;
const tenure = tenureSince(tenureAnchor);
// ── Activity ticker ────────────────────────────────────────────────
const activity = getRecentActivity({ limit: 12, sinceDays: 7 });
const tickerItems = activity.map(row => {
let label: string | null = null;
switch (row.subject_type) {
case 'pulse': {
const p = getPulseById(row.subject_id);
label = p?.question ?? null;
break;
}
case 'event': {
const e = getEventById(row.subject_id);
label = e?.title ?? null;
break;
}
case 'roadmap': {
const r = getRoadmapItem(row.subject_id);
label = r?.title ?? null;
break;
}
}
return tickerItem(row, label);
});
// ── This week's Pulse ──────────────────────────────────────────────
const openPulseRaw = getOpenPulse();
const totalMembers = getAllUsersPublic().filter(u => u.role === 'cab').length;
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
function timeLeftLabel(closesAt: string): string {
const ms = new Date(closesAt).getTime() - Date.now();
if (ms <= 0) return 'closing now';
const d = Math.floor(ms / 86400000);
if (d >= 1) return `${d} day${d === 1 ? '' : 's'}`;
const h = Math.floor(ms / 3600000);
if (h >= 1) return `${h} hour${h === 1 ? '' : 's'}`;
const m = Math.floor(ms / 60000);
if (m >= 1) return `${m} minute${m === 1 ? '' : 's'}`;
const s = Math.floor(ms / 1000);
return `${s} seconds`;
}
function closeDayLabel(closesAt: string): string {
const d = new Date(closesAt);
return new Intl.DateTimeFormat('en-GB', {
weekday: 'long', timeZone: 'Europe/Copenhagen',
}).format(d);
}
// ── Roadmap preview (3 most-recently-updated items) ────────────────
const roadmapPreview = getAllRoadmapItems()
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
.slice(0, 3);
function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string {
return ({
shipping: 'var(--pigment-copper)',
beta: 'var(--pigment-ochre)',
exploring: 'var(--on-surface-muted)',
})[status];
}
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null; attributed: unknown[] }): string {
const target = item.target ? ` · ${item.target}` : '';
switch (item.status) {
case 'shipping': return `Shipping${target}`;
case 'beta': return `In beta${target}`;
case 'exploring': return `Exploring${target}`;
}
}
// ── Council mark + stat ────────────────────────────────────────────
const litCount = getLitQuarters(user.id).size;
const shippedCount = countShippedAttributions(user.id);
// ── Members in the room ────────────────────────────────────────────
const allMembers = getAllUsersPublic();
const onlineOthers = allMembers.filter(u =>
u.id !== user.id
&& u.last_seen_at
&& (Date.now() - new Date(u.last_seen_at).getTime()) < 5 * 60_000
);
const visibleChips = onlineOthers.slice(0, 4);
const overflowCount = Math.max(0, onlineOthers.length - visibleChips.length);
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return (parts[0][0] ?? '').toUpperCase();
return ((parts[0][0] ?? '') + (parts[parts.length - 1][0] ?? '')).toUpperCase();
}
// ── Events row ─────────────────────────────────────────────────────
const upcoming = getUpcomingEvents(20);
const nextExclusive = upcoming.find(e => e.kind === 'dinner' || e.kind === 'summit') ?? null;
const nextOfficeHours = upcoming.find(e => e.kind === 'office_hours') ?? null;
function formatEventDate(iso: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
}).format(new Date(iso)).toUpperCase();
}
---
<AppLayout title="Pulse" user={user}>
<div class="page">
<p class="label-sm eyebrow">Pulse</p>
<h1 class="display-md">Welcome back, <em>{firstName}</em>.</h1>
<p class="lead">The full member view lands in the next commit.</p>
<!-- ── Greeting ─────────────────────────────────────────────── -->
<section class="cascade greeting">
<p class="label-sm date-label">{dateLabel}</p>
<h1 class="greeting-line">
<span class="greeting-italic">{greeting}</span>
</h1>
<p class="greeting-sub body-md">
You've been a council member for <em>{tenure}</em>. The team is reading every note you leave.
</p>
</section>
<!-- ── Ticker ───────────────────────────────────────────────── -->
{tickerItems.length > 0 && (
<section class="cascade ticker-wrap">
<ActivityTicker items={tickerItems} />
</section>
)}
<!-- ── This week's Pulse ────────────────────────────────────── -->
<section class="cascade pulse-card">
{openPulse ? (
<>
<div class="pulse-meta">
<span class="live-dot" aria-hidden="true"></span>
<span class="label-sm pulse-label">This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)}</span>
</div>
<p class="pulse-question">{openPulse.question}</p>
{openPulse.context && <p class="pulse-context body-md">{openPulse.context}</p>}
<form method="POST" class="pulse-options" novalidate>
<input type="hidden" name="action" value="vote" />
<input type="hidden" name="pulse_id" value={openPulse.id} />
{openPulse.options.map((opt, i) => {
const chosen = openPulse.my_vote === i;
const count = openPulse.votes_by_option[i] ?? 0;
const pct = openPulse.votes_total > 0 ? (count / openPulse.votes_total) * 100 : 0;
const locked = openPulse.my_vote !== null;
const letter = String.fromCharCode(65 + i); // A/B/C/D
return (
<button
type="submit"
name="option_index"
value={i}
class:list={['pulse-option', { chosen, locked }]}
disabled={locked && !chosen}
aria-pressed={chosen}
>
<span class="pulse-option-letter label-sm">{letter}</span>
<span class="pulse-option-text">{opt}</span>
{locked && (
<span class="pulse-option-bar" aria-hidden="true">
<span class="pulse-option-bar-fill" style={`width:${pct.toFixed(1)}%`}></span>
</span>
)}
</button>
);
})}
</form>
<p class="pulse-count body-sm">
<strong>{openPulse.votes_total}</strong> of {totalMembers} council member{totalMembers === 1 ? '' : 's'} weighed in. Closes {closeDayLabel(openPulse.closes_at)}.
</p>
</>
) : (
<div class="pulse-empty">
<span class="label-sm pulse-label-muted">This week's pulse</span>
<p class="pulse-empty-line">No pulse is open right now. The next one drops soon.</p>
</div>
)}
</section>
<!-- ── Roadmap preview + Council mark ───────────────────────── -->
<section class="cascade preview-row">
<div class="roadmap-preview">
<p class="label-sm section-eyebrow">From the roadmap</p>
{roadmapPreview.length === 0 ? (
<p class="body-sm muted">No roadmap items yet.</p>
) : (
<ul class="roadmap-list">
{roadmapPreview.map(item => (
<li class="roadmap-row">
<span
class:list={['status-dot', { breathing: item.status === 'shipping' }]}
style={`background:${roadmapStatusDot(item.status)}`}
aria-hidden="true"
></span>
<div class="roadmap-row-text">
<p class="roadmap-row-title">{item.title}</p>
<p class="roadmap-row-blurb label-sm">{roadmapStatusBlurb(item)}</p>
</div>
</li>
))}
</ul>
)}
<a href="/roadmap" class="see-all label-sm">See the full roadmap →</a>
</div>
<aside class="mark-card">
<p class="label-sm section-eyebrow">Your council mark</p>
<div class="mark-svg-wrap">
<CouncilMark member={user} size="md" />
</div>
<p class="mark-stat label-sm">
{litCount} of 12 quarters with a contribution that shipped
{shippedCount > 0 && <span class="mark-stat-sub"> ({shippedCount} item{shippedCount === 1 ? '' : 's'})</span>}
</p>
</aside>
</section>
<!-- ── Members in the room ──────────────────────────────────── -->
{visibleChips.length > 0 && (
<section class="cascade room-row">
<div class="room-label">
<span class="online-dot" aria-hidden="true"></span>
<span class="label-sm">{onlineOthers.length} other{onlineOthers.length === 1 ? '' : 's'} online now</span>
</div>
<div class="chip-row">
{visibleChips.map(m => (
<a href={m.slug ? `/members/${m.slug}` : '#'} class="member-chip">
<span class="chip-initials" aria-hidden="true">{initials(m.name)}</span>
<span class="chip-text">
<span class="chip-name">{redactName(m.name)}</span>
<span class="chip-org label-sm">{m.organisation}</span>
</span>
</a>
))}
{overflowCount > 0 && (
<span class="overflow-chip label-sm">+{overflowCount} others</span>
)}
</div>
</section>
)}
<!-- ── Event row ────────────────────────────────────────────── -->
{(nextExclusive || nextOfficeHours) && (
<section class="cascade event-row">
{nextExclusive && (
<article class="event-card event-card--dark">
<p class="label-sm event-eyebrow event-eyebrow--light">
Members only · {formatEventDate(nextExclusive.starts_at)}
</p>
<h3 class="event-title">{nextExclusive.title}</h3>
<p class="event-desc">{nextExclusive.description}</p>
{nextExclusive.capacity && (
<p class="event-scarcity label-sm">{nextExclusive.capacity} seats · invitation by hand</p>
)}
</article>
)}
{nextOfficeHours && (
<article class="event-card event-card--light">
<p class="label-sm event-eyebrow">
Office hours · {formatEventDate(nextOfficeHours.starts_at)}
</p>
<h3 class="event-title">{nextOfficeHours.title}</h3>
<p class="event-desc">{nextOfficeHours.description}</p>
</article>
)}
</section>
)}
</div>
</AppLayout>
@ -17,12 +315,414 @@ const firstName = user.name.split(' ')[0];
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-10);
}
.eyebrow {
/* ── Cascade entry (first paint only) ─────────────────────────── */
.cascade {
opacity: 0;
transform: translateY(10px);
animation: cascade-in 650ms var(--ease-standard) forwards;
}
.cascade:nth-child(1) { animation-delay: 0ms; }
.cascade:nth-child(2) { animation-delay: 100ms; }
.cascade:nth-child(3) { animation-delay: 200ms; }
.cascade:nth-child(4) { animation-delay: 300ms; }
.cascade:nth-child(5) { animation-delay: 400ms; }
.cascade:nth-child(6) { animation-delay: 500ms; }
@keyframes cascade-in {
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.cascade { opacity: 1; transform: none; animation: none; }
}
/* ── Greeting ─────────────────────────────────────────────────── */
.greeting { display: flex; flex-direction: column; gap: var(--space-3); }
.date-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.lead { color: var(--on-surface-variant); max-width: var(--reading-max); }
.greeting-line {
font-family: var(--font-serif);
font-weight: 400;
font-size: var(--text-display-md);
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
color: var(--on-surface);
margin: 0;
}
.greeting-italic { font-style: italic; }
.greeting-sub {
color: var(--on-surface-variant);
max-width: 48rem;
margin: 0;
}
.greeting-sub em { font-style: italic; color: var(--on-surface); }
/* ── Pulse card ───────────────────────────────────────────────── */
.pulse-card {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
padding: var(--space-7) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.pulse-meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.live-dot {
width: 8px;
height: 8px;
background: var(--pigment-terracotta);
border-radius: 50%;
animation: breathe 2.4s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.5; }
}
.pulse-label {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
font-weight: 500;
}
.pulse-label-muted {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.pulse-question {
font-family: var(--font-serif);
font-style: italic;
font-size: 1.375rem;
line-height: var(--leading-snug);
color: var(--on-surface);
margin: 0;
max-width: 50rem;
}
.pulse-context {
color: var(--on-surface-variant);
margin: 0;
max-width: 50rem;
}
.pulse-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
margin-top: var(--space-2);
}
.pulse-option {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
text-align: left;
cursor: pointer;
transition: transform 300ms var(--ease-standard),
border-color 300ms var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
overflow: hidden;
}
.pulse-option:hover:not(.locked) {
transform: translateY(-2px);
border-color: var(--outline);
}
.pulse-option.chosen {
border-color: var(--pigment-terracotta);
background: var(--surface-container-low);
}
.pulse-option.locked:not(.chosen) {
cursor: default;
color: var(--on-surface-variant);
}
.pulse-option:disabled { opacity: 0.8; }
.pulse-option-letter {
font-weight: 600;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.pulse-option.chosen .pulse-option-letter { color: var(--pigment-terracotta); }
.pulse-option-text { flex: 1; }
.pulse-option-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
height: 2px;
background: var(--surface-container);
}
.pulse-option-bar-fill {
display: block;
height: 100%;
background: var(--pigment-terracotta);
opacity: 0.6;
transition: width 600ms var(--ease-standard);
}
.pulse-count {
color: var(--on-surface-variant);
margin: 0;
}
.pulse-count strong { color: var(--on-surface); font-weight: 600; }
.pulse-empty {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.pulse-empty-line {
font-family: var(--font-serif);
font-style: italic;
font-size: 1.25rem;
color: var(--on-surface-variant);
margin: 0;
}
/* ── Roadmap preview + Council mark ──────────────────────────── */
.preview-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-8);
}
.section-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-4);
}
.roadmap-preview { display: flex; flex-direction: column; gap: var(--space-4); }
.roadmap-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.roadmap-row {
display: flex;
align-items: flex-start;
gap: var(--space-4);
padding: var(--space-4) 0;
border-top: var(--ghost-border);
}
.roadmap-row:last-child { border-bottom: var(--ghost-border); }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.4em;
}
.status-dot.breathing { animation: breathe 2.4s ease-in-out infinite; }
.roadmap-row-text { flex: 1; display: flex; flex-direction: column; gap: var(--space-1); }
.roadmap-row-title { margin: 0; font-weight: 500; color: var(--on-surface); }
.roadmap-row-blurb {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin: 0;
}
.see-all {
color: var(--on-surface-variant);
text-decoration: none;
border-bottom: none;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin-top: var(--space-3);
align-self: flex-start;
transition: color var(--duration-fast) var(--ease-standard);
}
.see-all:hover { color: var(--on-surface); border-bottom: none; }
.mark-card {
background: var(--surface-container-low);
border-radius: var(--radius-md);
padding: var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.mark-svg-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.mark-stat {
color: var(--on-surface-variant);
letter-spacing: var(--tracking-wide);
text-align: center;
margin: 0;
max-width: 18rem;
line-height: var(--leading-normal);
text-transform: none;
}
.mark-stat-sub { color: var(--on-surface-muted); }
/* ── Members in the room ─────────────────────────────────────── */
.room-row { display: flex; flex-direction: column; gap: var(--space-4); }
.room-label { display: flex; align-items: center; gap: var(--space-3); }
.online-dot {
width: 8px;
height: 8px;
background: var(--pigment-copper);
border-radius: 50%;
animation: breathe 2.4s ease-in-out infinite;
}
.room-label .label-sm {
color: var(--on-surface-variant);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.member-chip {
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4) var(--space-2) var(--space-2);
background: var(--surface-container-lowest);
border-radius: var(--radius-full);
color: var(--on-surface);
text-decoration: none;
border-bottom: none;
transition: transform 300ms var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.member-chip:hover {
transform: translateY(-2px);
background: var(--surface-container-low);
border-bottom: none;
}
.chip-initials {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--surface-container);
color: var(--secondary);
font-family: var(--font-serif);
font-style: italic;
font-size: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chip-text { display: inline-flex; flex-direction: column; line-height: 1.2; }
.chip-name { font-size: var(--text-body-sm); }
.chip-org {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.overflow-chip {
align-self: center;
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: var(--space-2) var(--space-3);
}
/* ── Events row ──────────────────────────────────────────────── */
.event-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
.event-card {
padding: var(--space-8);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: var(--space-3);
transition: transform 300ms var(--ease-standard);
}
.event-card:hover { transform: translateY(-2px); }
.event-card--dark {
background: var(--pigment-indigo);
color: var(--on-primary);
}
.event-card--dark .event-title,
.event-card--dark .event-desc,
.event-card--dark .event-scarcity {
color: var(--on-primary);
}
.event-card--light {
background: var(--surface-container-low);
}
.event-eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
.event-eyebrow--light { color: rgba(255, 252, 247, 0.7); }
.event-title {
font-family: var(--font-serif);
font-size: 1.5rem;
line-height: var(--leading-snug);
margin: 0;
}
.event-desc { margin: 0; }
.event-scarcity {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
margin: 0;
}
/* ── Responsive: collapse 2-col rows on narrow widths ────────── */
@media (max-width: 880px) {
.preview-row, .event-row, .pulse-options { grid-template-columns: 1fr; }
}
</style>