NGU-Web/src/lib/timeline.ts

390 lines
12 KiB
TypeScript

/**
* 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 []
}