/** * 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. */ import type { OrgKind } from './hrefs.ts' 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 /** Only on organizations: history.js copies v_timeline.org_kind. */ orgKind?: OrgKind } /** 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() 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() 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() 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 [] }