diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index 1b976c2..ebb562e 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -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(); +} ---
-

Pulse

-

Welcome back, {firstName}.

-

The full member view lands in the next commit.

+ + +
+

{dateLabel}

+

+ {greeting} +

+

+ You've been a council member for {tenure}. The team is reading every note you leave. +

+
+ + + {tickerItems.length > 0 && ( +
+ +
+ )} + + +
+ {openPulse ? ( + <> +
+ + This week's pulse · closes in {timeLeftLabel(openPulse.closes_at)} +
+

{openPulse.question}

+ {openPulse.context &&

{openPulse.context}

} + +
+ + + {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 ( + + ); + })} +
+ +

+ {openPulse.votes_total} of {totalMembers} council member{totalMembers === 1 ? '' : 's'} weighed in. Closes {closeDayLabel(openPulse.closes_at)}. +

+ + ) : ( +
+ This week's pulse +

No pulse is open right now. The next one drops soon.

+
+ )} +
+ + +
+
+

From the roadmap

+ {roadmapPreview.length === 0 ? ( +

No roadmap items yet.

+ ) : ( +
    + {roadmapPreview.map(item => ( +
  • + +
    +

    {item.title}

    +

    {roadmapStatusBlurb(item)}

    +
    +
  • + ))} +
+ )} + See the full roadmap → +
+ + +
+ + + {visibleChips.length > 0 && ( +
+
+ + {onlineOthers.length} other{onlineOthers.length === 1 ? '' : 's'} online now +
+
+ {visibleChips.map(m => ( + + + + {redactName(m.name)} + {m.organisation} + + + ))} + {overflowCount > 0 && ( + +{overflowCount} others + )} +
+
+ )} + + + {(nextExclusive || nextOfficeHours) && ( +
+ {nextExclusive && ( +
+

+ Members only · {formatEventDate(nextExclusive.starts_at)} +

+

{nextExclusive.title}

+

{nextExclusive.description}

+ {nextExclusive.capacity && ( +

{nextExclusive.capacity} seats · invitation by hand

+ )} +
+ )} + {nextOfficeHours && ( +
+

+ Office hours · {formatEventDate(nextOfficeHours.starts_at)} +

+

{nextOfficeHours.title}

+

{nextOfficeHours.description}

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