From b0e6d7e18b039b083d8227bd9351b4dff2144664 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 16:05:47 +0200 Subject: [PATCH] feat(page): /events + /events/past + AvatarPile component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /events: - Header: EVENTS · 'Where the council gathers.' · one-line subtitle - Hero invitation card on --ink for the soonest non-office_hours event: NEXT UP · MEMBERS ONLY / INVITATION BY HAND eyebrow strip, two-column date/detail body separated by a 0.5px vertical line, foot strip with '{capacity} seats · {confirmed} confirmed' + AvatarPile of confirmed attendees and the RSVP CTA. The RSVP button toggles between cream-on-ink 'Save your seat →' and outlined 'You're confirmed ✓ Change'. Empty-state card retains the visual weight when no upcoming non-office_hours event. - ALSO COMING UP — every other upcoming event including office_hours. Three-column rows; the right column uses event.action_label or falls back to defaultActionLabel(kind). Studio hours surfaces with 'Book a slot →'. - PAST GATHERINGS — two-column grid. Each card has a 56px thumb: photo_url if set, else a copper-tinted notes square when notes_url is present, else a deterministic two-pigment gradient block. View all → links to /events/past. /events/past — same card component, full list of starts_at < now() events. No boolean past flag column; filter is purely date-based. AvatarPile (src/components/AvatarPile.astro) — reusable. Overlapping circle slots with a 1.5px border in a caller-provided colour (defaults to surface, the hero card overrides to --ink so circles read on dark). Stacks z-index so leftmost is on top; +N overflow chip at the end. format.ts: adds eventKindLabel (office_hours → 'Studio hours') and defaultActionLabel per kind. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/AvatarPile.astro | 56 ++++ src/lib/format.ts | Bin 9323 -> 10178 bytes src/pages/events.astro | 529 ++++++++++++++++++++++++++++++++ src/pages/events/past.astro | 171 +++++++++++ 4 files changed, 756 insertions(+) create mode 100644 src/components/AvatarPile.astro create mode 100644 src/pages/events.astro create mode 100644 src/pages/events/past.astro diff --git a/src/components/AvatarPile.astro b/src/components/AvatarPile.astro new file mode 100644 index 0000000..615fc01 --- /dev/null +++ b/src/components/AvatarPile.astro @@ -0,0 +1,56 @@ +--- +import Avatar from './Avatar.astro'; +import type { UserPublic } from '../lib/db'; + +interface Props { + users: UserPublic[]; + max?: number; + size?: number; + /** Border color between overlapping avatars — defaults to --surface for cream surfaces. */ + borderColor?: string; +} + +const { users, max = 5, size = 22, borderColor = 'var(--surface)' } = Astro.props; + +const shown = users.slice(0, max); +const overflow = Math.max(0, users.length - shown.length); +--- +
+ {shown.map((u, i) => ( + + + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+ + diff --git a/src/lib/format.ts b/src/lib/format.ts index 07d758325a27f35bc43ec808fe3941ca9054df31..7126bc17965c56867bfe9f858e11cb6787e93566 100644 GIT binary patch delta 720 zcmb7>K~4fO7=;&ZVB(5|m}p}DxTzw+J;a3uBLNfCF}N^>)R~Snl$K0e2n)i_wQ&yM z8HhLV1fIbymvGt{afrCEyS~2uef{2|@!ozu&Yz74S1ro@d2Nv^*&X!bEin8*n1n;{ zNq{v|fIUS8|JI{<;k*qEJ-{4%Du)bDk|PL22(d`aU^}V;A_%BQx;>#o2~vjv_J{;5 z0l7;1RDeA}(bYzA*WNRME=+)ZlcfsFl+nj#F?!w3%_!$2tisPibt|E=W3=pxiEH(# z#3l7$K-J0=*GlP>5e=2bEMq{OM4F|Ju?XXYk6lTmjOoNqbel+!O4c|vpY;Tn>fp}& z=)UGm9p|W2g8CY|FeG7yUm3I{K8$-LrCPyW!F18J9RZcVHDiX}URu_P?drb-T|6Q% ziA##JV+Eg2FMrcK6`~IqBokTV8lcy1=Q1;DiL|+_4mAbk8Os``D?@(4eA~+3?0f-& C9O!`n delta 15 WcmX@)|Jq}M?M9E69Gh!-T%`d# e.kind !== 'office_hours') ?? null; +const alsoUpcoming = upcoming.filter(e => e.id !== hero?.id); +const past = getPastEvents(8); + +function parseUtc(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'); +} + +function fmt(part: Intl.DateTimeFormatOptions, iso: string): string { + return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso)); +} +function dayNum(iso: string) { return fmt({ day: 'numeric' }, iso); } +function weekday(iso: string) { return fmt({ weekday: 'short' }, iso).toUpperCase(); } +function monthShort(iso: string) { return fmt({ month: 'short' }, iso).toUpperCase(); } +function timeStr(iso: string) { return fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, iso); } + +const heroAttendees = hero ? getEventAttendees(hero.slug, 'yes') : []; +const heroConfirmedCount = heroAttendees.length; +const heroMyRsvp = hero ? getUserRsvp(user.id, hero.slug) : null; +const heroAudience = hero?.audience ?? 'Members only'; +--- + +
+ +
+

Events

+

Where the council gathers.

+

Dinners, working sessions, the occasional summit. Always small, always off the record.

+
+ + + {hero ? ( +
+
+ Next up · {heroAudience} + Invitation by hand +
+ +
+
+ {weekday(hero.starts_at)} + {dayNum(hero.starts_at)} + {monthShort(hero.starts_at)} +
+ +
+

{hero.title}

+

{hero.description}

+

{hero.location}{hero.location && ' · '}{timeStr(hero.starts_at)}{hero.duration_label ? ` · ${hero.duration_label}` : ''}

+
+
+ +
+
+ + {hero.capacity ? `${hero.capacity} seats · ` : ''}{heroConfirmedCount} confirmed + + {heroAttendees.length > 0 && ( + + )} +
+ +
+ + + {heroMyRsvp === 'yes' ? ( + <> + You're confirmed ✓ + + + ) : ( + + )} +
+
+
+ ) : ( +
+

+ Nothing scheduled yet — when we have something, you'll be the first to know. +

+
+ )} + + + {alsoUpcoming.length > 0 && ( +
+

Also coming up

+
    + {alsoUpcoming.map(ev => ( +
  • +
    + {dayNum(ev.starts_at)} + {monthShort(ev.starts_at)} +
    +
    +

    {ev.title}

    +

    + {[ev.duration_label, ev.audience, ev.location].filter(Boolean).join(' · ') + || eventKindLabel(ev.kind)} +

    +
    +
    + + + +
    +
  • + ))} +
+
+ )} + + + {past.length > 0 && ( +
+
+

Past gatherings

+ View all → +
+
    + {past.map(ev => { + const monthCode = monthShort(ev.starts_at); + const attended = getEventRsvpCount(ev.slug).going; + const hasNotes = !!ev.notes_url; + const pigA = pigmentForId(ev.id); + const pigB = pigmentForId(ev.id + 1); + return ( +
  • + {ev.photo_url ? ( + + ) : hasNotes ? ( + + {monthCode} + + ) : ( + + )} +
    +

    {ev.title}

    +

    {fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)}{ev.location && ` · ${ev.location}`}

    +

    + {attended} attended · {hasNotes ? 'Notes shared' : 'No notes'} +

    +
    +
  • + ); + })} +
+
+ )} + +
+
+ + diff --git a/src/pages/events/past.astro b/src/pages/events/past.astro new file mode 100644 index 0000000..63529f9 --- /dev/null +++ b/src/pages/events/past.astro @@ -0,0 +1,171 @@ +--- +import AppLayout from '../../layouts/AppLayout.astro'; +import { getPastEvents, getEventRsvpCount } from '../../lib/db'; +import { pigmentForId } from '../../lib/format'; + +const user = Astro.locals.user; +const past = getPastEvents(500); + +function parseUtc(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'); +} +function fmt(part: Intl.DateTimeFormatOptions, iso: string): string { + return new Intl.DateTimeFormat('en-GB', { ...part, timeZone: 'Europe/Copenhagen' }).format(parseUtc(iso)); +} +--- + +
+ +
+

Past gatherings

+

The archive.

+

Everything the council has gathered around so far.

+ ← Back to upcoming +
+ + {past.length === 0 ? ( +

No past events yet.

+ ) : ( +
    + {past.map(ev => { + const monthCode = fmt({ month: 'short' }, ev.starts_at).toUpperCase(); + const attended = getEventRsvpCount(ev.slug).going; + const hasNotes = !!ev.notes_url; + const pigA = pigmentForId(ev.id); + const pigB = pigmentForId(ev.id + 1); + return ( +
  • + {ev.photo_url ? ( + + ) : hasNotes ? ( + + {monthCode} + + ) : ( + + )} +
    +

    {ev.title}

    +

    + {fmt({ day: 'numeric', month: 'long', year: 'numeric' }, ev.starts_at)} + {ev.location && ` · ${ev.location}`} +

    +

    + {attended} attended · {hasNotes ? 'Notes shared' : 'No notes'} +

    +
    +
  • + ); + })} +
+ )} + +
+
+ +