-- ═══════════════════════════════════════════════════════════════ -- 007 TIMELINE -- -- The history page's spine. One row per thing worth putting on the -- rail, and — this is the whole point — a row that points at an -- event holds almost nothing of its own. Title, date and logo are -- read back from `events` at query time, so editing the event edits -- the timeline and there is no second copy to drift. -- -- Decade headers are NOT here. There are four of them, they change -- about never, and they are editorial voice rather than record; they -- live in src/data/historyDecades.ts. -- -- ref_kind + ref_id is polymorphic, matching content_blocks and -- links rather than inventing a second pattern. SQLite can't express -- that as a foreign key, so the triggers below do the work one -- would, exactly as those two tables already do. -- -- PRAGMA user_version; -- was 6 before this file -- ═══════════════════════════════════════════════════════════════ CREATE TABLE timeline_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, -- What the entry is about, which drives the marker and the body -- layout on the page. Usually mirrors ref_kind; 'people' is the -- exception, being a team ref rendered as a roster, and -- 'milestone' is the free-standing case with no ref at all. kind TEXT NOT NULL DEFAULT 'milestone' CHECK (kind IN ('milestone', 'event', 'organization', 'award', 'people')), ref_kind TEXT CHECK (ref_kind IN ('event', 'organization', 'award', 'person', 'team')), ref_id TEXT, -- Null inherits from the referenced row: an event's starts_on. A -- hand-authored entry has to supply its own, which the descriptor -- can't require conditionally — the read layer reports an entry -- with neither rather than the table refusing it. occurred_on TEXT, -- How much of occurred_on is trustworthy. Backfilled rows often -- have a full date where only the year is actually known, and -- 'year' is what routes them to "Elsewhere in 2009" instead of -- asserting a month nobody can source. precision TEXT NOT NULL DEFAULT 'day' CHECK (precision IN ('year', 'month', 'day')), -- All null-inherits-from-the-ref. Filling one in is an override, -- for when the timeline wants to say something the event card -- doesn't. title TEXT, blurb TEXT, meta TEXT, link_url TEXT, is_featured INTEGER NOT NULL DEFAULT 0 CHECK (is_featured IN (0, 1)), is_published INTEGER NOT NULL DEFAULT 1 CHECK (is_published IN (0, 1)), sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), -- Half a reference is worse than none: it would resolve to a link -- with no destination and no way to notice. CHECK ((ref_kind IS NULL) = (ref_id IS NULL)), -- One timeline entry per referenced record, which is what makes -- the in_timeline checkbox an upsert rather than a duplicate -- factory. SQLite permits any number of NULL pairs here, so -- hand-authored entries are unaffected. UNIQUE (ref_kind, ref_id) ) STRICT; CREATE INDEX timeline_entries_date_idx ON timeline_entries (is_published, occurred_on DESC); -- Who an entry is about, when it isn't a whole team. A 'people' -- entry naming a team resolves its roster through v_org_leadership -- instead and leaves this table empty; this is for the cases where -- the list is editorial rather than structural. -- -- Safe for the CRUD engine's delete-and-reinsert because nothing -- references these rows. CREATE TABLE timeline_entry_people ( entry_id INTEGER NOT NULL REFERENCES timeline_entries (id) ON DELETE CASCADE, person_id TEXT NOT NULL REFERENCES people (id) ON DELETE CASCADE, note TEXT, -- 'Founding lead' sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (entry_id, person_id) ) STRICT; CREATE INDEX timeline_entry_people_person_idx ON timeline_entry_people (person_id); -- ── The checkbox on the event and organization editors ───────── -- -- Not a denormalised copy of "does a timeline row exist" — it is the -- gate the admin descriptor reads. Ticked, the extension upserts a -- timeline_entries row; unticked, the engine deletes it. The flag -- and the row are written in the same transaction, so they cannot -- disagree. ALTER TABLE events ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)); ALTER TABLE organizations ADD COLUMN in_timeline INTEGER NOT NULL DEFAULT 0 CHECK (in_timeline IN (0, 1)); -- ── Integrity for the polymorphic reference ──────────────────── CREATE TRIGGER timeline_entries_ref_exists BEFORE INSERT ON timeline_entries BEGIN SELECT CASE WHEN new.ref_kind = 'event' AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such event') WHEN new.ref_kind = 'organization' AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such organization') WHEN new.ref_kind = 'award' AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such award') WHEN new.ref_kind = 'person' AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such person') WHEN new.ref_kind = 'team' AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such team') END; END; -- The same check on update, because the standalone editor can -- repoint an entry at a different record. CREATE TRIGGER timeline_entries_ref_exists_update BEFORE UPDATE OF ref_kind, ref_id ON timeline_entries BEGIN SELECT CASE WHEN new.ref_kind = 'event' AND NOT EXISTS (SELECT 1 FROM events WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such event') WHEN new.ref_kind = 'organization' AND NOT EXISTS (SELECT 1 FROM organizations WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such organization') WHEN new.ref_kind = 'award' AND NOT EXISTS (SELECT 1 FROM awards WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such award') WHEN new.ref_kind = 'person' AND NOT EXISTS (SELECT 1 FROM people WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such person') WHEN new.ref_kind = 'team' AND NOT EXISTS (SELECT 1 FROM teams WHERE id = new.ref_id) THEN RAISE(ABORT, 'timeline_entries: no such team') END; END; -- Deleting the record deletes its entry. Separate triggers rather -- than editing the existing *_cleanup ones, so this migration adds -- and never rewrites. CREATE TRIGGER timeline_events_cleanup AFTER DELETE ON events BEGIN DELETE FROM timeline_entries WHERE ref_kind = 'event' AND ref_id = old.id; END; CREATE TRIGGER timeline_organizations_cleanup AFTER DELETE ON organizations BEGIN DELETE FROM timeline_entries WHERE ref_kind = 'organization' AND ref_id = old.id; END; CREATE TRIGGER timeline_awards_cleanup AFTER DELETE ON awards BEGIN DELETE FROM timeline_entries WHERE ref_kind = 'award' AND ref_id = old.id; END; CREATE TRIGGER timeline_people_cleanup AFTER DELETE ON people BEGIN DELETE FROM timeline_entries WHERE ref_kind = 'person' AND ref_id = old.id; END; CREATE TRIGGER timeline_teams_cleanup AFTER DELETE ON teams BEGIN DELETE FROM timeline_entries WHERE ref_kind = 'team' AND ref_id = old.id; END; -- updated_at, with the same WHEN guard as the other touch triggers -- so an explicit value passes through untouched on import. CREATE TRIGGER timeline_entries_touch AFTER UPDATE ON timeline_entries FOR EACH ROW WHEN new.updated_at = old.updated_at BEGIN UPDATE timeline_entries SET updated_at = datetime('now') WHERE id = new.id; END; -- ── Read view ────────────────────────────────────────────────── -- -- Every fallback the page depends on, resolved once here rather than -- restated by each route. An entry with no title of its own takes -- the referenced record's name; with no date, the event's starts_on. -- -- org_kind rides along because /regions, /chapters and /partners are -- three different routes and only this table knows which a slug is. -- -- effective_date is the sort key. An entry that ended up with no -- date at all sorts last rather than vanishing, so a missing one is -- visible in the admin instead of silently absent from the page. CREATE VIEW v_timeline AS SELECT t.id, t.kind, t.ref_kind, t.ref_id, t.precision, t.is_featured, t.is_published, t.sort_order, COALESCE(t.occurred_on, e.starts_on, pa.awarded_on) AS effective_date, COALESCE( t.title, e.title, o.name, aw.name, p.display_name, tm.name ) AS effective_title, COALESCE(t.blurb, e.tagline, o.tagline, aw.description, p.tagline, tm.tagline) AS effective_blurb, t.meta, t.link_url, -- Filename only. The directory is the frontend's business. COALESCE(e.event_logo, e.org_logo, o.logo, aw.logo, p.photo, tm.logo) AS effective_logo, o.kind AS org_kind, tm.org_id AS team_org_id, tm.name AS team_name, -- Whether the referenced record is itself visible. An entry must not -- outlive the thing it points at being unpublished — a draft event -- would otherwise leak its title and date onto a public page. Null -- for a standalone milestone, which answers to nothing but its own -- is_published. CASE t.ref_kind WHEN 'event' THEN e.is_published WHEN 'organization' THEN o.is_published WHEN 'person' THEN p.is_published WHEN 'team' THEN tm.is_published ELSE NULL END AS ref_is_published, t.occurred_on, t.title AS title_override FROM timeline_entries t LEFT JOIN events e ON t.ref_kind = 'event' AND e.id = t.ref_id LEFT JOIN organizations o ON t.ref_kind = 'organization' AND o.id = t.ref_id LEFT JOIN awards aw ON t.ref_kind = 'award' AND aw.id = t.ref_id LEFT JOIN people p ON t.ref_kind = 'person' AND p.id = t.ref_id LEFT JOIN teams tm ON t.ref_kind = 'team' AND tm.id = t.ref_id LEFT JOIN person_awards pa ON t.ref_kind = 'award' AND pa.award_id = t.ref_id AND pa.id = (SELECT MIN(id) FROM person_awards WHERE award_id = t.ref_id); PRAGMA user_version = 7;