feat(pulse): nav restructure, white surfaces, membership card, dispatches
Nav (AppLayout.astro): brand stays left; everything else is a single
right-aligned flex group — Pulse · Roadmap · Members · Events · [Admin
if fenja] · 0.5px vertical divider · name · Sign out. The .nav-user
wrapper is gone; the name and logout button now belong to the same flex
flow as the link list.
/pulse:
- ActivityTicker render removed. The component file gets a one-line
deprecation comment; the activity table and write hooks stay in place
for later use.
- 'X others online now' chip strip removed — including all its supporting
helpers and styles in the page.
- CouncilMark replaced with <MembershipCard> in the right column of the
preview row. The roadmap preview is now a white --surface-card with
0.5px border; pulse-card switches to the same white surface and
--radius-lg. The .chosen pulse option uses --pigment-terracotta border
and a 6% terracotta tint via color-mix.
- <DispatchesSection limit={4} /> and <RecentlyFromTheCouncil /> stacked
below the preview row, in the position the online-now strip vacated.
- Vote-count denominator pulls from countCabMembers() and renders via
voteCountSentence(votes, total) — a new helper covering 0/1/5+ cases.
- Event row: dark dinner card now uses --ink/--ink-text; light card uses
--surface-card with 0.5px border.
Tests: 3 new cases for voteCountSentence (0/1/5). 36/36 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e9fadcf79
commit
243a456880
5 changed files with 83 additions and 221 deletions
|
|
@ -1,4 +1,7 @@
|
|||
---
|
||||
// Deprecated for Phase 2: no longer rendered on /pulse. The activity table
|
||||
// and its write hooks remain — kept around in case we resurface this later
|
||||
// or build an admin debug view that streams from it.
|
||||
import type { TickerItem } from '../lib/format';
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const year = new Date().getFullYear();
|
|||
<img src="/logo.svg" alt="Fenja AI" class="wordmark" />
|
||||
</a>
|
||||
|
||||
<nav class="nav-links" aria-label="Main navigation">
|
||||
<nav class="nav-right" aria-label="Main navigation">
|
||||
{navLinks.map(({ href, label }) => (
|
||||
<a
|
||||
href={href}
|
||||
|
|
@ -49,14 +49,12 @@ const year = new Date().getFullYear();
|
|||
Admin
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div class="nav-user">
|
||||
<span class="nav-divider" aria-hidden="true"></span>
|
||||
<a href="/account" class="nav-user-name body-sm">{user.name}</a>
|
||||
<form method="POST" action="/api/logout">
|
||||
<form method="POST" action="/api/logout" class="nav-logout-form">
|
||||
<button type="submit" class="logout-btn label-sm">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -126,12 +124,22 @@ const year = new Date().getFullYear();
|
|||
}
|
||||
|
||||
/* ── Nav links ──────────────────────────────────────────────────── */
|
||||
.nav-links {
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--ghost-border-color);
|
||||
margin: 0 var(--space-2);
|
||||
transform: scaleX(0.5);
|
||||
transform-origin: center;
|
||||
}
|
||||
.nav-logout-form { display: inline-flex; }
|
||||
|
||||
.nav-link {
|
||||
font-family: var(--font-sans);
|
||||
|
|
@ -158,13 +166,6 @@ const year = new Date().getFullYear();
|
|||
}
|
||||
|
||||
/* ── User zone ──────────────────────────────────────────────────── */
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-user-name {
|
||||
color: var(--on-surface-variant);
|
||||
text-decoration: none;
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
import AppLayout from '../layouts/AppLayout.astro';
|
||||
import ActivityTicker from '../components/ActivityTicker.astro';
|
||||
import CouncilMark from '../components/CouncilMark.astro';
|
||||
import MembershipCard from '../components/MembershipCard.astro';
|
||||
import DispatchesSection from '../components/DispatchesSection.astro';
|
||||
import RecentlyFromTheCouncil from '../components/RecentlyFromTheCouncil.astro';
|
||||
import {
|
||||
getOpenPulse, getPulseWithCounts, castVote, recordActivity,
|
||||
getRecentActivity, getPulseById, getEventById, getRoadmapItem,
|
||||
getAllRoadmapItems, getUpcomingEvents, getAllUsersPublic,
|
||||
getLitQuarters, countShippedAttributions, getUserVote,
|
||||
getPulseById, getAllRoadmapItems, getUpcomingEvents,
|
||||
countCabMembers, getUserVote,
|
||||
} from '../lib/db';
|
||||
import { tickerItem, pulseDateLabel, timeOfDay, tenureSince, redactName } from '../lib/format';
|
||||
import { pulseDateLabel, timeOfDay, tenureSince, voteCountSentence } from '../lib/format';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
|
||||
|
|
@ -42,33 +42,9 @@ const tenureAnchor = user.role === 'cab' && 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 totalMembers = countCabMembers();
|
||||
const openPulse = openPulseRaw ? getPulseWithCounts(openPulseRaw.id, user.id) : null;
|
||||
|
||||
// Time-left label: "32 seconds" / "3 hours" / "2 days" — soft countdown
|
||||
|
|
@ -113,31 +89,6 @@ function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; t
|
|||
}
|
||||
}
|
||||
|
||||
// ── Council mark + stat ────────────────────────────────────────────
|
||||
const litCount = getLitQuarters(user.id).size;
|
||||
const shippedCount = countShippedAttributions(user.id);
|
||||
|
||||
// ── Members in the room ────────────────────────────────────────────
|
||||
const allMembers = getAllUsersPublic();
|
||||
// SQL stores 'YYYY-MM-DD HH:MM:SS' UTC; new Date() would parse as local — coerce to UTC ISO first.
|
||||
function sqlToUtcDate(s: string): Date {
|
||||
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
|
||||
return new Date(s.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
const onlineOthers = allMembers.filter(u =>
|
||||
u.id !== user.id
|
||||
&& u.last_seen_at
|
||||
&& (Date.now() - sqlToUtcDate(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;
|
||||
|
|
@ -163,13 +114,6 @@ function formatEventDate(iso: string): string {
|
|||
</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 ? (
|
||||
|
|
@ -212,7 +156,7 @@ function formatEventDate(iso: string): string {
|
|||
</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)}.
|
||||
{voteCountSentence(openPulse.votes_total, totalMembers)} Closes {closeDayLabel(openPulse.closes_at)}.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -249,41 +193,20 @@ function formatEventDate(iso: string): string {
|
|||
<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 class="membership-slot">
|
||||
<MembershipCard member={user} />
|
||||
</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>
|
||||
<!-- ── Dispatches ───────────────────────────────────────────── -->
|
||||
<section class="cascade">
|
||||
<DispatchesSection limit={4} />
|
||||
</section>
|
||||
|
||||
<!-- ── Recently from the council ────────────────────────────── -->
|
||||
<section class="cascade">
|
||||
<RecentlyFromTheCouncil />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<!-- ── Event row ────────────────────────────────────────────── -->
|
||||
{(nextExclusive || nextOfficeHours) && (
|
||||
|
|
@ -373,8 +296,9 @@ function formatEventDate(iso: string): string {
|
|||
|
||||
/* ── Pulse card ───────────────────────────────────────────────── */
|
||||
.pulse-card {
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-7) var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -461,7 +385,7 @@ function formatEventDate(iso: string): string {
|
|||
}
|
||||
.pulse-option.chosen {
|
||||
border-color: var(--pigment-terracotta);
|
||||
background: var(--surface-container-low);
|
||||
background: color-mix(in oklab, var(--pigment-terracotta) 6%, var(--surface-card));
|
||||
}
|
||||
.pulse-option.locked:not(.chosen) {
|
||||
cursor: default;
|
||||
|
|
@ -511,21 +435,33 @@ function formatEventDate(iso: string): string {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Roadmap preview + Council mark ──────────────────────────── */
|
||||
/* ── Roadmap preview + Membership card ──────────────────────── */
|
||||
.preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-8);
|
||||
gap: var(--space-6);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.roadmap-preview { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.roadmap-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.membership-slot { display: flex; }
|
||||
.membership-slot > * { flex: 1; }
|
||||
|
||||
.roadmap-list {
|
||||
list-style: none;
|
||||
|
|
@ -575,104 +511,6 @@ function formatEventDate(iso: string): string {
|
|||
}
|
||||
.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;
|
||||
|
|
@ -691,17 +529,18 @@ function formatEventDate(iso: string): string {
|
|||
.event-card:hover { transform: translateY(-2px); }
|
||||
|
||||
.event-card--dark {
|
||||
background: var(--pigment-indigo);
|
||||
color: var(--on-primary);
|
||||
background: var(--ink);
|
||||
color: var(--ink-text);
|
||||
}
|
||||
.event-card--dark .event-title,
|
||||
.event-card--dark .event-desc,
|
||||
.event-card--dark .event-scarcity {
|
||||
color: var(--on-primary);
|
||||
color: var(--ink-text);
|
||||
}
|
||||
|
||||
.event-card--light {
|
||||
background: var(--surface-container-low);
|
||||
background: var(--surface-card);
|
||||
border: 0.5px solid var(--surface-card-border);
|
||||
}
|
||||
|
||||
.event-eyebrow {
|
||||
|
|
@ -709,7 +548,7 @@ function formatEventDate(iso: string): string {
|
|||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
.event-eyebrow--light { color: rgba(255, 252, 247, 0.7); }
|
||||
.event-eyebrow--light { color: var(--ink-muted); }
|
||||
|
||||
.event-title {
|
||||
font-family: var(--font-serif);
|
||||
|
|
|
|||
19
tests/vote-count.test.ts
Normal file
19
tests/vote-count.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { voteCountSentence } from '../src/lib/format.js';
|
||||
|
||||
describe('voteCountSentence — denominator pluralisation', () => {
|
||||
it('reads correctly for a council of 0 (defensive — should never ship)', () => {
|
||||
expect(voteCountSentence(0, 0)).toBe('0 of 0 council members have weighed in.');
|
||||
});
|
||||
|
||||
it('uses singular "member has" when the council has exactly 1 member', () => {
|
||||
expect(voteCountSentence(0, 1)).toBe('0 of 1 council member has weighed in.');
|
||||
expect(voteCountSentence(1, 1)).toBe('1 of 1 council member has weighed in.');
|
||||
});
|
||||
|
||||
it('uses plural "members have" for a council of 5', () => {
|
||||
expect(voteCountSentence(0, 5)).toBe('0 of 5 council members have weighed in.');
|
||||
expect(voteCountSentence(2, 5)).toBe('2 of 5 council members have weighed in.');
|
||||
expect(voteCountSentence(5, 5)).toBe('5 of 5 council members have weighed in.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue