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

@ -74,6 +74,51 @@ const affiliationRole = [
bool("is_public"),
];
/* The editable half of a timeline entry, shared by the standalone
editor and by the in_timeline extension on events and organizations.
occurred_on is text, not date. "2012" and "2025-07" are legitimate
values — a backfilled entry often knows the year and nothing more —
and the date coercion would reject both. `precision` is what says how
much of it to believe. */
const timelineFields = [
text("occurred_on"),
enumeration("precision", ["year", "month", "day"]),
text("title"),
text("blurb"),
text("meta"),
text("link_url"),
bool("is_featured"),
bool("is_published"),
int("sort_order"),
];
/* The extension that the in_timeline checkbox drives. Ticked, the row
is upserted; unticked, writeExtensions deletes it. Both happen in the
parent's transaction, so the flag and the row cannot disagree.
The conflict target is the UNIQUE (ref_kind, ref_id) index from
migration 007, which is also what stops a second save creating a
duplicate instead of updating the first. */
const timelineExtension = (refKind) => ({
key: "timeline",
table: "timeline_entries",
owner: { column: "ref_id", kindColumn: "ref_kind", kindValue: refKind },
conflict: ["ref_kind", "ref_id"],
when: { column: "in_timeline", value: 1 },
columns: [
// Fixed for this end: an event's entry is always an event entry.
// Declared as a default rather than a form field so the column is
// written without asking.
enumeration(
"kind",
["milestone", "event", "organization", "award", "people"],
{ default: refKind === "organization" ? "organization" : refKind },
),
...timelineFields,
],
});
/* The two polymorphic collections, parameterised by owner_kind. */
const linksChild = (ownerKind) => ({
key: "links",
@ -131,6 +176,26 @@ const blocksChild = (ownerKind) => ({
],
});
/* Hosts. One row is one host, ordered, each either an organization
or a person — the CHECK on event_hosts rejects both and the blank
filter drops neither, so the only bad row that reaches SQLite is
one with both selects filled, and that comes back keyed to the
row like any other field error.
Not parameterised the way links and blocks are: this table is
events-only, and the owner column says so.
sort_order isn't declared. The engine writes it from the row's
position because `order` is "sort_order", which is what makes
the first row the one v_events takes the logo and colour from. */
const hostsChild = {
key: "event_hosts",
table: "event_hosts",
owner: { column: "event_id" },
order: "sort_order",
columns: [text("org_id"), text("person_id")],
};
/* ── Organizations ───────────────────────────────────────────── */
const organizations = {
@ -169,9 +234,11 @@ const organizations = {
...placeColumns,
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
],
extensions: [
timelineExtension("organization"),
{
key: "region",
table: "regions",
@ -226,7 +293,7 @@ const events = {
"id",
"title",
"section_id",
"host_org_id",
"event_type",
"date_label",
"starts_on",
"status",
@ -234,14 +301,29 @@ const events = {
"sort_order",
"updated_at",
],
filters: ["section_id", "status", "is_published", "host_org_id"],
// No host filter: hosts are rows in another table now, and the
// engine's filters are columns on this one. The events a host
// owns are on that host's own page.
filters: ["section_id", "event_type", "status", "is_published"],
search: ["title", "id", "theme"],
order: "sort_order, starts_on DESC, title",
},
columns: [
text("section_id", { required: true }),
text("host_org_id"),
// What kind of gathering, as against section_id's which band of
// the page. Declared required even though the column has a
// DEFAULT: every select renders a blank first option, so without
// it a new event files itself as a retreat while nobody is
// looking. An existing row always loads with its value set, so
// this only ever asks on create.
enumeration(
"event_type",
["retreat", "class", "workshop", "meeting", "other"],
{ required: true },
),
text("title", { required: true }),
text("theme"),
text("tagline"),
@ -256,9 +338,13 @@ const events = {
text("gradient"),
bool("is_published"),
int("sort_order"),
bool("in_timeline"),
],
extensions: [timelineExtension("event")],
children: [
hostsChild,
linksChild("event"),
blocksChild("event"),
{
@ -485,7 +571,69 @@ const awards = {
],
};
export const ENTITIES = { organizations, events, people, teams, awards };
/* ── Timeline ────────────────────────────────────────────────── */
// The history page's spine, and the only entity whose id the table
// assigns. There is nothing to slug: an entry referencing an event has
// no name of its own, and one gets created every time somebody ticks a
// checkbox. idKind "auto" is what lets createRow skip the id entirely.
//
// ref_kind and ref_id are writable here and only here. The extension on
// events and organizations owns those two columns for rows it created,
// which is why they aren't in timelineFields.
//
// Deleting an entry takes its people with it (ON DELETE CASCADE) and
// nothing points at an entry, so the delete-and-reinsert child engine
// is safe on this one.
const timeline = {
key: "timeline",
table: "timeline_entries",
idColumn: "id",
idKind: "auto",
concurrency: "updated_at",
list: {
columns: [
"id",
"kind",
"ref_kind",
"ref_id",
"occurred_on",
"title",
"is_featured",
"is_published",
"updated_at",
],
filters: ["kind", "ref_kind", "is_featured", "is_published"],
search: ["title", "blurb", "meta", "ref_id"],
// Undated entries sort last rather than first, so a missing date
// reads as something to fix instead of something to scroll past.
order: "occurred_on IS NULL, occurred_on DESC, sort_order",
},
columns: [
enumeration(
"kind",
["milestone", "event", "organization", "award", "people"],
{ required: true },
),
enumeration("ref_kind", ["event", "organization", "award", "person", "team"]),
text("ref_id"),
...timelineFields,
],
children: [
{
key: "people",
table: "timeline_entry_people",
owner: { column: "entry_id" },
order: "sort_order",
columns: [text("person_id", { required: true }), text("note")],
},
],
};
export const ENTITIES = { organizations, events, people, teams, awards, timeline };
/* ── Options for the form's select inputs ────────────────────── */
@ -503,6 +651,22 @@ export const OPTION_QUERIES = {
teams:
"SELECT id, name AS label, org_id FROM teams ORDER BY org_id, sort_order, name",
// One flat list the ref picker filters by ref_kind, rather than five
// dropdowns of which four are always wrong. `kind` is the discriminator
// the client's filterBy matches on; org_kind disambiguates the label,
// since a region and a chapter can share a name.
timeline_refs: `
SELECT 'event' AS kind, id, title AS label FROM events
UNION ALL
SELECT 'organization', id, name || ' (' || kind || ')' FROM organizations
UNION ALL
SELECT 'award', id, name FROM awards
UNION ALL
SELECT 'person', id, display_name FROM people
UNION ALL
SELECT 'team', id, name FROM teams
ORDER BY kind, label`,
// The awarding organization is folded into the label instead,
// because a person can receive an award from any organization —
// there is nothing to filter on, only something to disambiguate