From 096cdb00b60b678b1486cfe1100ebba9e3a309bb Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 10:48:50 +0200 Subject: [PATCH] feat(pulse): two-column greeting + tenure-milestone copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- src/lib/format.ts | Bin 10989 -> 12332 bytes src/pages/pulse.astro | 85 ++++++++++++++++++++++++++++----- tests/tenure-milestone.test.ts | 33 +++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 tests/tenure-milestone.test.ts diff --git a/src/lib/format.ts b/src/lib/format.ts index 7eb1b9181ea7a65830b81eef96b765cdd766292d..0fce83e1efbb11a88baf899d123ae8c05ae0107e 100644 GIT binary patch delta 1101 zcmbVLO-~d-5Dm$~#wDKkh%q5nB4%dTb%2Fk2(Y5jiw9y1OHA|ty)(7!NKZHR^ne>e z)*s-7^xq)Sgdc>On0WEvKd`zNLE=ZyIZSt|t6#nMs`~53@z(p#g98=wW$?~OB-4~Z z8DiF@e=3FUm~Yp{PUePI2Ph>k-s+WL3f2;inU(?}lDl2lV=7@nXV8`kbO&KuA?ieI zgGP}n1fB>C;SqsGG8Y<>%%Vh$qllTZ)iTHu5fL71ZN8M09&g4>Blv_EDWq+@$uzXt z3(i|C+6h8vQlSNl)5@bAr0PuJ63oCf%zk=f>a;GRbu%tSqvkPe>z6Ngfd8*cyO) zsJb8|N$JWQII=wqz2y=>(gueOE3j~eNVgp1mW7-=a0SaSd+4C=k(pP(-nxw4fNGzw zxnCIVWsrz};Q0$yt=4DyL;3?~<38*o?pz6Z@{blYfO-8UAr){z|V5)Q;kyNwF%BWR%}PX*|G36IyG(EHGsfxppw7?`KS z<3*atb$TsF@BF67(-tacTqFhQ5K^U#CmF`jph1)&CGtGR%=emJ0IN0zOYTJBE@u(? x9(WaSr)BV-d&@_sUikth2)9u;lN5c|h%?*PnC^tOOEVQyEO-OK$=f2k{T=WLV3Gg; delta 74 zcmZ3J@HTYAQ&wh$L)#}mV^tMQNvu@RRw&NMFDg;U&Ce^zm~6l=y4je`o>2;@L_wuG dC9xz`uOvUTpdhu#Ik7lZL$fwv^J$($WdJF38&CiM diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index 722c8bd..b151e53 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -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}

-

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

+
+

{dateLabel}

+

{greetingPrefix}{firstName}.

+

{tenureCopy}

+
+ {memberNumberLabel && ( +
+

{memberNumberLabel}

+

Founding circle

+
+ )}
@@ -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 ───────────── */ diff --git a/tests/tenure-milestone.test.ts b/tests/tenure-milestone.test.ts new file mode 100644 index 0000000..332d180 --- /dev/null +++ b/tests/tenure-milestone.test.ts @@ -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.'); + }); +});