Darasa Hub - Learning Lions
Dira Portal Safety and Welfare ICT Support

For Developers

The hub as a system: what it is made of, where every record lives, what each endpoint takes and returns, and the exact shape of a hand-in, for whoever connects Asha or anything else to it. Kept true by the build: the schema below is the generated one.

What It Is Made Of

  1. A static site. tools/build.js, Node with no packages, writes public/ from data/, content/, prompts/ and site/static/. Cloudflare Pages builds it on every push to main; a branch gets its own preview address.
  2. Pages Functions in functions/api/, ES modules, one file per endpoint, sharing lib/auth.js (sign-in, cookie, roles) and lib/career-map.js.
  3. One D1 database (SQLite), bound as DB. Its shape is the numbered files in functions/migrations/; functions/schema.sql is generated from them and checked on every build.
  4. No AI, no mail, no queue. Prompts go to the learner's own AI as text; the support form opens the learner's mail app.

Sign-in and Roles

  1. Google Identity Services in the browser gives an ID token; POST /api/auth/google verifies it against Google's keys and sets a signed, HttpOnly session cookie (HMAC with SESSION_SECRET). Every function reads the cookie with currentUser().
  2. Roles: admin, ml-captain, ml-guide, ml-coach, ml-evaluator, bc-trainer, bc-sa, bc-evaluator. Two floors come from variables: ADMIN_EMAILS are admins; any address on TRAINER_DOMAIN or in TRAINER_EMAILS is bc-trainer. The rest live in the roles table.
  3. Every protected endpoint fails closed: 401 without a session, 403 without the role, 503 without the database. All SQL is bound; all browser HTML is escaped.
  4. The person's key everywhere is the email they sign in with. No table joins across people.

Endpoints

