v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -48,6 +48,78 @@ const AFFILIATION_ROLE_FIELDS = [
{ path: "is_public", label: "Public", widget: "checkbox" },
];
/* The timeline panel that appears on an event or organization once the
box is ticked. Paths are prefixed with the extension key, exactly as
'region.scope' and 'private.notes' are.
Everything here is an override. Left blank, the history page falls
back to the record's own title, date and logo through v_timeline —
which is the point of referencing rather than copying. */
const timelineExtensionFields = (noun) => [
{
path: "timeline.occurred_on",
label: "Date on the timeline",
help: `Blank uses the ${noun}'s own date. Partial dates are fine: 2012, 2012-06`,
},
{
path: "timeline.precision",
label: "Date precision",
widget: "select",
options: ["year", "month", "day"],
help: "How much of the date to trust. 'year' files it under 'Elsewhere in 2012'",
},
{
path: "timeline.title",
label: "Title override",
full: true,
help: `Blank uses the ${noun}'s name`,
},
{
path: "timeline.blurb",
label: "Blurb override",
widget: "textarea",
full: true,
help: `Blank uses the ${noun}'s tagline`,
},
{ path: "timeline.meta", label: "Secondary line", help: "Region, venue, recipient" },
{
path: "timeline.link_url",
label: "Link override",
help: `Blank links to the ${noun}'s own page`,
},
{
path: "timeline.is_featured",
label: "Featured",
widget: "checkbox",
help: "Shown large, above the month list for its year",
},
{ path: "timeline.is_published", label: "Visible on the history page", widget: "checkbox" },
{ path: "timeline.sort_order", label: "Sort order", widget: "number" },
];
/* The checkbox itself, plus the panel it gates. One entry in `groups`. */
const timelineGroup = (noun) => ({
legend: "Timeline",
note:
`Adds this ${noun} to the history page. Nothing is copied — the entry ` +
`reads this record, so editing it here updates the timeline too.`,
fields: [
{
path: "in_timeline",
label: "On the timeline",
widget: "checkbox",
help: "Show this on the history page",
},
],
});
const timelineDetailGroup = (noun) => ({
legend: "Timeline entry",
when: { path: "in_timeline", value: 1 },
note: "Every field here is optional. Blank means \u201cuse the record's own value\u201d.",
fields: timelineExtensionFields(noun),
});
/* The two collections every entity carries. */
const linksChild = {
key: "links",
@ -69,6 +141,43 @@ const linksChild = {
],
};
/* Hosts. One row is one host, ordered, and each is either an
organization or a person — never both, which the CHECK on
event_hosts enforces and this form can only ask nicely about.
The first row supplies the logo and colour when the event sets
neither, so the order here is data rather than a display choice.
A person supplies neither: there is no colour on a person and a
headshot is not a logo, so a person-hosted event with no colour
of its own falls through to the section default. */
const hostsChild = {
key: "event_hosts",
label: "Hosts",
addLabel: "Add host",
title: (row, options) =>
options?.organizations?.find((o) => o.id === row.org_id)?.label ??
options?.people?.find((p) => p.id === row.person_id)?.label ??
"New host",
blank: { org_id: "", person_id: "" },
fields: [
{
path: "org_id",
label: "Organization",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
},
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
blankLabel: "— none —",
help: "One or the other, not both. First host supplies the logo and colour.",
},
],
};
const blocksChild = {
key: "content_blocks",
label: "Content blocks",
@ -184,6 +293,8 @@ const organizations = {
],
},
{ legend: "Place", fields: PLACE_FIELDS },
timelineGroup("organization"),
timelineDetailGroup("organization"),
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
@ -225,13 +336,19 @@ const events = {
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "section_id", label: "Section" },
{ key: "section_id", label: "Scope" },
{ key: "event_type", label: "Type" },
{ key: "date_label", label: "Dates" },
{ key: "status", label: "Status" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{ key: "section_id", label: "Section", optionsFrom: "event_sections" },
{ key: "section_id", label: "Scope", optionsFrom: "event_sections" },
{
key: "event_type",
label: "Type",
options: ["retreat", "class", "workshop", "meeting", "other"],
},
{ key: "status", label: "Status", options: ["upcoming", "past", "cancelled"] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
@ -241,20 +358,25 @@ const events = {
{
legend: "Identity",
fields: [
// The column is still section_id — the rows in event_sections
// are what changed, not the schema. Only the label moved,
// because "scope" is what the field has always meant and
// "section" described where it happened to be rendered.
{
path: "section_id",
label: "Section",
label: "Scope",
widget: "select",
optionsFrom: "event_sections",
required: true,
help: "Whose gathering this is. Only national, regional and partner appear on the Retreats page",
},
{
path: "host_org_id",
label: "Host",
path: "event_type",
label: "Type",
widget: "select",
optionsFrom: "organizations",
blankLabel: "— none —",
help: "Supplies the logo and colour when this event sets neither",
options: ["retreat", "class", "workshop", "meeting", "other"],
required: true,
help: "What kind of gathering. Independent of the scope",
},
{ path: "title", label: "Title", required: true },
{ path: "theme", label: "Theme" },
@ -290,10 +412,13 @@ const events = {
{ path: "gradient", label: "Gradient", full: true },
],
},
timelineGroup("event"),
timelineDetailGroup("event"),
{ legend: "Publishing", fields: PUBLISH_FIELDS },
],
children: [
hostsChild,
{
key: "event_people",
label: "People at this event",
@ -600,7 +725,160 @@ const awards = {
],
};
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards };
/* ── Timeline ─────────────────────────────────────────── */
// The only entity with no slug: the table assigns the id, because an
// entry referencing an event has no name of its own and one gets
// created every time somebody ticks a checkbox. `idKind: "auto"` tells
// EntityEdit to show the id rather than ask for it.
//
// Most rows here are created from an event or organization page, not
// this one. What this page is for: hand-authored milestones with no
// record behind them, 'people' entries about a team forming, and fixing
// up the entries the checkboxes made.
const timeline = {
key: "timeline",
label: "Timeline",
singular: "entry",
idLabel: "Entry",
idKind: "auto",
titleFrom: "title",
list: {
columns: [
{ key: "title", label: "Title", primary: true },
{ key: "occurred_on", label: "Date" },
{ key: "kind", label: "Kind" },
{ key: "ref_id", label: "References" },
{ key: "is_featured", label: "Featured", widget: "bool" },
{ key: "is_published", label: "Live", widget: "bool" },
],
filters: [
{
key: "kind",
label: "Kind",
options: ["milestone", "event", "organization", "award", "people"],
},
{
key: "ref_kind",
label: "References",
options: ["event", "organization", "award", "person", "team"],
},
{ key: "is_featured", label: "Featured", options: [["1", "Featured"], ["0", "Normal"]] },
{ key: "is_published", label: "Published", options: [["1", "Live"], ["0", "Hidden"]] },
],
},
groups: [
{
legend: "What this is",
note:
"An entry either points at a record or stands on its own. " +
"Pointing at one means its title, date and logo come from that " +
"record \u2014 nothing is copied, so editing the record updates this.",
fields: [
{
path: "kind",
label: "Kind",
widget: "select",
options: ["milestone", "event", "organization", "award", "people"],
required: true,
help: "Drives the marker and the layout. 'people' renders a roster",
},
{
path: "ref_kind",
label: "Points at",
widget: "select",
options: ["event", "organization", "award", "person", "team"],
blankLabel: "\u2014 nothing, this stands alone \u2014",
help: "Changing this leaves the record below orphaned \u2014 pick a new one",
},
{
path: "ref_id",
label: "Record",
widget: "select",
optionsFrom: "timeline_refs",
blankLabel: "\u2014 none \u2014",
// One flat list of every referenceable row, narrowed to the
// kind chosen above. Five dropdowns of which four are always
// wrong would be worse.
filterBy: (option, row) => option.kind === row.ref_kind,
help: "Only records of the kind chosen above",
},
],
},
{
legend: "When",
note:
"Blank takes the referenced record's own date. Precision is what " +
"says how much of the date to believe \u2014 a backfilled entry that " +
"only knows the year should say so.",
fields: [
{
path: "occurred_on",
label: "Date",
help: "2012, 2012-06 or 2012-06-14",
},
{
path: "precision",
label: "Precision",
widget: "select",
options: ["year", "month", "day"],
},
],
},
{
legend: "Text",
note: "All optional. Blank uses the referenced record's own wording.",
fields: [
{ path: "title", label: "Title", full: true },
{ path: "blurb", label: "Blurb", widget: "textarea", full: true },
{ path: "meta", label: "Secondary line", help: "Region, venue, recipient" },
{ path: "link_url", label: "Link", help: "Blank links to the record's own page" },
],
},
{
legend: "Publishing",
fields: [
{
path: "is_featured",
label: "Featured",
widget: "checkbox",
help: "Shown large, above the month list for its year",
},
{ path: "is_published", label: "Published", widget: "checkbox" },
{ path: "sort_order", label: "Sort order", widget: "number" },
],
},
],
children: [
{
key: "people",
label: "People",
addLabel: "Add person",
note:
"For an entry about people. A 'people' entry pointing at a team " +
"already shows that team's members \u2014 this is for the cases where " +
"the list is editorial rather than structural.",
title: (row, options) =>
options?.people?.find((p) => p.id === row.person_id)?.label ?? "New person",
blank: { person_id: "" },
fields: [
{
path: "person_id",
label: "Person",
widget: "select",
optionsFrom: "people",
required: true,
},
{ path: "note", label: "Note", help: "Founding lead, first chair" },
],
},
],
};
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
export function slugify(value) {
return String(value ?? "")

46
src/lib/eventTypes.ts Normal file
View file

@ -0,0 +1,46 @@
/* ═══════════════════════════════════════════════════════════════
EVENT TYPES
The client half of the CHECK on events.event_type. Order here is
display order — the filter chips read it straight off this array,
so moving a line moves a chip.
What this is not: event_sections. A section owns presentation —
Retreats.tsx keys its title, accent and background on the id, so
an unrecognised section_id makes an event vanish with no error,
which is why that one is a real table with a real foreign key. A
type carries no presentation of its own and an unknown value
renders as its own name, so a CHECK is enough.
Adding a type is three edits: the CHECK in a migration, the enum
in both descriptor halves, and this list. Adding it here alone
means the site offers a filter the database will refuse to store.
═══════════════════════════════════════════════════════════════ */
export type EventType = 'retreat' | 'class' | 'workshop' | 'meeting' | 'other'
export const EVENT_TYPES: { id: EventType; label: string; plural: string }[] = [
{ id: 'retreat', label: 'Retreat', plural: 'Retreats' },
{ id: 'class', label: 'Class', plural: 'Classes' },
{ id: 'workshop', label: 'Workshop', plural: 'Workshops' },
{ id: 'meeting', label: 'Meeting', plural: 'Meetings' },
{ id: 'other', label: 'Other', plural: 'Other' },
]
export const EVENT_TYPE_IDS: EventType[] = EVENT_TYPES.map((entry) => entry.id)
const BY_ID = new Map<string, { label: string; plural: string }>(
EVENT_TYPES.map((entry) => [entry.id as string, entry]),
)
const capitalize = (word: string) =>
word ? word.charAt(0).toUpperCase() + word.slice(1) : ''
/* A value the CHECK has gained since this file was written renders
as itself rather than vanishing — the same rule EventDetail's
ROLE_ORDER follows for billing roles. */
export const eventTypeLabel = (id?: string | null): string =>
(id ? BY_ID.get(id)?.label : null) ?? capitalize(id ?? '')
export const eventTypePlural = (id?: string | null): string =>
(id ? BY_ID.get(id)?.plural : null) ?? capitalize(id ?? '')

174
src/lib/hrefs.ts Normal file
View file

@ -0,0 +1,174 @@
/* ═══════════════════════════════════════════════════════════════
PUBLIC HREFS
One place that turns a record into a URL. The API deliberately
never sends paths — it sends `{ kind, id }` and, for an
organization, the `org_kind` that decides which of the three
routes a slug belongs to. Deciding that in each component is how
/regions/x and /chapters/x end up both existing for the same
record, and how the history timeline ended up emitting /event/:id
while the router only knew /retreats/:id.
timelineRefs.ts now delegates here rather than keeping its own
table, so there is one answer to "where does an event live" and
changing it is changing EVENT_BASE below.
navConfig.js stays the source of truth for the *nav*: these are
record routes, which never appear in it.
── On missing ids ──
Every builder takes an id the caller believed it had. When it
doesn't, the old behaviour was to interpolate the string
"undefined" into a path, render a link to it, mount a page and
fetch /api/organizations/undefined — four steps between the
mistake and any sign of it, none of which name the component
that made it.
Now the id is checked here. In dev that's a console.error with a
stack trace pointing at the caller; in production the path still
comes out, because a broken link beats a crashed page, and
useResource refuses to fetch it.
═══════════════════════════════════════════════════════════════ */
export type OrgKind = 'national' | 'region' | 'chapter' | 'partner'
export type RefKind = 'event' | 'organization' | 'team' | 'award' | 'person'
/* A region, a chapter and a partner read as different things to a
visitor even though they are one table, so they get one route
each. 'national' is NGU itself — one record, no listing to sit
under, so it falls through to the generic path. */
const ORG_BASE: Record<string, string> = {
region: '/regions',
chapter: '/chapters',
partner: '/partners',
national: '/organizations',
}
/** Canonical base for an event page.
*
* /events/:id, not /retreats/:id. The listing page is called
* Retreats because that's what NGU calls the gatherings it hosts,
* but the records are `events`, the API route is /api/events, and
* plenty of them — partner events, conferences — aren't retreats
* at all. Naming the record route after one page's editorial
* framing would have been wrong the first time a non-retreat got
* its own page.
*
* Nothing redirects from /retreats/:id, because nothing ever
* linked there. */
export const EVENT_BASE = '/events'
const DEV = Boolean((import.meta as any)?.env?.DEV)
/* Router params arrive as strings, so an id that has already been
through a template literal shows up as the literal word. Those
are as broken as a genuine null. */
const BAD = new Set(['', 'undefined', 'null', 'NaN'])
export const isBadId = (id: unknown): boolean =>
id == null || BAD.has(String(id))
function checkId(id: unknown, what: string): string {
if (!isBadId(id)) return String(id)
if (DEV) {
// console.error rather than warn: this is always a bug, and the
// stack is the whole point — it names the component that passed
// nothing.
console.error(
`hrefs: ${what}() was given ${JSON.stringify(id)}. ` +
`The link it returns will 404. Caller:`,
new Error('hrefs: missing id').stack,
)
}
return 'undefined'
}
/**
* The API path for one record, or null when the id isn't usable.
*
* The mirror image of the builders below: they make the URL a
* visitor sees, this makes the URL the client fetches, and both
* have to agree about what counts as an id.
*
* It lives here rather than next to the hook that calls it because
* it is a path, and paths are this file's job — and because a
* component that interpolates a missing route param produces the
* literal string "undefined", which the server cannot tell apart
* from a slug somebody genuinely typed. It answers 404 either way,
* and the log fills with GET /api/organizations/undefined with
* nothing to say where it came from.
*
* Returning null costs a round trip and turns a mystery 404 into
* the not-found page, which is what a visitor should see anyway.
*/
export function detailPath(base: string, id?: string | null): string | null {
return isBadId(id) ? null : `${base}/${encodeURIComponent(String(id))}`
}
export const eventHref = (id?: string | null) =>
`${EVENT_BASE}/${checkId(id, 'eventHref')}`
export const teamHref = (id?: string | null) => `/teams/${checkId(id, 'teamHref')}`
export const awardHref = (id?: string | null) => `/awards/${checkId(id, 'awardHref')}`
export const personHref = (id?: string | null) => `/people/${checkId(id, 'personHref')}`
export function orgHref(id?: string | null, kind?: string | null): string {
return `${ORG_BASE[kind ?? ''] ?? '/organizations'}/${checkId(id, 'orgHref')}`
}
/* For anything holding a polymorphic reference — timeline entries,
content blocks — where the kind arrives as data rather than being
known at the call site.
Returns null for a kind with no page AND for a reference with no
id, so the caller renders plain text instead of a dead link.
This is the one the timeline wants: an entry whose ref didn't
resolve should read as text, not as a link to nowhere. */
export function refHref(
kind: string | null | undefined,
id: string | null | undefined,
orgKind?: string | null,
): string | null {
if (!kind || isBadId(id)) return null
switch (kind) {
case 'event':
return eventHref(id)
case 'organization':
return orgHref(id, orgKind)
case 'team':
return teamHref(id)
case 'award':
return awardHref(id)
case 'person':
return personHref(id)
default:
return null
}
}
/* What to call the kind in a breadcrumb or a back link. */
export const ORG_KIND_LABEL: Record<string, string> = {
national: 'Next Generation of Unity',
region: 'Region',
chapter: 'Chapter',
partner: 'Partner organization',
}
/* Where "back" goes from a record page. A chapter belongs to
/community, a retreat to /retreats. */
export function orgListHref(kind?: string | null): { to: string; label: string } {
switch (kind) {
case 'region':
return { to: '/community#local', label: 'All regions' }
case 'chapter':
return { to: '/community#local', label: 'All chapters' }
case 'partner':
return { to: '/community#partners', label: 'All partner organizations' }
default:
return { to: '/community', label: 'Community' }
}
}

77
src/lib/media.ts Normal file
View file

@ -0,0 +1,77 @@
/* ═══════════════════════════════════════════════════════════════
MEDIA PATHS
Every image field in the API is a bare filename — "where the
images live is the component's business", as history.js puts it.
This is that business, in one file, so moving a directory is one
edit rather than a grep.
⚠ Only two of these directories are confirmed by the admin help
text: people/ and event-logos/. The other three are a guess at
your convention. Check public/ and fix them here — nothing else
references the paths.
A value that already looks like a path or a URL is returned
untouched, so a hand-written entry can point anywhere.
═══════════════════════════════════════════════════════════════ */
const ABSOLUTE = /^(https?:|\/|data:)/
function inDir(dir: string) {
return (file?: string | null): string | null => {
if (!file) return null
if (ABSOLUTE.test(file)) return file
return `${dir}/${file}`
}
}
export const personPhoto = inDir('/people') // confirmed
export const eventLogo = inDir('/event-logos') // confirmed
export const orgLogo = inDir('/org-logos') // ⚠ guess
export const teamLogo = inDir('/team-logos') // ⚠ guess
export const awardLogo = inDir('/award-logos') // ⚠ guess
/* content_blocks.media, which can be an image on any owner's page,
so it can't share a per-entity directory. */
export const blockMedia = inDir('/media') // ⚠ guess
/* ── By record kind ────────────────────────────────────────────
The timeline sends `logo: { file, kind }` rather than a path,
because v_timeline COALESCEs across five tables and only the
ref_kind says which one the filename came from.
⚠ Three of these directories are the guesses above. Your current
timelineRefs.ts already has the real ones — timeline logos render
today — so copy them into the map above and delete this note.
───────────────────────────────────────────────────────────── */
const BY_KIND: Record<string, (file?: string | null) => string | null> = {
event: eventLogo,
organization: orgLogo,
team: teamLogo,
award: awardLogo,
person: personPhoto,
}
export function logoForKind(
kind?: string | null,
file?: string | null,
): string | null {
if (!file) return null
// An unrecognised kind still renders: a filename with no home
// directory is a bug, but a broken <img> says so louder than a
// silently absent one.
return (BY_KIND[kind ?? ''] ?? blockMedia)(file)
}
/* Initials for a photo that is missing or fails to load. Same
two-word rule PeopleTiles uses. */
export function initials(name = ''): string {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] || '')
.join('')
.toUpperCase()
}

57
src/lib/roles.ts Normal file
View file

@ -0,0 +1,57 @@
/* ═══════════════════════════════════════════════════════════════
ROLES — src/lib/roles.ts
The same ladder as server/src/auth.js, and it has to stay the
same ladder. This copy exists to decide what to draw; the
server's copy decides what's allowed. If they ever disagree the
worst case is a button that 403s, which is the right way round
for them to fail.
Components should ask canDelete(user), not
user.role === "admin". The second form is what silently locked
superadmins out of saving when the third role went in: an
equality check against a ladder is a bug waiting for the next
role to be added, and there's now a fourth.
═══════════════════════════════════════════════════════════════ */
export const ROLES = ["viewer", "editor", "admin", "superadmin"] as const;
export type Role = (typeof ROLES)[number];
export const ROLE_RANK: Record<Role, number> = {
viewer: 1,
editor: 2,
admin: 3,
superadmin: 4,
};
export const ROLE_LABELS: Record<Role, string> = {
viewer: "Viewer",
editor: "Editor",
admin: "Admin",
superadmin: "Superadmin",
};
/* Each line is what that role adds to the one above it in the
list. Read top to bottom, they describe the whole ladder. */
export const ROLE_NOTES: Record<Role, string> = {
viewer: "Can read everything in the CMS and change nothing.",
editor: "Can create and update records. Can't delete anything.",
admin: "Can delete records, including feedback.",
superadmin: "Can manage accounts, roles and sessions.",
};
type MaybeUser = { role?: string | null } | null | undefined;
/* Minimum, not equality: a superadmin passes atLeast(user, "editor"). */
export function atLeast(user: MaybeUser, role: Role): boolean {
const have = ROLE_RANK[(user?.role ?? "") as Role] ?? 0;
return have >= ROLE_RANK[role];
}
/* Named for the capability rather than the rank, so call sites
read as intent and a future reshuffle of the ladder is one edit
here rather than a search for every comparison. */
export const canWrite = (user: MaybeUser) => atLeast(user, "editor");
export const canDelete = (user: MaybeUser) => atLeast(user, "admin");
export const isSuper = (user: MaybeUser) => atLeast(user, "superadmin");

390
src/lib/timeline.ts Normal file
View file

@ -0,0 +1,390 @@
/**
* Timeline types + grouping.
*
* This is the contract between the future `GET /api/history` route and the
* history page.
*
* ── Reference, don't duplicate ─────────────────────────────────────────
* An entry is a *pointer* to a record plus an optional narrative override.
* When the admin panel's "add to timeline" button fires on an event, it
* writes a row holding the event's id and nothing else; title, logo and
* date are read back from `events` at query time. Editing the event
* therefore edits the timeline, and there is no second copy to drift.
*
* Hand-authored entries — "bylaws rewritten", "the gathering becomes
* annual" — carry no ref and supply their own title and blurb. An entry
* may also do both: reference an event but override its title, for when
* the timeline wants to say something the event card doesn't.
*
* ── What the server resolves, and what it doesn't ──────────────────────
* The server resolves *data*: title, date, logo filename, the members of
* a referenced team. It does not resolve *routes* or *asset paths* —
* those are presentation, and live in `timelineRefs.ts` so React Router
* and the public/ layout stay the frontend's business.
*/
export type DatePrecision = 'year' | 'month' | 'day'
/** What an entry is about. Drives the marker and the body layout. */
export type TimelineKind =
| 'milestone' // free-standing narrative, no record behind it
| 'event'
| 'organization'
| 'award'
| 'people' // a team forming, someone joining one
/** Tables an entry can point at. Mirrors the polymorphic owner_kind
* pattern already used by content_blocks and links. */
export type RefKind = 'event' | 'organization' | 'award' | 'person' | 'team'
export type TimelineRef = {
kind: RefKind
/** The row's TEXT primary key — an event id, org slug, team slug. */
id: string
}
/** Filename plus the table it came from; the directory is derived
* frontend-side, because asset layout is not database business. */
export type TimelineLogo = {
file: string
kind: RefKind
}
/** A person as they appear in a 'people' entry. Resolved server-side,
* whether the entry named a team or listed people directly. */
export type PersonRef = {
id: string
name: string
/** people.photo — filename only. */
photo?: string
/** Their affiliation title at the time, if it's worth printing. */
title?: string
}
export type TeamRef = {
id: string
name: string
orgId?: string
orgName?: string
logo?: string
}
export type TimelineItem = {
/** The timeline row's own id, not the referenced record's. */
id: string
/** "2014" | "2014-06" | "2014-06-12" */
date: string
/** How much of `date` is trustworthy. Authoritative — a backfilled row
* may hold a full date while only the year is actually known. */
precision: DatePrecision
kind: TimelineKind
/** Falls back to the referenced record's own name when the row has no
* title of its own. Resolved server-side. */
title: string
blurb?: string
/** Secondary line: host org, region, venue, recipient. */
meta?: string
featured?: boolean
/** The record this points at. Absent for free-standing milestones. */
ref?: TimelineRef
/** Explicit link override. Absent → derived from `ref`. Every kind can
* carry one; event/organization/award fall back to their own page. */
href?: string
logo?: TimelineLogo
/** kind === 'people': who the entry is about. Populated from the named
* team's current members, or from an explicit person list. */
people?: PersonRef[]
/** Set when the entry named a team rather than loose people. */
team?: TeamRef
}
export type DecadeMeta = {
/** 2010, 2020, … */
decade: number
title: string
tagline: string
blurb?: string
/** Renders the ghosted treatment and the "before NGU" marker. */
preProgram?: boolean
}
export type GroupedMonth = {
month: number
label: string
items: TimelineItem[]
}
export type GroupedYear = {
year: number
featured: boolean
count: number
featuredItems: TimelineItem[]
/** Year-precision items — known to be this year, month unknown. */
undated: TimelineItem[]
months: GroupedMonth[]
}
export type GroupedDecade = DecadeMeta & {
years: GroupedYear[]
count: number
}
export type SortDirection = 'desc' | 'asc'
export const MONTH_LABELS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export function decadeOf(year: number): number {
return Math.floor(year / 10) * 10
}
export function decadeLabel(decade: number): string {
return `${decade}s`
}
// ── dates ────────────────────────────────────────────────────────────
type DateParts = { year: number; month: number | null; day: number | null }
function parseDate(item: TimelineItem): DateParts {
const [y, m, d] = item.date.split('-')
const year = Number(y)
if (!Number.isFinite(year)) {
throw new Error(`Timeline item ${item.id} has an unparseable date: "${item.date}"`)
}
if (item.precision === 'year') return { year, month: null, day: null }
const month = m ? Number(m) : null
if (item.precision === 'month') return { year, month, day: null }
return { year, month, day: d ? Number(d) : null }
}
const pad = (n: number) => String(n).padStart(2, '0')
/**
* Start of the item's date window, as a sortable YYYY-MM-DD.
*
* A year-precision item resolves to 1 January, a month-precision one to
* the 1st. That makes "is this still upcoming?" answerable for imprecise
* dates in the one way that can't surprise anyone: an entry stops being
* upcoming as soon as any part of its window has passed. A row dated
* only "2027" is upcoming through the end of 2026 and no longer is on
* 1 January 2027, even though its real date may be months away.
*/
export function windowStart(item: TimelineItem): string {
const { year, month, day } = parseDate(item)
return `${year}-${pad(month ?? 1)}-${pad(day ?? 1)}`
}
export function todayISO(now: Date = new Date()): string {
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/**
* Split upcoming from recorded, off the wall clock rather than a flag.
* Nothing needs flipping when a date passes.
*/
export function partitionByDate(
items: TimelineItem[],
now: Date = new Date(),
): { upcoming: TimelineItem[]; past: TimelineItem[] } {
const today = todayISO(now)
const upcoming: TimelineItem[] = []
const past: TimelineItem[] = []
for (const item of items) {
if (windowStart(item) > today) upcoming.push(item)
else past.push(item)
}
return { upcoming, past }
}
// ── grouping ─────────────────────────────────────────────────────────
function byDay(dir: SortDirection) {
return (a: TimelineItem, b: TimelineItem) => {
const da = parseDate(a).day
const db = parseDate(b).day
if (da == null && db == null) return a.title.localeCompare(b.title)
if (da == null) return 1
if (db == null) return -1
return dir === 'desc' ? db - da : da - db
}
}
function byFeaturedThenDay(dir: SortDirection) {
const day = byDay(dir)
return (a: TimelineItem, b: TimelineItem) => {
if (!!a.featured !== !!b.featured) return a.featured ? -1 : 1
return day(a, b)
}
}
export type GroupOptions = {
direction?: SortDirection
/**
* Decades ending before this year get the pre-program treatment even
* if the decade row doesn't say so. Lets the gap survive missing
* metadata.
*/
programStartYear?: number
/**
* Repeat featured items inside their month node as well as in the
* featured block. Off by default — in a sparse year it just prints the
* same line twice. A month left with nothing but featured items drops
* out entirely.
*/
featuredInMonths?: boolean
}
/** Bucket a flat item list into years. Shared by the main rail and the
* upcoming block above it. */
export function groupYears(
items: TimelineItem[],
options: GroupOptions = {},
): GroupedYear[] {
const direction = options.direction ?? 'desc'
const featuredInMonths = options.featuredInMonths ?? false
const sign = direction === 'desc' ? -1 : 1
const yearBuckets = new Map<number, TimelineItem[]>()
for (const item of items) {
const { year } = parseDate(item)
const bucket = yearBuckets.get(year)
if (bucket) bucket.push(item)
else yearBuckets.set(year, [item])
}
const years: GroupedYear[] = []
for (const [year, yearItems] of yearBuckets) {
const featuredItems: TimelineItem[] = []
const undated: TimelineItem[] = []
const monthMap = new Map<number, TimelineItem[]>()
for (const item of yearItems) {
if (item.featured) {
featuredItems.push(item)
if (!featuredInMonths) continue
}
const { month } = parseDate(item)
if (month == null) {
undated.push(item)
continue
}
const bucket = monthMap.get(month)
if (bucket) bucket.push(item)
else monthMap.set(month, [item])
}
const months: GroupedMonth[] = [...monthMap.entries()]
.sort((a, b) => sign * (a[0] - b[0]))
.map(([month, monthItems]) => ({
month,
label: MONTH_LABELS[month - 1] ?? `Month ${month}`,
items: monthItems.sort(byFeaturedThenDay(direction)),
}))
featuredItems.sort(byDay(direction))
undated.sort((a, b) => a.title.localeCompare(b.title))
years.push({
year,
featured: featuredItems.length > 0,
count: yearItems.length,
featuredItems,
undated,
months,
})
}
return years.sort((a, b) => sign * (a.year - b.year))
}
export function groupTimeline(
items: TimelineItem[],
decades: DecadeMeta[],
options: GroupOptions = {},
): GroupedDecade[] {
const direction = options.direction ?? 'desc'
const sign = direction === 'desc' ? -1 : 1
const metaByDecade = new Map(decades.map((d) => [d.decade, d]))
const decadeBuckets = new Map<number, GroupedYear[]>()
for (const year of groupYears(items, options)) {
const dec = decadeOf(year.year)
const bucket = decadeBuckets.get(dec)
if (bucket) bucket.push(year)
else decadeBuckets.set(dec, [year])
}
// Include decades that have metadata but no items yet, so an authored
// "before NGU" decade still renders its marker.
for (const meta of decades) {
if (!decadeBuckets.has(meta.decade)) decadeBuckets.set(meta.decade, [])
}
return [...decadeBuckets.entries()]
.sort((a, b) => sign * (a[0] - b[0]))
.map(([decade, years]) => {
const meta = metaByDecade.get(decade)
const inferredPreProgram =
options.programStartYear != null && decade + 9 < options.programStartYear
return {
decade,
title: meta?.title ?? decadeLabel(decade),
tagline: meta?.tagline ?? '',
blurb: meta?.blurb,
preProgram: meta?.preProgram ?? inferredPreProgram,
years: years.sort((a, b) => sign * (a.year - b.year)),
count: years.reduce((sum, y) => sum + y.count, 0),
}
})
}
/**
* Insert empty year nodes between the first and last year that actually
* has data, so sparse decades read as gaps in the record rather than as
* a shorter decade. Does not pad beyond the data.
*/
export function withGapYears(
years: GroupedYear[],
direction: SortDirection = 'desc',
): GroupedYear[] {
if (years.length < 2) return years
const present = new Map(years.map((y) => [y.year, y]))
const all = years.map((y) => y.year)
const min = Math.min(...all)
const max = Math.max(...all)
const filled: GroupedYear[] = []
for (let year = min; year <= max; year += 1) {
filled.push(
present.get(year) ?? {
year,
featured: false,
count: 0,
featuredItems: [],
undated: [],
months: [],
},
)
}
return direction === 'desc' ? filled.reverse() : filled
}
/** Years that should start expanded: the most recent year with featured items. */
export function defaultOpenYears(decades: GroupedDecade[]): number[] {
for (const decade of decades) {
if (decade.preProgram) continue
const hit = decade.years.find((y) => y.featured)
if (hit) return [hit.year]
}
return []
}

75
src/lib/timelineRefs.ts Normal file
View file

@ -0,0 +1,75 @@
/* ═══════════════════════════════════════════════════════════════
TIMELINE REFS
Turns a timeline item into a destination and an image. Both were
answered locally here before, which is how the timeline came to
link events at /event/:id while the router only knew about
/retreats/:id — two files holding the same opinion, one of them
wrong, neither aware of the other.
Now this file knows about timeline items and nothing else. Where
a record lives is hrefs.ts; where an image lives is media.ts.
── The shape this reads, from history.js ──
item.href explicit link_url override, may be off-site
item.ref { kind, id, orgKind? } — orgKind only on
organizations, because only they have it
item.logo { file, kind } — v_timeline COALESCEs the
filename across five tables, so the kind is
what says which directory it came from
item.team { id, name, orgId? } on a team ref
item.people[] { id, name, photo?, title? }
Every one of those is optional. An entry is a standalone
milestone until proven otherwise, and the two accessors below
return null rather than assuming a shape that isn't there —
which is the other half of the /organizations/undefined bug:
reaching into a ref that wasn't sent yields undefined, and
undefined interpolates into a path perfectly happily.
═══════════════════════════════════════════════════════════════ */
import { refHref } from './hrefs.ts'
import { logoForKind, personPhoto } from './media.ts'
import type { TimelineItem } from './timeline.ts'
/**
* Where this entry points, or null if nowhere.
*
* An explicit link_url wins: it's the editor deliberately
* overriding the record's own page, usually to send someone to an
* external write-up. TimelineEntry checks for a scheme and renders
* an <a> instead of a <Link>, so this returns it unchanged.
*
* Otherwise the reference decides, and refHref returns null for a
* kind with no page yet ('person', until /people/:id exists) as
* well as for a ref that didn't resolve. Null means the entry
* renders as a plain <div> — right for a milestone, and right for
* a reference that's missing an id, which used to render as a link
* to a 404.
*/
export function hrefFor(item: TimelineItem): string | null {
if (item.href) return item.href
const ref = item.ref
if (!ref) return null
return refHref(ref.kind, ref.id, ref.orgKind)
}
/**
* The entry's image, resolved against the directory for whatever
* kind of record the filename came from.
*/
export function logoSrc(item: TimelineItem): string | null {
if (!item.logo) return null
return logoForKind(item.logo.kind, item.logo.file)
}
/**
* A roster member's photo. Same rule as everywhere else — the API
* sends a bare filename.
*/
export function photoSrc(photo?: string | null): string | null {
return personPhoto(photo)
}

243
src/lib/useContent.ts Normal file
View file

@ -0,0 +1,243 @@
/**
* The four detail endpoints, typed.
*
* Adding a fifth is a type and a one-line hook — useRecord owns
* the fetching, the caching and the 404.
*
* An id that is missing — or the literal string "undefined", which
* is what a template literal makes of a missing route param — never
* reaches the network. detailPath returns null and the hook reports
* notFound, which is what the visitor should see anyway, and
* hrefs.ts has already logged the component that produced it.
*/
import { detailPath } from './hrefs.ts'
import { useRecord, type Resource } from './useRecord.ts'
import type { EventType } from './eventTypes.ts'
/* ── Shared shapes ───────────────────────────────────────────── */
/** A content_blocks row with its `items` child, as shape.js sends it. */
export type ContentBlock = {
id?: number | string
slot?: 'card' | 'body'
type:
| 'heading'
| 'subheading'
| 'paragraph'
| 'list'
| 'links'
| 'quote'
| 'image'
| 'divider'
text?: string | null
media?: string | null
href?: string | null
items?: Array<{ text: string; detail?: string | null; url?: string | null }>
}
export type Link = {
kind?: string
platform?: string | null
label: string
url: string
is_primary?: boolean
}
export type OrgRef = { id: string; name: string; kind?: string | null }
/* One host of an event. `kind` says which table the id is in;
`org_kind` is the region/chapter/partner split that decides an
organization's route, and is null for a person. The triple is
exactly what refHref() in hrefs.ts takes. */
export type EventHost = {
kind: 'organization' | 'person'
id: string
name: string
org_kind?: string | null
}
/* ── Events ──────────────────────────────────────────────────── */
export type EventPerson = {
person_id: string
display_name: string
pronouns?: string | null
tagline?: string | null
photo?: string | null
role?: string | null
title?: string | null
}
export type EventAward = {
award: { id: string; name: string; logo?: string | null }
person: { id: string; name: string; photo?: string | null }
awarded_on?: string | null
citation?: string | null
}
export type EventRecord = {
id: string
/** Which band of the Retreats page this belongs to. */
section_id: string
/** What kind of gathering it is. Orthogonal to section_id. */
event_type: EventType
title: string
theme?: string | null
tagline?: string | null
starts_on?: string | null
ends_on?: string | null
date_label?: string | null
status: 'upcoming' | 'past' | 'cancelled'
location_label?: string | null
locality?: string | null
state_code?: string | null
country?: string | null
is_online: boolean
org_logo?: string | null
event_logo?: string | null
color?: string | null
gradient?: string | null
/* In billing order. The first is the one `color` and `org_logo`
fell back to when the event set neither. */
hosts: EventHost[]
description: string[]
links: Link[]
instagram?: Link | null
blocks: ContentBlock[]
people: EventPerson[]
awards: EventAward[]
}
export const useEvent = (id?: string): Resource<EventRecord> =>
useRecord<EventRecord>(detailPath('/events', id), 'event')
/* ── Organizations ───────────────────────────────────────────── */
export type Leader = {
person_id: string
display_name: string
pronouns?: string | null
title?: string | null
role?: string | null
is_owner: boolean
photo?: string | null
public_email?: string | null
team_id?: string | null
team_name?: string | null
}
export type OrgTeam = {
id: string
name: string
tagline?: string | null
color?: string | null
logo?: string | null
}
export type OrgAward = {
id: string
name: string
description?: string | null
logo?: string | null
recipient_count: number
}
export type OrgEvent = {
id: string
title: string
event_type: EventType
date_label?: string | null
status: string
location_label?: string | null
event_logo?: string | null
color?: string | null
}
export type OrganizationRecord = {
id: string
kind: 'national' | 'region' | 'chapter' | 'partner'
name: string
short_name?: string | null
tagline?: string | null
color?: string | null
logo?: string | null
venue?: string | null
address?: string | null
locality?: string | null
state_code?: string | null
country?: string | null
location_label?: string | null
is_online: boolean
description: string[]
blocks: ContentBlock[]
links: Link[]
socials: Link[]
website?: Link | null
email?: Link | null
instagram?: Link | null
/** Shape depends on `kind`; empty object for national and partner. */
details: {
scope?: string | null
map_note?: string | null
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
region_id?: string | null
region_name?: string | null
region_color?: string | null
meets?: string | null
started?: string | null
}
leadership: Leader[]
teams: OrgTeam[]
awards: OrgAward[]
events: OrgEvent[]
}
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
/* ── Teams ───────────────────────────────────────────────────── */
/** No members here on purpose — PeopleTiles fetches
* /teams/:id/people itself. See the note in content.js. */
export type TeamRecord = {
id: string
name: string
tagline?: string | null
color?: string | null
logo?: string | null
org: OrgRef
description: string[]
links: Link[]
socials: Link[]
instagram?: Link | null
blocks: ContentBlock[]
}
export const useTeam = (id?: string): Resource<TeamRecord> =>
useRecord<TeamRecord>(detailPath('/teams', id), 'team')
/* ── Awards ──────────────────────────────────────────────────── */
export type Recipient = {
id: string
name: string
photo?: string | null
tagline?: string | null
awarded_on?: string | null
citation?: string | null
event: { id: string; title: string } | null
}
export type AwardRecord = {
id: string
name: string
description?: string | null
logo?: string | null
org: OrgRef | null
recipients: Recipient[]
}
export const useAward = (id?: string): Resource<AwardRecord> =>
useRecord<AwardRecord>(detailPath('/awards', id), 'award')

87
src/lib/useHistory.ts Normal file
View file

@ -0,0 +1,87 @@
/**
* Loads the history timeline from `GET /api/history`.
*
* Goes through the shared client, so the request is deduped and cached
* for 60s like every other read. Two consequences worth knowing:
*
* · `reload` has to invalidate before it refetches. Without that, the
* retry button inside the TTL would hand back the same settled
* promise and look like it did nothing. A *failed* request is
* already evicted by the client, so this matters for the refresh
* case rather than the error case.
*
* · there's no AbortController. `get` shares one promise between
* callers, so aborting on unmount would cancel someone else's
* request. The `live` flag drops the result instead.
*
* No `fallback` on purpose. Handing this the mock data would render a
* plausible-looking history with no indication the server is down, and
* the wrong history is worse than a visible error — the same reason
* `fallback: EMPTY` came out elsewhere.
*
* `undated` is the number of published entries the API left out because
* nothing gave them a date. Not rendered publicly — a visitor can't act
* on it — but returned so it's reachable if you want a warning in the
* admin later.
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
import type { TimelineItem } from './timeline'
const PATH = '/history'
type HistoryResponse = {
items?: TimelineItem[]
undated?: number
}
type State = {
items: TimelineItem[]
undated: number
loading: boolean
error: string | null
reload: () => void
}
export function useHistory(): State {
const [items, setItems] = useState<TimelineItem[]>([])
const [undated, setUndated] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [attempt, setAttempt] = useState(0)
const reload = useCallback(() => {
invalidate(PATH)
setAttempt((n) => n + 1)
}, [])
useEffect(() => {
let live = true
async function load() {
setLoading(true)
setError(null)
try {
const data: HistoryResponse = await get(PATH)
if (!live) return
setItems(Array.isArray(data?.items) ? data.items : [])
setUndated(Number(data?.undated) || 0)
} catch (err) {
if (!live) return
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
)
} finally {
if (live) setLoading(false)
}
}
load()
return () => {
live = false
}
}, [attempt])
return { items, undated, loading, error, reload }
}

127
src/lib/useRecord.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* useRecord — one record from one endpoint, on the useHistory
* pattern.
*
* Named for what it returns, and deliberately not useResource:
* src/lib/useResource.js is a different hook — it hands back the
* whole response body and takes an options object — and a .ts file
* of the same name would sit one extension away from it. An import
* whose target goes missing then resolves to the other file without
* a word, and every detail page renders the wrapper object instead
* of the record. That is exactly how this file came to exist.
*
* Every detail route in content.js answers the same shape: 200 with
* a single top-level key, or 404 with `{ error }`. So the hook takes
* the path and the key, and the four callers in useContent.ts are
* one line each rather than four copies of this file.
*
* `key` is a string rather than a selector function on purpose. A
* selector passed inline would be a new identity every render, and
* putting it in the effect's deps would refetch forever; leaving it
* out would silently use a stale closure. A string has neither
* problem.
*
* Carried over from useHistory, and worth restating:
*
* · `reload` invalidates before it refetches. Without that, the
* retry button inside the 60s TTL hands back the same settled
* promise and looks like it did nothing. A *failed* request is
* already evicted by api.js, so this matters for the refresh
* case rather than the error case.
*
* · no AbortController. `get` shares one promise between callers,
* so aborting on unmount would cancel someone else's request.
* The `live` flag drops the result instead.
*
* · no `fallback`. Rendering plausible-looking content with no
* sign the server is down is worse than a visible error.
*
* `notFound` is separated from `error` because they are different
* pages: a 404 is a slug that doesn't exist and retrying won't help,
* anything else is worth a Try again button.
*
* A null `path` means there is nothing to ask for, and the hook
* reports notFound rather than loading forever. Callers build the
* path with detailPath() from hrefs.ts, which returns null for an
* id that could never be real — "undefined" chief among them.
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
export type Resource<T> = {
data: T | null
loading: boolean
error: string | null
notFound: boolean
reload: () => void
}
export function useRecord<T>(path: string | null, key: string): Resource<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
const [attempt, setAttempt] = useState(0)
const reload = useCallback(() => {
if (path) invalidate(path)
setAttempt((n) => n + 1)
}, [path])
useEffect(() => {
// Nothing to ask for. Distinguished from "not asked yet" by the
// caller: useContent passes null only when the id is unusable,
// and an unusable id is a 404 as far as the visitor is
// concerned.
if (!path) {
setData(null)
setError(null)
setNotFound(true)
setLoading(false)
return undefined
}
let live = true
async function load() {
setLoading(true)
setError(null)
setNotFound(false)
try {
const body = await get(path as string)
if (!live) return
// A 200 with the key absent is a server-side shaping bug,
// not an empty record. Say so rather than rendering a page
// full of blanks.
const record = body?.[key]
if (record === undefined) {
setError(`The server sent no "${key}".`)
setData(null)
return
}
setData(record as T)
} catch (err) {
if (!live) return
if (err instanceof ApiError && err.status === 404) {
setNotFound(true)
setData(null)
return
}
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
)
setData(null)
} finally {
if (live) setLoading(false)
}
}
load()
return () => {
live = false
}
}, [path, key, attempt])
return { data, loading, error, notFound, reload }
}

10
src/lib/version.ts Normal file
View file

@ -0,0 +1,10 @@
/* ═══════════════════════════════════════════════════════════════
SITE VERSION — src/lib/version.ts
One string, bumped by hand when the working directory name
changes. The admin footer is the only thing reading it today;
keep it here rather than in a component so the panel, an about
box or a build banner can read the same value later.
═══════════════════════════════════════════════════════════════ */
export const SITE_VERSION = "NGU-Web.v1.5-history";