390 lines
12 KiB
TypeScript
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 []
|
|
}
|