MethodPathWhoTakesReturns
GET/api/meanyonenone; ?view=1 counts a page view{ ok, configured, clientId, user: { email, name, picture, roles[], sections[] } | null }; sections are the staff pages the roles open, { name, path, what }
POST/api/auth/googleanyone{ credential }, a Google ID tokensets the session cookie; { ok, user }
POST/api/auth/logoutsigned innoneclears the cookie
GET/api/portal/rostercampus staff (an address on portal.domains)none{ ok, updated, sources, groups: [{ name, roles: [{ role, who, note, source }] }] } from data/roster.json: who holds which role, as the papers name them; 401 or 403 to anyone else, and never in a page
GET/api/portal/meanyonenone{ ok, configured, clientId, orgs[], user: { email, name, picture, roles[], roleLabels[], sections[], org, staff } | null }: the Dira Portal's sign-in state; the session cookie is shared across learninglions.org
POST/api/submitanyone; signed in is verified{ name, lionId, level, session, link, note, items: { piece: value } }{ ok, message }; the row is the learner's own only when signed in
GET/api/minesigned innone{ ok, user: { email, name, level, cohort, startDate }, rows: [submission] }, verified rows only
POST/api/minesigned in{ level, cohort, startDate }saves to users
GET/api/handins/<session id>.jsonanyone (a static file)none{ id, code, html, pieces[], worksheet }: what the session asks for, and the fill-in page when there is one
GET/worksheets/<session id>.jsonanyone (a static file)nonethe session's worksheets as data: sheets of blocks, each answer cell, blank and box a field with an id and a label
GET/api/worksheet?session=<id>signed innone{ ok, row: { id, session, version, answers, status, submissionId, updatedAt, submittedAt } | null }, the person's own
PUT/api/worksheet?session=<id>signed in{ action: "save"|"submit"|"reopen", answers: { field id: text | true }, version }saves the draft; hands it in, filing a submissions row whose worksheet_id points back; or reopens it
GET/api/worksheet/<id>the owner; evaluation and submissions roles for the level; adminnone{ ok, row, owner: { name, email }, submission: { status, stars, feedback } }: one filled-in worksheet
GET/lessons/<session id>.jsonanyone (a static file)nonethe lesson as data: parts of moves (read, look, do, check, answer, remember), from content/lessons
GET/slides/<session id>.jsonanyone (a static file)nonethe Session Presentation as slides: title, kicker, body lines, the trainer's notes (say, do, ask, watch, time), the image, and which sheets each slide opens
GET/api/walk?kind=take|teach|learn|card[&amp;session=&lt;id&gt;]signed in; teach: bc-trainer, adminnone{ ok, row | rows }: the person's own walk (the stage reached, the ticks, the notes; for a card, its data: card-<id> a card, sign-<id> a signed document); &team=1 every trainer's teach rows, for the Studio; kind=card&all=1&prefix=sign- every signature, for admins
PUT/api/walk?kind=take|teach|learn|card&amp;session=&lt;id&gt;signed in; teach: bc-trainer, admin{ slide, count, ticks, notes, status }; a card { data, status }saves the walk, or a card or a signature; the worksheet answers go through /api/worksheet
GET/cards.jsonanyone (a static file)nonethe cards a learner builds on My Darasa (content/cards.json) and the titles of the Day-One Pack documents
GET/api/teach[?session=&lt;id&gt;]bc-trainer, adminnonethe sessions with a TOT Deck, or one session: the deck as slides with its notes, the run sheet's segments as a timeline, the trainer files as Drive links
GET/api/submissionsbc-trainer, ml-captain, ml-guide, admin?session=&level=&q=&limit={ ok, rows: [submission] } for the levels the roles cover
GET/api/evaluateml-evaluator (own), bc-trainer, bc-evaluator, admin?done=1the queue, { ok, rows }
POST/api/evaluate/&lt;id&gt;the assignee, the BootCamp team, admin{ status: "done"|"new", feedback, stars: 1|2|3|null }{ ok, id, status, stars }
GET/api/planbc-trainer, adminnone{ ok, team, plan | null, previous, updatedAt, updatedBy }: the trainer team's plan of the BootCamp; null means the kits' placements apply
PUT/api/planbc-trainer, admin{ plan: { v: 2, capacity: { taught, sgl, evening }, pools: { "week-day-block": [piece ids] }, tray: [ids], hours: { id: hours }, pieces: { id: { session, kind } } } }saves the team's plan (one row, plans.team = bootcamp), keeping the version before for one undo; { ok, plan, previous, updatedAt, updatedBy }
DELETE/api/planbc-trainer, adminnoneresets the team's plan to the kits' placements (removes the row)
POST/api/plan/undobc-trainer, adminnoneputs the version before the last save back, once; 404 when there is none
GET/plan-base.jsonanyone (a static file)nonethe base calendar from the kits: { capacity, sessions[], pieces: [{ id, session, kind: taught|sgl, hours, briefing }], pools: { "week-day-block": [piece ids] }, tray }; the blocks are taught, sgl and evening
GET/api/cohortsbc-trainer, adminnone{ ok, rows: [{ id, name, startDate, endDate, holidays: [{ date, name }], active }] }
POST/api/cohortsbc-trainer, admin{ id?, name, startDate (a Monday), holidays, active }makes or changes a cohort; active makes it the one /calendar/ shows; { ok, row }
DELETE/api/cohorts/&lt;id&gt;bc-trainer, adminnoneremoves the cohort
GET/api/calendarsigned in?cohort=<id>, else the active cohort{ ok, cohort, track, capacity, days: [{ date, week, day, holiday, pools: { taught, sgl, evening } }], overflow, tray }: the plan laid on the cohort's dates; each piece { id, session, code, title, url, track, kind, hours, briefing, from }; track is the viewer's own
GET/api/filescrew, studio, desk roles?level=&audience=trainer|scriptsthe staff file lists as Drive links
GET/api/admin/usersadmin?q=&days=everyone signed in, with roles and counts
POST/api/admin/rolesadmin{ email, roles[] }replaces the person's given roles
POST/api/admin/view-asadmin{ email } or { role } or {} to stopsets the view cookie; every write is refused while it is set (functions/_middleware.js)
GET/api/admin/logadmin?kind=&days=&limit={ ok, rows: [{ at, kind, who, what }] }, newest first
any/api/career-map/*signed in; own rows onlysee the route list belowthe Career Map, the CV material and Finding Work

The Career Map Routes, Under /api/career-Map/

RouteWhat
mapGET the whole map; DELETE purges the person's rows
map/who, map/plan, map/audit, map/introPUT one stage as JSON
map/evidence, map/entries, map/actionsPOST adds, PUT /<id> edits, DELETE /<id> removes
map/export.docxGET the map as a Word file
work/leads, work/outreachPOST, PUT /<id>, DELETE /<id>
work/prompt/intro, work/prompt/scoutPOST: the prompt text for the person's own AI
work/export.docxGET Finding Work as a Word file
checkinsPOST a check-in; DELETE /<id>; PUT reminders
cvPOST the draft; PUT improved; POST undo; versions/<id> PUT, DELETE, undo, docx
answers, prefsPUT the questionnaire and the preferences
ai/prompt/<kind>, ai/file/instructions, ai/file/cv, ai/paste/<kind>the own-AI round trip: the prompt and files out, the answer pasted back

A Hand-In, as Stored

One row of submissions. This is what Asha will receive, field for field, once the hub writes to it. items is JSON text keyed by the piece names from the session's kit (output.items); note carries the same pieces as readable lines. verified is 1 only when the learner was signed in. stars is the programme's score: 3 exceeds, 2 meets, 1 below and resubmit.

{
  "id": 42,
  "created": "2026-10-24T14:03:11.000Z",
  "name": "Amina Ekiru",
  "lion_id": "amina.ekiru@learninglions.org",
  "level": "bootcamp",
  "session": "bootcamp-v3-fs4",
  "link": "https://drive.google.com/file/d/.../view",
  "note": "the restore pair: https://drive.google.com/...\nthe one-minute find: witnessed by Peter",
  "items": "{\"the BootCamp tree on the trainee's own machine, built to the pattern on the board\": \"https://drive.google.com/...\", \"the restore pair\": \"https://drive.google.com/...\", \"the one-minute find\": \"witnessed by Peter\"}",
  "verified": 1,
  "assigned_to": null,
  "assigned_at": null,
  "status": "done",
  "evaluated_by": "trainer@learninglions.org",
  "evaluated_at": "2026-10-25T09:40:00.000Z",
  "stars": 2,
  "feedback": "Tree and copy present; one naming slip. Two stars."
}

Ids and Conventions

  1. Session ids are <level>-<version>-<code>, lower case: bootcamp-v3-fs4, mobile-leap-v2-do3. They are stable; a renamed session keeps its id and gains an alias. The full list is /sessions.json.
  2. Levels: mobile-leap, bootcamp, advanced-track; the Career Map adds graduate and earlier.
  3. Times are ISO 8601 in UTC, as text. Dates (a start date, a check-in) are YYYY-MM-DD.
  4. JSON columns end in _json (the Career Map) or are named for what they hold (items); they are text and parsed by the function that owns them.
  5. Every answer is JSON with ok: true or ok: false, error, and a matching HTTP status.
  6. What Asha gets, planned: one call per evaluated hand-in with the row above plus the session code and title, authenticated with a token Asha issues, retried until Asha answers 2xx. Not built until the token exists.

The Data Files the Build Reads

  1. data/sessions.json: the manifest from the Drive tree (npm run scan): levels, modules, sessions with code, title, url, placement (week, day, slot), aliases, assets by audience. Committed.
  2. data/handins.json: per BootCamp session, from the live Word kits (npm run kits: the Take-Home Card for the pieces, the Evaluation Sheet for the criteria; the kit scripts as fallback): outputTitle, output (lead, items), submission, criteria, grading, gradingNote, source, updated, fromDocx.
  3. data/worksheets.json: per BootCamp session, from the live Word kits (npm run kits; the kit scripts as fallback): the sheets as blocks (heading, sub, label, note, p with parts, blank, ticks, form, grid, list, tree), each field { id, kind: text | output | blank | check | select, label, options }; served as /worksheets/<session id>.json. The receipt (FS5's Director Log) is added to every definition by lib/worksheets.js addReceipt(). Answers are { field id: text | true } on the worksheets table; a hand-in is a submissions row with worksheet_id; the evaluator's marks are submissions.evaluation_json, { sheets: [{ id, title, met, note }], lines: [{ text, met }] }.
  4. data/plan-hours.json: per BootCamp session, from the kit scripts (npm run plan): the workbook block's taught, sglLab, overtime and total hours. The build turns the placements and these hours into /plan-base.json, the base calendar: every session's pieces (taught, and self-guided from SGL/Lab and Overtime) in the three blocks of a day. The trainers' plan (/studio/plan/) is one plans row laid over it; a cohort calendar (/calendar/) lays the result onto dates from a cohorts row. Never written back to the kits, the Eight-Week Matrix or the Google calendar.
  5. data/levels.json: the three levels, hand-written. data/career-map.json: the Career Map's words, stages, sittings per level, tracks, blocks, the CV questionnaire.
  6. data/drive-ids.json: the Drive file id for every file in the manifest, so session pages link to Drive.
  7. hub.config.json: the site name, addresses, the publish policy, the AI cards, the Safety and Welfare people and numbers, the kit script folders.

The Database

Generated from 14 migrations: 001-hub.sql, 002-roles.sql, 003-my-hub.sql, 004-handins.sql, 005-career-map.sql, 006-verified.sql, 007-guided.sql, 008-worksheets.sql, 009-plans.sql, 010-evaluation.sql, 011-track.sql, 012-cohorts.sql, 013-google-calendar.sql, 014-walks.sql. A fresh database takes the whole file; an existing one takes the migrations it has not had, in order.

-- The hub's D1 schema, GENERATED by tools/schema.js from functions/migrations/ (001-hub.sql, 002-roles.sql, 003-my-hub.sql, 004-handins.sql, 005-career-map.sql, 006-verified.sql, 007-guided.sql, 008-worksheets.sql, 009-plans.sql, 010-evaluation.sql, 011-track.sql, 012-cohorts.sql, 013-google-calendar.sql, 014-walks.sql).
-- Do not edit: change a migration, or add the next numbered one, then run `npm run schema`.
-- Paste this whole file into a fresh database (the D1 console takes one statement per Execute, or
--   wrangler d1 execute learninglions-hub --remote --file functions/schema.sql).
-- An existing database gets only the migrations it has not had yet, in order. Column comments and
-- the reasons are in the migration files.

CREATE TABLE IF NOT EXISTS submissions (
  id       INTEGER PRIMARY KEY AUTOINCREMENT,
  created  TEXT NOT NULL,
  name     TEXT NOT NULL,
  lion_id  TEXT,                -- the learner's email when signed in, else what they typed
  level    TEXT,
  session  TEXT NOT NULL,       -- a session id from data/sessions.json
  link     TEXT,
  note     TEXT
, assigned_to TEXT, assigned_at TEXT, status TEXT NOT NULL DEFAULT 'new', evaluated_by TEXT, evaluated_at TEXT, feedback TEXT, items TEXT, verified INTEGER NOT NULL DEFAULT 0, stars INTEGER, worksheet_id INTEGER, evaluation_json TEXT);
CREATE INDEX IF NOT EXISTS submissions_session ON submissions (session, created);
CREATE INDEX IF NOT EXISTS submissions_assigned ON submissions (assigned_to, status);

CREATE TABLE IF NOT EXISTS users (
  email      TEXT PRIMARY KEY,
  name       TEXT,
  picture    TEXT,
  first_seen TEXT NOT NULL,
  last_seen  TEXT NOT NULL,
  logins     INTEGER NOT NULL DEFAULT 0,
  views      INTEGER NOT NULL DEFAULT 0
, level TEXT, cohort TEXT, start_date TEXT, track TEXT);

CREATE TABLE IF NOT EXISTS logins (
  id     INTEGER PRIMARY KEY AUTOINCREMENT,
  email  TEXT NOT NULL,
  at     TEXT NOT NULL,
  agent  TEXT
);
CREATE INDEX IF NOT EXISTS logins_email ON logins (email, at);

CREATE TABLE IF NOT EXISTS roles (
  email TEXT NOT NULL,
  role  TEXT NOT NULL,
  given_by TEXT,
  given_at TEXT,
  PRIMARY KEY (email, role)
);

CREATE TABLE IF NOT EXISTS cm_map (
  email             TEXT PRIMARY KEY,                  -- level and cohort live on users, shared with My Hub
  who_json          TEXT NOT NULL DEFAULT '{}',        -- traits self-check, strength, watch-out, EI goals, the sentence
  plan_json         TEXT NOT NULL DEFAULT '{}',        -- strongest track, direction, three steps with cost and date, backup
  plan_history_json TEXT NOT NULL DEFAULT '[]',        -- earlier plans, newest first
  audit_json        TEXT NOT NULL DEFAULT '{}',        -- what a search shows, the three fixes
  intro_json        TEXT NOT NULL DEFAULT '{}',        -- the two-line introduction request
  reminders_on      INTEGER NOT NULL DEFAULT 1,
  updated_at        TEXT
);

CREATE TABLE IF NOT EXISTS cm_evidence (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  email      TEXT NOT NULL,
  on_date    TEXT NOT NULL DEFAULT '',
  what       TEXT NOT NULL,
  link       TEXT NOT NULL DEFAULT '',
  block      TEXT NOT NULL DEFAULT '',                 -- the session or place it came from (DO3, X1.7, a client, a job)
  created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_evidence_email ON cm_evidence (email, on_date);

CREATE TABLE IF NOT EXISTS cm_entries (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  email       TEXT NOT NULL,
  level       TEXT NOT NULL DEFAULT '',
  track       TEXT NOT NULL,
  made        TEXT NOT NULL DEFAULT '',
  easy_hard   TEXT NOT NULL DEFAULT '',
  pull        TEXT NOT NULL DEFAULT '',
  direction   TEXT NOT NULL DEFAULT '',                -- Pursue | Stay curious | Not my path
  action      TEXT NOT NULL DEFAULT '',
  action_date TEXT NOT NULL DEFAULT '',
  created_at  TEXT,
  updated_at  TEXT
);
CREATE INDEX IF NOT EXISTS cm_entries_email ON cm_entries (email, id);

CREATE TABLE IF NOT EXISTS cm_actions (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  email      TEXT NOT NULL,
  text       TEXT NOT NULL,
  due        TEXT NOT NULL DEFAULT '',
  earning    INTEGER NOT NULL DEFAULT 0,
  done_at    TEXT,
  created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_actions_email ON cm_actions (email, done_at, due);

CREATE TABLE IF NOT EXISTS cm_leads (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  email      TEXT NOT NULL,
  col        TEXT NOT NULL DEFAULT 'local',            -- local | national | online | ai | any
  lead       TEXT NOT NULL,
  offer      TEXT NOT NULL DEFAULT '',
  route      TEXT NOT NULL DEFAULT '',
  fit        TEXT NOT NULL DEFAULT '',                 -- the honest fit note: what it pays, what it requires
  link       TEXT NOT NULL DEFAULT '',
  created_at TEXT,
  updated_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_leads_email ON cm_leads (email, col, id);

CREATE TABLE IF NOT EXISTS cm_outreach (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  email      TEXT NOT NULL,
  lead_id    INTEGER,
  kind       TEXT NOT NULL DEFAULT 'intro',            -- intro | application | follow-up
  to_name    TEXT NOT NULL DEFAULT '',                 -- first name only
  channel    TEXT NOT NULL DEFAULT '',
  message    TEXT NOT NULL DEFAULT '',
  sent_on    TEXT NOT NULL DEFAULT '',
  reply      TEXT NOT NULL DEFAULT 'none',             -- none | yes | no | silence
  reply_note TEXT NOT NULL DEFAULT '',
  next_on    TEXT NOT NULL DEFAULT '',
  created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_outreach_email ON cm_outreach (email, sent_on);

CREATE TABLE IF NOT EXISTS cm_checkins (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  email       TEXT NOT NULL,
  on_date     TEXT NOT NULL,
  status      TEXT NOT NULL,                           -- job-ft | job-pt | business | freelance | study | looking | other
  employer    TEXT NOT NULL DEFAULT '',
  role        TEXT NOT NULL DEFAULT '',
  town        TEXT NOT NULL DEFAULT '',
  hours       TEXT NOT NULL DEFAULT '',
  gigs_3m     INTEGER NOT NULL DEFAULT 0,
  platforms   TEXT NOT NULL DEFAULT '',
  income_band TEXT NOT NULL DEFAULT '',                -- a band, never a figure; optional
  how_found   TEXT NOT NULL DEFAULT '',
  note        TEXT NOT NULL DEFAULT '',
  consent     INTEGER NOT NULL DEFAULT 0,              -- Learning Lions may use this, without the name, in its reports
  created_at  TEXT
);
CREATE INDEX IF NOT EXISTS cm_checkins_email ON cm_checkins (email, on_date);

CREATE TABLE IF NOT EXISTS cm_profiles (
  email          TEXT PRIMARY KEY,
  cv_text        TEXT,
  cv_filename    TEXT,
  cv_uploaded_at TEXT,
  answers_json   TEXT NOT NULL DEFAULT '{}',
  prefs_json     TEXT NOT NULL DEFAULT '{}',
  analysis_json  TEXT,
  improved_cv    TEXT,
  previous_cv    TEXT,                                 -- for Undo
  game_plan      TEXT,
  last_change    TEXT,
  cv_version     INTEGER NOT NULL DEFAULT 1,
  updated_at     TEXT
);

CREATE TABLE IF NOT EXISTS cm_versions (
  id                INTEGER PRIMARY KEY AUTOINCREMENT,
  email             TEXT NOT NULL,
  name              TEXT NOT NULL,
  brief             TEXT NOT NULL DEFAULT '',
  posting           TEXT NOT NULL DEFAULT '',
  markdown          TEXT,
  previous_markdown TEXT,
  last_change       TEXT,
  version           INTEGER NOT NULL DEFAULT 1,
  created_at        TEXT,
  updated_at        TEXT
);
CREATE INDEX IF NOT EXISTS cm_versions_email ON cm_versions (email, id);

CREATE TABLE IF NOT EXISTS worksheets (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  email         TEXT NOT NULL,                     -- the signed-in learner
  session       TEXT NOT NULL,                     -- a session id from data/sessions.json
  version       TEXT,                              -- the kit version the answers were typed against
  answers_json  TEXT NOT NULL DEFAULT '{}',        -- { field id: text | true }
  status        TEXT NOT NULL DEFAULT 'draft',     -- draft | submitted
  submission_id INTEGER,                           -- the submissions row of the latest hand-in
  created_at    TEXT NOT NULL,
  updated_at    TEXT NOT NULL,
  submitted_at  TEXT
);
CREATE UNIQUE INDEX worksheets_owner ON worksheets (email, session);

CREATE TABLE IF NOT EXISTS plans (
  team          TEXT PRIMARY KEY,         -- 'bootcamp': the BootCamp trainer team
  plan_json     TEXT NOT NULL,            -- { v, capacity: { am, pm }, pools: { "week-day-slot": [session ids] }, tray: [ids], hours: { id: hours } }
  previous_json TEXT,                     -- the plan before the last save, for one undo
  updated_at    TEXT NOT NULL,
  updated_by    TEXT NOT NULL             -- the email of whoever saved last
);

CREATE TABLE IF NOT EXISTS cohorts (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  name          TEXT NOT NULL,                      -- "Cohort 1, Lodwar"
  level         TEXT NOT NULL DEFAULT 'bootcamp',
  start_date    TEXT NOT NULL,                      -- the Monday the cohort starts, YYYY-MM-DD
  holidays_json TEXT NOT NULL DEFAULT '[]',         -- [{ "date": "2026-10-20", "name": "Mashujaa Day" }]
  active        INTEGER NOT NULL DEFAULT 0,         -- 1 on the one cohort /calendar/ shows by default
  created_at    TEXT NOT NULL,
  created_by    TEXT NOT NULL,
  updated_at    TEXT NOT NULL
, gcal_id TEXT, gcal_name TEXT, gcal_synced_at TEXT);

CREATE TABLE IF NOT EXISTS walks (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  email       TEXT NOT NULL,                      -- the signed-in person
  session     TEXT NOT NULL,                      -- a session id from data/sessions.json
  kind        TEXT NOT NULL,                      -- take | teach
  slide       INTEGER NOT NULL DEFAULT 1,         -- the stage reached, 1-based (the slides, then the sheets and the hand-in)
  count       INTEGER,                            -- how many stages the walk had when last saved
  ticks_json  TEXT NOT NULL DEFAULT '[]',         -- take: the step ids ticked done ("<sheet id>:<row>"); teach: the slide numbers practised
  notes_json  TEXT NOT NULL DEFAULT '{}',         -- teach: { slide number: the trainer's note to self }
  status      TEXT NOT NULL DEFAULT 'started',    -- started | done
  created_at  TEXT NOT NULL,
  updated_at  TEXT NOT NULL
);
CREATE UNIQUE INDEX walks_owner ON walks (email, session, kind);

Running It Locally

  1. git clone, then npm run build (the prompt linter, the schema check, the build, the output check) and npm run serve on port 8787. The local server stands in for sign-in and the database with one preview admin and a few made-up rows, so every page can be looked at.
  2. The output check refuses a Cloudflare preview address, the kits' all-capitals marker for an unsettled detail, filler Latin text, and a clock time on a learner page; the banned list is bannedInOutput in hub.config.json.
  3. The repo is github.com/Learning-Lions-Arizona/learninglions-hub; the README has the Cloudflare setup, the variables and the house rules.