v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
390
src/lib/timeline.ts
Normal file
390
src/lib/timeline.ts
Normal 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 []
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue