Migrations 020–023, with the code that reads each: - 020 drops people_lists, people_list_members, v_chapters and v_person_affiliations. Nothing queried any of them. - 021 renames event_sections to event_scopes and events.section_id to scope_id, finishing what 015 described. The API sends `scopes` and `scope_id`, and useEvents, EventListCards and EventCalendar take `scope`. It also inserts national/regional/partner, which only the retired seed ever created: a database built from migrations alone had no scope for the Retreats bands. - 022 drops events.sort_order and people.sort_order. Events now sort by date (upcoming soonest first, past latest first, undated last) on /events, org pages and the countdown. People were only ever sorted by sort_name on the site. Every other sort_order stays. - 023 rebuilds teams with created_at and updated_at plus a touch trigger, so the teams editor gets the same optimistic concurrency as the other entities. Awards can be drafted: is_published (added in 011) is on both descriptor halves with a Publishing group, and the award list, award page, org awards and event awards leave drafts out. The migration runner now turns foreign keys off around the per-file transactions and runs foreign_key_check before each commit. PRAGMA foreign_keys is a no-op inside a transaction, so 009's warning was right and a rebuild of a referenced table (023) couldn't be written otherwise. CLAUDE.md is updated to match. Also removes the stray src/App.tsx.save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
6.5 KiB
JavaScript
186 lines
6.5 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
|
FRONT PAGE ROUTE — read-only, mounted under /api
|
|
|
|
GET /front-page the home page's configuration, resolved
|
|
|
|
Everything the admin's Front page editor holds, shaped for the
|
|
page: hidden sections dropped, stats counted, paths carrying
|
|
their actions, and the countdown's event looked up.
|
|
|
|
The retreats carousel and the timeline rail are not in here.
|
|
They fetch /events and /history themselves, as they do on their
|
|
own pages, so the rules for which events and entries are public
|
|
live in one place each. This route only says whether those bands
|
|
appear and under what heading.
|
|
|
|
── Stats ──
|
|
A stat's source picks a query from STAT_QUERIES. Each counts
|
|
exactly what the matching public page shows: published rows, and
|
|
for awards only public citations to published people. A count
|
|
that disagreed with the page it summarises would be worse than
|
|
none. 'manual' and 'years_since' read the row's own value.
|
|
|
|
── Countdown ──
|
|
The pinned event if it is still published and not over;
|
|
otherwise the next published, non-cancelled event that hasn't
|
|
ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today,
|
|
so a running series with a start date in the past still counts.
|
|
The client works out the next meeting of a series from `series`.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
import { Hono } from "hono";
|
|
|
|
import { asBool, shapeSeries } from "../shape.js";
|
|
|
|
const home = new Hono();
|
|
|
|
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
|
|
|
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
|
|
|
const PAGE_ID = "home";
|
|
|
|
const STAT_QUERIES = {
|
|
regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`,
|
|
chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`,
|
|
partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`,
|
|
events_held: `SELECT COUNT(*) AS n FROM v_events
|
|
WHERE is_published = 1 AND effective_status = 'past'`,
|
|
retreats_held: `SELECT COUNT(*) AS n FROM v_events
|
|
WHERE is_published = 1 AND effective_status = 'past'
|
|
AND event_type = 'retreat'`,
|
|
people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`,
|
|
awards_given: `SELECT COUNT(*) AS n
|
|
FROM person_awards pa
|
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
|
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
|
|
WHERE pa.is_public = 1`,
|
|
};
|
|
|
|
/* The number as a string, or null when there's nothing to print —
|
|
a manual stat nobody filled in, or a year that isn't one. */
|
|
function statValue(db, row) {
|
|
if (row.source === "manual") return row.value || null;
|
|
|
|
if (row.source === "years_since") {
|
|
const year = Number.parseInt(row.value ?? "", 10);
|
|
if (!Number.isInteger(year)) return null;
|
|
return String(Math.max(0, new Date().getFullYear() - year));
|
|
}
|
|
|
|
const sql = STAT_QUERIES[row.source];
|
|
return sql ? String(db.prepare(sql).get().n) : null;
|
|
}
|
|
|
|
function shapeCountdown(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
theme: row.theme,
|
|
starts_on: row.starts_on,
|
|
ends_on: row.ends_on,
|
|
date_label: row.date_label,
|
|
location_label: row.location_label,
|
|
is_online: asBool(row.is_online),
|
|
color: row.effective_color,
|
|
event_logo: row.event_logo,
|
|
series: shapeSeries(row),
|
|
};
|
|
}
|
|
|
|
home.get("/front-page", (c) => {
|
|
const db = c.get("db");
|
|
|
|
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
|
|
|
|
// Migration 017 creates the row and the engine refuses to delete
|
|
// it, so this is a database that hasn't been migrated. Say so.
|
|
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
|
|
|
|
const byOrder = (table) =>
|
|
db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID);
|
|
|
|
const sections = byOrder("front_page_sections")
|
|
.filter((row) => !asBool(row.is_hidden))
|
|
.map((row) => ({ section: row.section, title: row.title, blurb: row.blurb }));
|
|
|
|
const slides = byOrder("front_page_slides").map((row) => ({
|
|
media: row.media,
|
|
alt: row.alt,
|
|
caption: row.caption,
|
|
link_url: row.link_url,
|
|
}));
|
|
|
|
const stats = byOrder("front_page_stats")
|
|
.map((row) => ({
|
|
label: row.label,
|
|
value: statValue(db, row),
|
|
suffix: row.suffix,
|
|
note: row.note,
|
|
}))
|
|
.filter((stat) => stat.value !== null);
|
|
|
|
const actions = db.prepare(
|
|
`SELECT label, description, url FROM front_page_path_actions
|
|
WHERE path_id = ? ORDER BY sort_order`,
|
|
);
|
|
const paths = byOrder("front_page_paths")
|
|
.map((row) => ({
|
|
label: row.label,
|
|
icon: row.icon,
|
|
blurb: row.blurb,
|
|
actions: actions.all(row.id),
|
|
}))
|
|
// A path with nothing to do is a dead tab.
|
|
.filter((path) => path.actions.length > 0);
|
|
|
|
const notOver = `is_published = 1
|
|
AND effective_status != 'cancelled'
|
|
AND COALESCE(ends_on, starts_on) >= date('now')`;
|
|
|
|
const pinned = page.countdown_event_id
|
|
? db
|
|
.prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`)
|
|
.get(page.countdown_event_id)
|
|
: null;
|
|
|
|
const next =
|
|
pinned ??
|
|
db
|
|
.prepare(
|
|
`SELECT * FROM v_events
|
|
WHERE ${notOver}
|
|
ORDER BY starts_on, title
|
|
LIMIT 1`,
|
|
)
|
|
.get();
|
|
|
|
return json(c, {
|
|
front_page: {
|
|
hero: {
|
|
mode: page.hero_mode,
|
|
eyebrow: page.eyebrow,
|
|
headline: page.headline,
|
|
subhead: page.subhead,
|
|
primary: page.primary_label && page.primary_url
|
|
? { label: page.primary_label, url: page.primary_url }
|
|
: null,
|
|
secondary: page.secondary_label && page.secondary_url
|
|
? { label: page.secondary_label, url: page.secondary_url }
|
|
: null,
|
|
slide_seconds: page.slide_seconds,
|
|
slides,
|
|
livestream: page.livestream_url
|
|
? { url: page.livestream_url, title: page.livestream_title }
|
|
: null,
|
|
},
|
|
sections,
|
|
stats,
|
|
paths,
|
|
countdown: shapeCountdown(next),
|
|
},
|
|
});
|
|
});
|
|
|
|
export default home;
|