Files
portal/docs/schedule-api-contract.md
T

29 KiB
Raw Blame History

Schedule API & Data Contract

Status: Phase 0 (contract only — no application code). This document is the authoritative API/data contract for the multi-phase scheduler overhaul. It is kept byte-identical in both repositories:

  • radiantwave (C++/SDL3 Kiosk app) — docs/schedule-api-contract.md
  • portal (SvelteKit/Node) — docs/schedule-api-contract.md

Any change is applied to both copies in the same step, with a dated line appended to the Changelog.

Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule, License Key, System Name) are used with the meanings defined in the scheduler overhaul brief.


Current state

This section reflects the code as it exists at the start of Phase 0. It is descriptive, not aspirational; the target behaviour is defined in the later sections.

Kiosk (radiantwave)

  • License validation. POST /api/v2/license with body {"license_key": "...", "email_address": "..."}. Implemented in src/network/api_client.cpp (validateLicense, parseLicenseJson) and consumed by src/network/license_service.cpp (validateStoredLicense, markLicenseVerified, isLicenseWithinGracePeriod). A 2xx response is the device's "subscribed" flag; a 7-day offline grace window is stored as license_last_verified in system_kv (src/db/db_keys.h). Response body currently read: {key, email_address}; additional fields are ignored.
  • Schedule fetch. GET /api/v2/schedule?email=<email>&licenseKey=<key> (src/network/api_client.cpp: fetchSchedule, parseScheduleJson; PATH_SCHEDULE = "/schedule"). Response shape read by the Kiosk: {email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}]}. dynamicAudio is optional on the wire and defaults to true in parseScheduleJson. Missing/blank credentials cause an early false.
  • Schedule persistence. SQLite tables schedule_blocks and schedule_block_proclamations (src/db/database_manager.cpp:185, replaceScheduleBlocks at :1105, getScheduleBlocks at :1169). Full replace; block sort_order and per-block proclamation position preserved. No day-of-week column and no schedule type; the model is implicitly daily.
  • Schedule sync. syncScheduleFromApi (src/network/proclamation_sync.cpp:172) is invoked only as a best-effort side effect of syncProclamationsFromApi (:168). There is no boot sync, no network-up sync, no periodic timer, and no conditional GET (If-None-Match/ETag). syncProclamationsFromApi is called from Home::syncProclamationsIfEmpty (src/home.cpp:461) and ProclamationOptionsPage::onPush (src/proclamation_options_page.cpp:49). There is no explicit atomic schedule cache file; the SQLite database is the durable store.
  • Schedule playback. src/scroller_page.cpp selects the active block by comparing timezone::currentLocalMinutes(cfg.timezone) (src/timezone_helper.h) against each block via scroller::minuteInScheduleBlock (src/scroller_streams.h:31). Block selection is daily-only (no day-of-week). An active block's ordered proclamation_ids replace the local selection (src/scroller_streams.cpp:57). effectiveDynamicAudio() (src/scroller_page.cpp:302) forces dynamic audio when the active block has dynamic_audio == true, otherwise the device-wide config.audio.dynamic_audio applies. A block change is latched in scheduleChangePending_ and applied at the next proclamation boundary.
  • Settings persistence. ~/.local/radiantwave/config.yaml (YAML) via ConfigManager (src/config/config_manager.cpp, src/config/yaml_parser.cpp, schema in src/config/app_config.h): timezone, audio.dynamic_audio, proclamation.schedule_active (Kiosk-local on/off toggle, default false). Identity lives in SQLite system_kv (email_address, license_key, license_last_verified). There is no System Name anywhere.
  • Conventions. API_BASE_URL = "https://portal.radiantwavetech.com/api/v2" (src/network/api_client.h). Kiosk API knowledge is mirrored in openapi.yaml at the repo root.

Portal (portal)

  • Framework/tooling. SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4; Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate (prisma/schema.prisma, prisma/migrations/, npm run migrate, npm run deploy; config in prisma.config.ts). API routes under src/routes/api/v1/ (API-key gated) and src/routes/api/v2/ (unauthenticated). v2 helpers: src/lib/server/v2-helpers.ts (jsonOk, jsonError, readJsonBody, rateLimited, clientIp); errors are { "message": "<text>" }.
  • License validation. POST /api/v2/license (src/routes/api/v2/license/+server.ts): validates license_key + email_address, requires the key be active and owned by the account, stamps LicenseKey.lastValidatedAt, returns serializeLicense({key, emailAddress}) (src/lib/server/v2-serializers.ts). A v1 variant exists (src/routes/api/v1/license/+server.ts).
  • Schedule storage. Schedule is per user (userId String @unique, prisma/schema.prisma:263), daily-only, 0..4 Blocks. Block (:275) stores startMinutes/endMinutes, dynamicAudio Boolean @default(false), and ordered BlockProclamations (sortOrder). The schema comment states overnight ranges are disallowed in v1, yet the serializer and Kiosk both support overnight — a known inconsistency.
  • Schedule service/validation. src/lib/server/schedule.ts (DB orchestration: getOrCreateSchedule, listBlocks, createBlock, updateBlock, deleteBlock) and the isomorphic src/lib/schedule-utils.ts (MAX_BLOCKS = 4, SNAP_MINUTES = 15, validateBlock, findOverlap, isOvernight, blockDuration, blockSegments, formatTime, toHm, snapMinutes). Validation enforces a 4-block maximum on create, startMinutes !== endMinutes, at least one Proclamation, and no overlaps (overnight-aware via linear segments).
  • Schedule read API. GET /api/v2/schedule (src/routes/api/v2/schedule/+server.ts): auth by paired email + licenseKey (legacy alias key) query params; checks only that the key belongs to the email's account (entitlement deliberately not checked); rate limited 10/min/IP; returns {email, blocks: [serializeScheduleBlock(...)]} where serializeScheduleBlock (src/lib/server/v2-serializers.ts:165) emits {id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}.
  • Schedule UI. Protected user route src/routes/(protected)/(user)/schedule/ (+page.server.ts, +page.svelte) with src/lib/components/schedule/DaySchedule.svelte (single 24h day timeline, drag-to-create) and BlockModal.svelte. Dashboard ((protected)/(user)/dashboard/) lists the user's License Keys. No System Name is displayed or editable.

Gaps between current state and target

  • Schedule is per-user, not per-License-Key.
  • No daily/weekly typing; no day-of-week storage or UI.
  • No version/ETag, no conditional GET.
  • Schedule GET requires email + licenseKey; there is no license-key-only route, no /api/v2/licenses/... namespace, and no system-name route.
  • dynamicAudio DB default is false; the target default for new Blocks is true.
  • No System Name field, validation, or display.
  • Sync is opportunistic with no boot/network-up/5-minute cadence and no atomic cache file.
  • Portal stores no timezone (matches target) and shows no informational geoip-derived timezone.

Time semantics

  • Times are minute precision, stored as 24-hour "HH:MM" strings representing local wall-clock time. AM/PM is display-only.
  • Numerators may also be carried as integer minutes-from-midnight (startMinutes/endMinutes, 0..1440) for the Kiosk; the string form is canonical on the wire.
  • A Block is the half-open interval [start, end).
  • A Block may start on the same minute the previous Block ends (end 07:00, next start 07:00 is valid and has no gap).
  • A Block whose end is earlier than its start crosses midnight and belongs to its start day. In weekly mode, Sunday's crossing Block spills into Monday. start == end is invalid (zero- or full-length ambiguity).
  • Overlaps are invalid, including spillover from a midnight-crossing Block into the next day's Blocks (or, in daily mode, into the same list's early Blocks). Touching boundaries are not overlaps.
  • Gaps between Blocks are allowed. During a gap the Kiosk plays whatever Proclamations are selected locally on the Kiosk.
  • The Kiosk evaluates Schedules in its own Timezone setting (config.timezone, IANA code). The payload carries no timezone. The Portal may show the user's geoip-derived timezone informationally only.

Schedule data model (JSON)

Each License Key has at most one Schedule. type is "daily" or "weekly" and determines the mode; there is no separate global mode. No Schedule means type is null.

Suggested shape from the brief, adapted to existing field names (start/end → startTime/endTime, retaining the existing startMinutes/endMinutes integers for the Kiosk):

