v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -0,0 +1,279 @@
-- ═══════════════════════════════════════════════════════════════
-- 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;