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
279
server/src/migrations/007_timeline.sql
Normal file
279
server/src/migrations/007_timeline.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue