feat(events): schedule, recurrence and the calendar (Phase 4)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s

The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:16 -05:00
parent a481248bc0
commit 6e73660b52
30 changed files with 3722 additions and 77 deletions

View File

@@ -3534,6 +3534,174 @@
}
}
},
"/api/v1/admin/events/calendar": {
"get": {
"tags": [
"Admin · Events"
],
"summary": "The calendar for a window: materialised runs and projected occurrences",
"description": "Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.",
"parameters": [
{
"name": "from",
"in": "query",
"description": "Window start, a UTC instant",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "to",
"in": "query",
"description": "Window end, a UTC instant. At most 92 days after from",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "status",
"in": "query",
"description": "Only runs in this status; suppresses projections",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "scope",
"in": "query",
"description": "Only runs at this scope; suppresses projections",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "seriesId",
"in": "query",
"description": "Only events belonging to this series",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "The window",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"window": {
"type": "object",
"additionalProperties": true
},
"horizon": {
"type": "string"
},
"horizonDays": {
"type": "integer"
},
"truncated": {
"type": "boolean"
},
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string"
},
"runId": {
"type": "integer",
"nullable": true
},
"definitionId": {
"type": "integer"
},
"title": {
"type": "string"
},
"slug": {
"type": "string"
},
"seriesName": {
"type": "string",
"nullable": true
},
"scheduledFor": {
"type": "string"
},
"timezone": {
"type": "string"
},
"scope": {
"type": "string"
},
"status": {
"type": "string",
"nullable": true
},
"health": {
"type": "string",
"nullable": true
},
"adjusted": {
"type": "string",
"nullable": true
}
}
}
}
}
}
}
}
},
"400": {
"description": "The window is missing, inverted or wider than 92 days",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"403": {
"description": "Not staff",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/events/catalog": {
"get": {
"tags": [
@@ -4431,7 +4599,7 @@
"Admin · Events"
],
"summary": "List the event series a definition may belong to",
"description": "A series is the arc several definitions form together. Read-only in this phase: creating and ordering one arrives with the calendar.",
"description": "A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.",
"responses": {
"200": {
"description": "The series",
@@ -4488,6 +4656,265 @@
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Events"
],
"summary": "Create an event series",
"description": "Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.",
"responses": {
"201": {
"description": "The created series",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"series": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"403": {
"description": "Not an admin or editor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"ordering": {
"type": "integer"
}
},
"required": [
"name"
]
}
}
}
}
}
},
"/api/v1/admin/events/series/{seriesId}": {
"put": {
"tags": [
"Admin · Events"
],
"summary": "Rename or reorder an event series",
"description": "The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.",
"parameters": [
{
"name": "seriesId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The updated series",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"series": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"400": {
"description": "Validation failed",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"403": {
"description": "Not an admin or editor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such series",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"ordering": {
"type": "integer"
}
},
"required": [
"name"
]
}
}
}
}
},
"delete": {
"tags": [
"Admin · Events"
],
"summary": "Delete an event series, detaching whatever belonged to it",
"description": "A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.",
"parameters": [
{
"name": "seriesId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Deleted; detached is how many definitions lost their series",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"detached": {
"type": "integer"
}
}
}
}
}
},
"403": {
"description": "Not an admin or editor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such series",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/events/{id}": {
@@ -4755,7 +5182,7 @@
"Admin · Events"
],
"summary": "Snapshot the working spec into an immutable version and mark the definition ready",
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch.",
"description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.",
"parameters": [
{
"name": "id",
@@ -4768,7 +5195,7 @@
],
"responses": {
"200": {
"description": "The definition, now ready, and the version that was cut",
"description": "The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it",
"content": {
"application/json": {
"schema": {
@@ -4783,6 +5210,9 @@
},
"versionId": {
"type": "integer"
},
"repinned": {
"type": "integer"
}
}
}