Flag overnight blocks in the v2 schedule API

This commit is contained in:
NeilClack
2026-09-11 18:24:51 -05:00
parent 750a9286c9
commit 177b7bb13a
4 changed files with 55 additions and 15 deletions
+4 -2
View File
@@ -107,8 +107,10 @@ Authenticates with the paired `email` + `licenseKey` query parameters and
validates only that the key belongs to the account (not that the license is validates only that the key belongs to the account (not that the license is
active/entitled). Returns the user's Blocks with start/end times and the IDs of active/entitled). Returns the user's Blocks with start/end times and the IDs of
the selected Proclamations (content is fetched separately via the selected Proclamations (content is fetched separately via
`/api/v2/proclamations`). Rate limited to 10 requests per minute per IP. `/api/v2/proclamations`). Overnight blocks (end clock time before start) are
Failure messages follow the existing v2 shape: supported and flagged with `overnight: true` plus a wrapping `durationMinutes`.
Rate limited to 10 requests per minute per IP. Failure messages follow the
existing v2 shape:
`not-authorized` or `this key doesn't appear to belong to you`. `not-authorized` or `this key doesn't appear to belong to you`.
--- ---
+21 -2
View File
@@ -245,7 +245,9 @@ paths:
pairing. pairing.
Returns every Block with its start/end times and the ordered list of Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs. Proclamation content is not included; the selected Proclamation IDs. Overnight blocks (an end clock time before
the start) are supported and flagged with `overnight: true` and a
wrapping `durationMinutes`. Proclamation content is not included; the
kiosk fetches it separately via `/api/v2/proclamations`. kiosk fetches it separately via `/api/v2/proclamations`.
operationId: getSchedule operationId: getSchedule
parameters: parameters:
@@ -498,7 +500,17 @@ components:
format: email format: email
ScheduleBlock: ScheduleBlock:
type: object type: object
required: [id, startTime, startMinutes, endTime, endMinutes, proclamationIds] required:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
proclamationIds
]
properties: properties:
id: id:
type: string type: string
@@ -521,6 +533,13 @@ components:
minimum: 0 minimum: 0
maximum: 1440 maximum: 1440
description: Minutes from midnight. description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
proclamationIds: proclamationIds:
type: array type: array
description: IDs of the selected Proclamations, in play order. description: IDs of the selected Proclamations, in play order.
+8 -2
View File
@@ -1,4 +1,4 @@
import { toHm } from '$lib/schedule-utils'; import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
@@ -147,6 +147,8 @@ export type V2ScheduleBlock = {
startMinutes: number; startMinutes: number;
endTime: string; endTime: string;
endMinutes: number; endMinutes: number;
overnight: boolean;
durationMinutes: number;
proclamationIds: string[]; proclamationIds: string[];
}; };
@@ -156,7 +158,9 @@ export type V2Schedule = {
}; };
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are /** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
* returned; the kiosk fetches proclamation content separately. */ * returned; the kiosk fetches proclamation content separately. `overnight`
* marks a range that crosses midnight (end clock time before start), and
* `durationMinutes` counts the wrap as the next day. */
export function serializeScheduleBlock( export function serializeScheduleBlock(
block: { id: string; startMinutes: number; endMinutes: number }, block: { id: string; startMinutes: number; endMinutes: number },
proclamationIds: string[] proclamationIds: string[]
@@ -167,6 +171,8 @@ export function serializeScheduleBlock(
startMinutes: block.startMinutes, startMinutes: block.startMinutes,
endTime: toHm(block.endMinutes), endTime: toHm(block.endMinutes),
endMinutes: block.endMinutes, endMinutes: block.endMinutes,
overnight: isOvernight(block),
durationMinutes: blockDuration(block),
proclamationIds proclamationIds
}; };
} }
+22 -9
View File
@@ -86,7 +86,7 @@ describe('GET /api/v2/schedule auth', () => {
}); });
describe('GET /api/v2/schedule success', () => { describe('GET /api/v2/schedule success', () => {
it('returns only proclamation IDs even when the key is inactive', async () => { it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
licenseKeyFindUnique.mockResolvedValue({ licenseKeyFindUnique.mockResolvedValue({
key: KEY, key: KEY,
userId: 'user-1', userId: 'user-1',
@@ -97,8 +97,8 @@ describe('GET /api/v2/schedule success', () => {
blockFindMany.mockResolvedValue([ blockFindMany.mockResolvedValue([
{ {
id: 'block-1', id: 'block-1',
startMinutes: 360, startMinutes: 21 * 60,
endMinutes: 480, endMinutes: 9 * 60,
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }] proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
} }
]); ]);
@@ -111,10 +111,12 @@ describe('GET /api/v2/schedule success', () => {
blocks: [ blocks: [
{ {
id: 'block-1', id: 'block-1',
startTime: '06:00', startTime: '21:00',
startMinutes: 360, startMinutes: 1260,
endTime: '08:00', endTime: '09:00',
endMinutes: 480, endMinutes: 540,
overnight: true,
durationMinutes: 720,
proclamationIds: ['p1', 'p2'] proclamationIds: ['p1', 'p2']
} }
] ]
@@ -134,9 +136,20 @@ describe('GET /api/v2/schedule success', () => {
]); ]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY }); const response = await call({ email: 'jane@example.com', licenseKey: KEY });
const body = (await response.json()) as { blocks: { proclamationIds: string[] }[] }; const body = (await response.json()) as {
blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[];
};
expect(body.blocks[0].proclamationIds).toEqual(['p1']); expect(body.blocks[0]).toEqual({
id: 'block-1',
startTime: '00:00',
startMinutes: 0,
endTime: '01:00',
endMinutes: 60,
overnight: false,
durationMinutes: 60,
proclamationIds: ['p1']
});
}); });
it('accepts the legacy `key` query alias and an email-only license', async () => { it('accepts the legacy `key` query alias and an email-only license', async () => {