{
  "version": "<opaque string; also used as the ETag>",
  "type": "daily" | "weekly" | null,
  "daily": [ Block, ... ],
  "weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
              "thu": [Block], "fri": [Block], "sat": [Block],
              "sun": [Block] },
  "blocks": [ LegacyBlock, ... ]
}

Block:

{
	"id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f",
	"startTime": "21:00",
	"startMinutes": 1260,
	"endTime": "09:00",
	"endMinutes": 540,
	"overnight": true,
	"durationMinutes": 720,
	"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
	"dynamicAudio": true
}
  • version is an opaque string derived from the Schedule content (see Endpoints). It is implementation-defined and must change whenever the serialized schedule changes.
  • Only the fields for the active type are populated. For type: "daily", daily is populated and weekly is omitted or empty. For type: "weekly", weekly is populated with all seven keys and daily is omitted or empty. For type: null, both are empty/omitted.
  • weekly always contains all seven lowercase keys mon..sun; a day with no Blocks is an empty array.
  • blocks is the legacy flattening retained for older Kiosks (see Backward compatibility). It uses the existing LegacyBlock shape serialized by the current portal ({id, startTime, startMinutes, endTime, endMinutes, overnight, durationMinutes, dynamicAudio, proclamationIds}). For type: "weekly", blocks is an empty array (the server does not know the Kiosk's timezone and cannot flatten weekly to a single day).
  • proclamationIds is the ordered play list (order is significant); duplicates are not meaningful and are de-duplicated by the Portal.
  • dynamicAudio defaults to true for new Blocks created on the Portal.
  • Proclamation content is never included; the Kiosk fetches content separately via /api/v2/proclamations. Portal users only ever see title/category/group.

Validation rules

These are enforced by the Portal (authoritative) and defended against by the Kiosk.

  1. A Schedule belongs to exactly one License Key; a License Key has at most one Schedule.
  2. type is one of "daily", "weekly", or null.
  3. A Block has start and end times; start == end is invalid.
  4. end < start denotes a midnight-crossing Block belonging to its start day.
  5. Blocks within a day (and the spillover of a midnight-crossing Block into the following day) must not overlap. Touching boundaries are allowed.
  6. Weekly modes apply rules 3–5 independently per day; a sun Block crossing midnight must not overlap mon early Blocks.
  7. A Block must contain at least one Proclamation. Empty Blocks are rejected server-side. The Kiosk defensively skips invalid Blocks and treats them as gaps.
  8. Gaps are allowed; during a gap local Kiosk selection applies.
  9. dynamicAudio is a boolean; absent means true for new Blocks.
  10. Times are minute-precision HH:MM strings; the Portal snaps UI input to SNAP_MINUTES (15) with a MINUTE_STEP (5) constraint from the existing helpers.
  11. The existing Portal cap of MAX_BLOCKS = 4 per day list is retained for Phase 0 (see Open items).

The Kiosk must not crash on, and must not play, an invalid Block: it skips it.


Endpoints

The API remains unauthenticated (no API keys) for v2. This is a known limitation and is recorded under Open items.

All new endpoints live under the existing versioned namespace /api/v2/... to preserve conventions. v1 routes are unchanged.

(a) License validation — existing, additive

POST /api/v2/license

Request (existing fields unchanged; systemName additive and optional):

{
	"license_key": "ABCD-EFGH-IJKL-MNOP",
	"email_address": "jane@example.com",
	"systemName": "Living Room"
}

Response (existing fields unchanged; systemNameStatus additive):

{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
  • systemName is optional. When present it is trimmed; when absent the stored name is left untouched.
  • systemNameStatus ∈ {"ok", "unchanged", "conflict", "invalid"}:
    • ok — name was set or updated.
    • unchanged — omitted, or identical after trim/case-insensitive compare.
    • conflict — another License Key on the same account already holds the name.
    • invalid — trimmed length outside 1–40.
  • Validation itself succeeds regardless of name status. A non-ok status never fails the license check; the Kiosk treats it as advisory.
  • Errors and rate limiting follow the existing endpoint (400 messages, 413 body cap, 429 with Retry-After).

(b) Set System Name — Kiosk-facing

PUT /api/v2/licenses/{licenseKey}/system-name

Request:

{ "systemName": "Living Room" }

Responses:

Status Condition Body
200 Name set/updated (or unchanged) {"systemName": "...", "systemNameStatus": "ok"}
400 invalid_name Trimmed length outside 1–40 {"message": "invalid_name"}
404 unknown_license No License Key matches {"message": "unknown_license"}
409 name_taken Another key on the account holds the name {"message": "name_taken"}
  • The Kiosk is the only writer. The Portal has no rename capability.
  • The response body uses the existing v2 error convention ({"message": "<code>"}).
  • Rate limited at the existing 10 requests/min/IP convention.

(c) Get Schedule — Kiosk-facing, key-only, conditional

GET /api/v2/licenses/{licenseKey}/schedule

  • 200 with ETag: "<version>" and the payload from Schedule data model. A License Key with no Schedule returns 200 with type: null and empty lists.
  • 304 Not Modified when the request's If-None-Match matches the current ETag.
  • 404 (unknown_license) when no License Key matches.
  • version doubles as the ETag value; the header value is the quoted form.
  • The response is the target payload (version, type, daily, weekly, blocks).
  • Rate limited at the existing 10 requests/min/IP convention.

(d) Legacy schedule read — existing, evolved additively

GET /api/v2/schedule?email=<email>&licenseKey=<key> remains supported with its existing auth semantics. Its response is extended additively with the same version, type, daily, and weekly fields, while continuing to populate email and blocks exactly as before. Old Kiosks ignore the new fields; new Kiosks may read either endpoint. This endpoint does not emit an ETag and does not return 304.

(e) Portal-internal endpoints (not Kiosk-facing)

These are session-authenticated Portal actions; they are not part of the Kiosk API and are not served by /api/v2/....

  • Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on (protected)/(user)/schedule (?/setType, ?/createBlock, ?/updateBlock, ?/deleteBlock), backed by $lib/server/schedule.ts.
  • Copy — implemented in Phase 4 as the ?/copy action plus copySchedule() in $lib/server/schedule.ts. Copies one License Key's Schedule to one or more other License Keys by value, in a single database transaction (all targets succeed or none). The target type becomes the source's; Block rows get fresh IDs; each block's dynamicAudio and ordered Proclamation references are preserved. The source and every target must belong to the caller's account; a target equal to the source, an empty target list, or a source without a schedule are rejected. The resulting schedule is run through the shared validator before commit. Because Block IDs are part of the serialized payload, every target's content-derived version/ETag changes, so its Kiosk refetches.

Sync behavior

Target Kiosk sync (defaults; adjust only if the code forces it):

  • The Kiosk pulls the Schedule:
    • on boot,
    • on network-up,
    • every 5 minutes.
  • Pulls use conditional GET (If-None-Match / ETag); a 304 leaves the cache untouched.
  • The last Schedule is cached to disk with an atomic write and used offline.
  • The schedule on/off toggle is Kiosk-local (existing proclamation.schedule_active) and is not synced.
  • With no cache and no network, the Kiosk behaves as if schedules are off (local Proclamation selection applies).
  • During gaps, or when schedules are off, the local audio selection applies.

Current-state deltas (to be closed by later phases): no boot/network-up/5-minute cadence, no ETag, no explicit atomic cache file. These are recorded under Touchpoints per phase.

Identity for sync is the License Key. The legacy schedule endpoint additionally requires the paired email; the new endpoint uses the License Key alone.


System Name rules

  • A System Name is a human-readable label for a License Key (e.g. "Living Room"). It is a label only — never used as a reference or key.
  • Unique per account, compared case-insensitively.
  • Trimmed, 1–40 characters.
  • Set or changed only via the Kiosk. The Portal displays it read-only and has no rename capability.
  • The Portal falls back to displaying the License Key when no name is set.
  • A Kiosk that omits systemName never clears an existing name.
  • Wire representations: request field systemName; status field systemNameStatus ∈ {"ok","unchanged","conflict","invalid"} (see Endpoints).

Dynamic audio

  • Each Block has a boolean dynamicAudio.
  • The Portal defaults it to true for new Blocks.
  • true ⇒ the Kiosk uses dynamic audio for that Block.
  • false ⇒ the Kiosk uses its locally chosen audio.
  • During gaps, or when schedules are off, the local audio selection applies (existing config.audio.dynamic_audio / fixed track).
  • Kiosk parsing defaults an absent dynamicAudio to true.

Backward compatibility

All existing endpoints and payloads keep working for Kiosks already in the field. New fields are additive; no v2 field is removed or renamed, and v1 is untouched.

  • Old Kiosk vs new Portal. An old Kiosk calls POST /api/v2/license (no systemName) and GET /api/v2/schedule?email&licenseKey, reading blocks and ignoring unknown fields. The Portal keeps both, keeps the blocks shape byte- compatible, and adds version/type/daily/weekly as extra fields. The old Kiosk is unaffected and ignores systemNameStatus.
  • Daily schedules. For type: "daily", blocks is the flattened daily list; an old Kiosk behaves exactly as today.
  • Weekly schedules. blocks is an empty array. An old Kiosk therefore sees no schedule and falls back to local Proclamation/audio selection. It cannot interpret weekly schedules; this is accepted and recorded under Open items.
  • No schedule. type: null, blocks: []; both old and new Kiosks fall back to local selection.
  • Omission of systemName by an old Kiosk never clears an existing name; systemNameStatus is simply ignored.
  • The new endpoints are additive; their absence on old Kiosks is irrelevant.

Touchpoints per phase

File/module candidates each later phase will change. Phase numbering is assumed from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy) and is recorded under Open items.

Kiosk (radiantwave)

  • Phase 1 (Kiosk scheduler UI, storage, sync, playback)
    • src/network/api_client.h / src/network/api_client.cpp — parse version/type/daily/weekly, ETag/If-None-Match, PUT system-name, systemNameStatus.
    • src/network/proclamation_sync.h / .cpp — boot/network-up/5-minute schedule sync, conditional GET, atomic cache.
    • src/network/license_service.h / .cpp — send systemName, handle systemNameStatus.
    • src/db/db_keys.h — System Name / ETag keys; src/db/database_manager.h / .cpp — schedule type/day-of-week storage, cache.
    • src/scroller_page.h / .cpp, src/scroller_streams.h / .cpp — day-of-week Block selection, spillover.
    • src/timezone_helper.h — local day-of-week.
    • src/config/app_config.h, src/config/config_manager.cpp, src/config/yaml_parser.cpp — settings surface.
    • New page(s) for setting the System Name; tests in tests/ registered in CMakeLists.txt.

Portal (portal)

  • Phase 2 (System Name + license validation)
    • prisma/schema.prisma + migration — LicenseKey.systemName and case-insensitive uniqueness.
    • src/routes/api/v2/license/+server.ts — systemName / systemNameStatus.
    • src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts — new.
    • src/lib/server/v2-serializers.ts, src/lib/server/v2-helpers.ts.
    • Dashboard read-only display.
  • Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)
    • prisma/schema.prisma + migration — Schedule per License Key, type, day-of-week.
    • src/lib/server/schedule.ts, src/lib/schedule-utils.ts — weekly, per-license orchestration, cross-day validation.
    • src/lib/server/v2-serializers.ts — version/type/daily/weekly.
    • src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts — new (ETag/304).
    • src/routes/api/v2/schedule/+server.ts — additive evolution.
    • src/routes/(protected)/(user)/schedule/*, src/lib/components/schedule/* — weekly UI, per-license selection.
    • Specs: schedule.spec.ts, page.server.spec.ts, schedule-utils.spec.ts.
  • Phase 4 (Schedule copy)
    • Copy service in src/lib/server/schedule.ts; copy route/action; UI.
  • All phases — docs/openapi.yaml, this contract, docs/fixtures/.

Open items

Recorded ambiguities and conservative choices. Each is subject to change in a later phase.

  1. Phase numbering. The brief names Phases 3 (CRUD) and 4 (copy) only; Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
  2. blocks for weekly. Chosen as an empty array (server cannot know the Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no schedule". Implemented in Phase 3.
  3. Block cap. The existing MAX_BLOCKS = 4 per day list is retained. Phase 3 applies it per day list (each of the seven weekly days gets its own 4-block budget). Whether the cap should be lower is unresolved.
  4. Field naming. The brief's start/end were mapped to the existing startTime/endTime; startMinutes/endMinutes retained for the Kiosk.
  5. version derivation. Resolved in Phase 3: a SHA-1 content hash of the serialized {type, daily, weekly} shape. Stable for equal content, changes on any mutation.
  6. Unauthenticated key-only access. The new endpoints authenticate by License Key alone (per glossary). Anyone who knows a key could read the schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known limitation.
  7. dynamicAudio default flip. DB default changed false → true for new Blocks in Phase 3; existing Blocks keep their stored values.
  8. Per-user → per-License-Key migration. Resolved in Phase 3: each existing per-user Schedule is mapped to that account's oldest License Key (createdAt ASC), as a daily schedule. Schedules with no owning user/license are retained with licenseKeyId NULL and reported via a migration NOTICE (not dropped).
  9. Cross-day spillover validation. Implemented in Phase 3: weekly sun→mon and daily self-wrap spillover are rejected as overlaps; the shared validateSchedule is authoritative and mirrored client-side.
  10. Account scope for name uniqueness. "Per account" = the owning User. Legacy License Keys with only an email and no userId are an edge case (written without a per-account uniqueness check).
  11. Geoip timezone display. Phase 3 shows an informational note that times run in the Kiosk's timezone and uses the browser-resolved timezone as a fallback; no geoip dependency (paid or external) was added. The Portal still stores no timezone.
  12. ETag on the legacy endpoint. The legacy GET /api/v2/schedule emits no ETag and never returns 304, to preserve old behaviour.
  13. systemNameStatus on v1. Not added; v1 is unchanged.
  14. Trim/case representation. Names are trimmed; uniqueness is case-insensitive; the stored casing is preserved as provided.
  15. Sync cadence. Resolved in Phase 3: the Kiosk loads its on-disk cache on boot, then pulls on boot, on a 5-minute timer, and on demand.
  16. Atomic cache file. Resolved in Phase 3: ~/.local/radiantwave/schedule_cache.json, written atomically (temp file + rename); a corrupt cache is ignored.
  17. type encoding for no schedule. type: null; daily/weekly absent or empty.
  18. Legacy alias. key remains accepted as a licenseKey alias on the legacy schedule endpoint.
  19. Kiosk local toggle. proclamation.schedule_enabled in config.yaml, default on, surfaced as System Settings → Schedule. Not synced. When off, the engine plays local selections but the cache keeps updating. The older proclamation.schedule_active flag is retained in config but is not consulted by the Phase 3 engine.
  20. Copy semantics. Copies are independent, by value snapshots: no link is kept to the source, and editing the source later does not affect targets (and vice versa). A copy replaces any existing target Schedule in full (type and blocks) and requires explicit confirmation in the UI when a target already has one. No "copy to all" shortcut and no scheduled/linked copies (per the Phase 4 brief). The copied schedule is validated before commit; a source schedule that fails validation is rejected rather than copied.
  21. Copy and Kiosk code. Verified against the Phase 3 Kiosk engine/fixture tests that a copied payload (new Block IDs, same type/times/audio/refs) is parsed and evaluated identically to any other; no Kiosk changes were required.

Changelog

  • 2026-09-29 — Initial contract created (Phase 0). Documents current state, target time semantics, schedule data model, validation rules, endpoints (/api/v2/licenses/..., additive license-validation fields), sync behavior, System Name rules, dynamic audio, backward compatibility, touchpoints, open items, and fixtures.
  • 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and Block.day; the shared validateSchedule is authoritative; the key-only GET /api/v2/licenses/{licenseKey}/schedule (ETag/304) ships and the legacy endpoint is extended additively; the Portal editor is per-system with a browser-fallback timezone note; the Kiosk adds the conditional client, atomic cache, schedule engine, and a System Settings On/Off toggle. Open items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes.
  • 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal action (?/copy) backed by a single-transaction service that copies by value with fresh Block IDs (so target ETags change), preserving dynamicAudio and Proclamation order, scoped to the caller's account, and validated before commit. Endpoint (e) is finalized; open items 20 and 21 record the copy semantics and the Kiosk no-change verification.