feat(pulse): two-column greeting + tenure-milestone copy
Replaces the single-column greeting block with a 1fr / 1fr row. The left
side holds the date label (MONDAY, 11 MAY in tracked uppercase 11px,
swapped from the bullet separator to comma per spec), the serif 36px
greeting line ('Good morning, Jonathan.') with the first name italic,
and a milestone-aware tenure line. The right side (only rendered when
the user has a member_number — i.e. cab members) shows 'MEMBER · NNN'
zero-padded above 'Founding circle' in serif.
tenureMilestone(days) is the new helper that produces the milestone copy
through seven buckets: 0 / 1–6 / 7–20 / 21–55 / 56–180 / 181–364 / 365+.
The 1–6 bucket renders 'Day 2.', 'Day 3.' etc. (day count is days-since-
join; day one is the first 24 hours after joining). The months bucket
uses Math.floor(days/30) with a min of 2 to avoid a one-day-after-21
reading '1 months in.'. Years pluralise normally.
daysSince(iso) — small wrapper that handles SQL date strings ('YYYY-MM-DD
HH:MM:SS' or pure dates) and returns whole-day UTC delta. Same UTC
coercion the rest of the page uses.
Tests: 7 cases for tenureMilestone at the boundary days 0/1/7/22/60/200/
400 (plus an extra 730 to exercise the year plural). 43/43 passing.
This block replaces the in-line <MembershipCard> idea that was floating
around in earlier passes; the dark indigo card stays in src/components
for /members/:slug, but on /pulse the two-line stamp is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4df2b4982
commit
096cdb00b6
3 changed files with 105 additions and 13 deletions
Binary file not shown.
|
|
@ -9,7 +9,7 @@ import {
|
|||
getAllCabMembers, getPulseById, getUserVote, castVote,
|
||||
} from '../lib/db';
|
||||
import {
|
||||
timeOfDay, tenureSince, relativeTime,
|
||||
timeOfDay, pulseDateLabel, daysSince, tenureMilestone, relativeTime,
|
||||
eventKindLabel,
|
||||
dispatchSlug, dispatchKindLabel, dispatchKindPigment, dispatchLongPreview,
|
||||
} from '../lib/format';
|
||||
|
|
@ -52,12 +52,17 @@ if (Astro.request.method === 'POST') {
|
|||
|
||||
// ── Greeting ───────────────────────────────────────────────────────
|
||||
const firstName = user.name.split(' ')[0];
|
||||
const greeting = `Good ${timeOfDay()}, ${firstName}.`;
|
||||
const greetingPrefix = `Good ${timeOfDay()}, `; // "Good morning, "
|
||||
const dateLabel = pulseDateLabel(); // "MONDAY, 11 MAY"
|
||||
|
||||
const tenureAnchor = user.role === 'cab' && user.cab_joined_date
|
||||
? user.cab_joined_date
|
||||
: user.created_at;
|
||||
const tenure = tenureSince(tenureAnchor);
|
||||
const tenureCopy = tenureMilestone(daysSince(tenureAnchor));
|
||||
|
||||
const memberNumberLabel = user.member_number != null
|
||||
? `MEMBER · ${String(user.member_number).padStart(3, '0')}`
|
||||
: null;
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────
|
||||
const upcoming = getUpcomingEvents(20);
|
||||
|
|
@ -124,10 +129,17 @@ const members = getAllCabMembers();
|
|||
|
||||
<!-- ── Greeting ─────────────────────────────────────────────── -->
|
||||
<section class="cascade greeting">
|
||||
<h1 class="greeting-line">{greeting}</h1>
|
||||
<p class="greeting-sub body-md">
|
||||
You've been a member for {tenure}. The team is reading every note you leave.
|
||||
</p>
|
||||
<div class="greeting-left">
|
||||
<p class="greeting-date">{dateLabel}</p>
|
||||
<h1 class="greeting-line">{greetingPrefix}<em class="greeting-first">{firstName}</em>.</h1>
|
||||
<p class="greeting-tenure">{tenureCopy}</p>
|
||||
</div>
|
||||
{memberNumberLabel && (
|
||||
<div class="greeting-right">
|
||||
<p class="greeting-member">{memberNumberLabel}</p>
|
||||
<p class="greeting-circle">Founding circle</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<!-- ── Events (--ink card) ──────────────────────────────────── -->
|
||||
|
|
@ -356,21 +368,68 @@ const members = getAllCabMembers();
|
|||
.cascade { opacity: 1; transform: none; animation: none; }
|
||||
}
|
||||
|
||||
/* ── Greeting ─────────────────────────────────────────────────── */
|
||||
.greeting { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
/* ── Greeting (two-column) ────────────────────────────────────── */
|
||||
.greeting {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6);
|
||||
align-items: end;
|
||||
}
|
||||
.greeting-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 30rem;
|
||||
}
|
||||
.greeting-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.greeting-date {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-line {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
font-size: var(--text-display-md);
|
||||
font-size: 36px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-sub {
|
||||
.greeting-first { font-style: italic; }
|
||||
.greeting-tenure {
|
||||
font-size: 13px;
|
||||
color: var(--on-surface-variant);
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
max-width: 380px;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
.greeting-member {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.greeting-circle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.greeting { grid-template-columns: 1fr; align-items: start; }
|
||||
.greeting-right { align-items: flex-start; }
|
||||
}
|
||||
|
||||
/* ── Events card (--ink) — more air, prominent hero ───────────── */
|
||||
|
|
|
|||
33
tests/tenure-milestone.test.ts
Normal file
33
tests/tenure-milestone.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { tenureMilestone } from '../src/lib/format.js';
|
||||
|
||||
describe('tenureMilestone — copy variants by day count', () => {
|
||||
it('0 days reads "Day one."', () => {
|
||||
expect(tenureMilestone(0)).toBe('Day one. The team is reading every note you leave.');
|
||||
});
|
||||
|
||||
it('1 day reads "Day 2." (off-by-one — day 1 is the first 24h after joining)', () => {
|
||||
expect(tenureMilestone(1)).toBe('Day 2. The team is reading every note you leave.');
|
||||
});
|
||||
|
||||
it('7 days enters the "{n} days in" bucket', () => {
|
||||
expect(tenureMilestone(7)).toBe('7 days in. The team is reading every note you leave.');
|
||||
});
|
||||
|
||||
it('22 days reads "A few weeks in."', () => {
|
||||
expect(tenureMilestone(22)).toBe('A few weeks in. The team is reading every note you leave.');
|
||||
});
|
||||
|
||||
it('60 days reads "{n_months} months in." (months = floor(days/30))', () => {
|
||||
expect(tenureMilestone(60)).toBe('2 months in. The team is reading every note you leave.');
|
||||
});
|
||||
|
||||
it('200 days reads "Almost a year in." (switches to "Still" suffix)', () => {
|
||||
expect(tenureMilestone(200)).toBe('Almost a year in. Still reading every note you leave.');
|
||||
});
|
||||
|
||||
it('400 days reads "{n_years} year(s) in."', () => {
|
||||
expect(tenureMilestone(400)).toBe('1 year in. Still reading every note you leave.');
|
||||
expect(tenureMilestone(730)).toBe('2 years in. Still reading every note you leave.');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue