Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d3237328 | ||
|
|
67f612e371 | ||
|
|
0f0adb4c60 |
@@ -323,10 +323,7 @@ below. Nothing is installed without someone clicking.
|
|||||||
session (Start ProdDash.command) and approve the one-time prompt; a server
|
session (Start ProdDash.command) and approve the one-time prompt; a server
|
||||||
started over SSH or by bare launchd silently captures zeros. (Engine
|
started over SSH or by bare launchd silently captures zeros. (Engine
|
||||||
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
|
||||||
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
- **PCO Plan** — the order of service from Planning Center Services: every
|
||||||
**Plan** (the order of service) and **Teams** (who is scheduled).
|
|
||||||
|
|
||||||
**Plan** shows every
|
|
||||||
plan item with its scheduled start time, length, type, song key,
|
plan item with its scheduled start time, length, type, song key,
|
||||||
description and item notes. When the ProPresenter module is running it
|
description and item notes. When the ProPresenter module is running it
|
||||||
follows that module's live item, highlights the matching plan item, dims
|
follows that module's live item, highlights the matching plan item, dims
|
||||||
@@ -345,17 +342,6 @@ below. Nothing is installed without someone clicking.
|
|||||||
(gear menu): the top-bar text (placeholders such as
|
(gear menu): the top-bar text (placeholders such as
|
||||||
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a
|
||||||
show/hide checklist for every detail.
|
show/hide checklist for every detail.
|
||||||
|
|
||||||
**Teams** shows the same plan's team and position assignments — each team
|
|
||||||
as a band of its people, with their photo (fetched through ProdDash, never
|
|
||||||
by the browser; initials when Planning Center has none) and a dot for
|
|
||||||
confirmed, unconfirmed or declined. Its gear menu picks which teams and
|
|
||||||
which positions appear, groups people under their position or lists them
|
|
||||||
plainly, and sets the colours: one for the team headers, or **Tint teams**
|
|
||||||
to give each team its own, optionally carried into the rows. Teams nobody
|
|
||||||
has signed up for are hidden until you ask for them — that list comes from
|
|
||||||
the service type, since Planning Center only returns a team once somebody
|
|
||||||
is on it.
|
|
||||||
- **Slack** — live transcripts of this location's Slack channels, plus
|
- **Slack** — live transcripts of this location's Slack channels, plus
|
||||||
PIN-protected sending as the location. The + picker lists one transcript
|
PIN-protected sending as the location. The + picker lists one transcript
|
||||||
tile per configured channel, titled `#channel`, and two send surfaces (one
|
tile per configured channel, titled `#channel`, and two send surfaces (one
|
||||||
|
|||||||
@@ -684,7 +684,6 @@ clock, and any module that offers some. A module offers timers by:
|
|||||||
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
|
||||||
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
|
||||||
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
|
||||||
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
|
|
||||||
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
|
||||||
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
|
||||||
| `detail` | Optional second line. |
|
| `detail` | Optional second line. |
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
/* PCO Plan — client part.
|
/* PCO Plan — client part.
|
||||||
|
|
||||||
The module presents two tiles and this is the entry for both: the Teams
|
|
||||||
tile (picker entry "teams") is built by teams-client.js, everything below
|
|
||||||
draws the Plan tile.
|
|
||||||
|
|
||||||
The server part reads Planning Center and (optionally) follows the
|
The server part reads Planning Center and (optionally) follows the
|
||||||
ProPresenter module's live item; this tile only renders the plan it is
|
ProPresenter module's live item; this tile only renders the plan it is
|
||||||
streamed. What is shown is this tile's own choice (gear menu →
|
streamed. What is shown is this tile's own choice (gear menu →
|
||||||
@@ -12,8 +8,6 @@
|
|||||||
(saved with the tile, 14 px until someone presses one); /admin only holds
|
(saved with the tile, 14 px until someone presses one); /admin only holds
|
||||||
the connection settings. */
|
the connection settings. */
|
||||||
|
|
||||||
import createTeams from "./teams-client.js";
|
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
||||||
|
|
||||||
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
|
||||||
@@ -21,12 +15,6 @@ const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
|||||||
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
// Tiles added as "Teams" carry that variant and have their own settings.
|
|
||||||
if (moduleApi.variant === "teams") return createTeams({ root, moduleApi });
|
|
||||||
return createPlan({ root, moduleApi });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPlan({ root, moduleApi }) {
|
|
||||||
let state = null;
|
let state = null;
|
||||||
let skew = 0; // server clock − local clock, ms
|
let skew = 0; // server clock − local clock, ms
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "pco-plan",
|
"id": "pco-plan",
|
||||||
"name": "PCO Plan",
|
"name": "PCO Plan",
|
||||||
"version": "1.7.0",
|
"version": "1.6.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service and team assignments from Planning Center Services",
|
"description": "Order of service from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -383,232 +383,5 @@
|
|||||||
"default": true,
|
"default": true,
|
||||||
"group": "Show"
|
"group": "Show"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tiles": [
|
|
||||||
{
|
|
||||||
"id": "plan",
|
|
||||||
"name": "Plan",
|
|
||||||
"description": "The order of service, item by item",
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 5,
|
|
||||||
"h": 6
|
|
||||||
},
|
|
||||||
"minSize": {
|
|
||||||
"w": 3,
|
|
||||||
"h": 3
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "teams",
|
|
||||||
"name": "Teams",
|
|
||||||
"description": "Who is scheduled, by team and position",
|
|
||||||
"defaultSize": {
|
|
||||||
"w": 4,
|
|
||||||
"h": 6
|
|
||||||
},
|
|
||||||
"minSize": {
|
|
||||||
"w": 2,
|
|
||||||
"h": 2
|
|
||||||
},
|
|
||||||
"instanceSchema": {
|
|
||||||
"columns": {
|
|
||||||
"type": "select",
|
|
||||||
"label": "Columns",
|
|
||||||
"default": "auto",
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"value": "auto",
|
|
||||||
"label": "Fit the tile"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "1",
|
|
||||||
"label": "One"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "2",
|
|
||||||
"label": "Two"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "3",
|
|
||||||
"label": "Three"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"teams": {
|
|
||||||
"type": "multiselect",
|
|
||||||
"label": "Teams",
|
|
||||||
"columns": 2,
|
|
||||||
"allByDefault": true,
|
|
||||||
"optionsRoute": "/teams"
|
|
||||||
},
|
|
||||||
"headerTemplate": {
|
|
||||||
"type": "template",
|
|
||||||
"label": "Top bar text",
|
|
||||||
"default": "{serviceType} • {date} | {people} on {teams}",
|
|
||||||
"group": "Top bar",
|
|
||||||
"variables": [
|
|
||||||
{
|
|
||||||
"value": "{series}",
|
|
||||||
"label": "Series"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{part}",
|
|
||||||
"label": "Part / plan title"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{serviceType}",
|
|
||||||
"label": "Service type"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{date}",
|
|
||||||
"label": "Date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{shortDate}",
|
|
||||||
"label": "Short date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{time}",
|
|
||||||
"label": "Service time"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{people}",
|
|
||||||
"label": "People count"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{teams}",
|
|
||||||
"label": "Team count"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "{confirmed}",
|
|
||||||
"label": "Confirmed count"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"help": "Use • between items and | to start a second line."
|
|
||||||
},
|
|
||||||
"teamColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Team headers",
|
|
||||||
"default": "#2ee59a",
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"tintTeams": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Tint teams",
|
|
||||||
"default": false,
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"tintRows": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Tint rows too",
|
|
||||||
"default": false,
|
|
||||||
"group": "Colors",
|
|
||||||
"showWhen": "tintTeams"
|
|
||||||
},
|
|
||||||
"headerGradient": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Header fade",
|
|
||||||
"default": true,
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"confirmedColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Confirmed",
|
|
||||||
"default": "#2ee59a",
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"unconfirmedColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Unconfirmed",
|
|
||||||
"default": "#f0b429",
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"declinedColor": {
|
|
||||||
"type": "color",
|
|
||||||
"label": "Declined",
|
|
||||||
"default": "#f4433c",
|
|
||||||
"group": "Colors"
|
|
||||||
},
|
|
||||||
"showTopBar": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Top bar",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showTeamHeaders": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Team headers",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showTeamCounts": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Team counts",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showAvatars": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Avatars",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showPosition": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Positions",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"groupByPosition": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Group by position",
|
|
||||||
"default": false,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showStatus": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Status",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showNotes": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Person notes",
|
|
||||||
"default": false,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showDeclined": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Declined people",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showUnconfirmed": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Unconfirmed people",
|
|
||||||
"default": true,
|
|
||||||
"group": "Show"
|
|
||||||
},
|
|
||||||
"showEmptyTeams": {
|
|
||||||
"type": "boolean",
|
|
||||||
"label": "Teams with nobody",
|
|
||||||
"default": false,
|
|
||||||
"group": "Show"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"instanceGroups": {
|
|
||||||
"Top bar": {
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
"Colors": {
|
|
||||||
"collapsed": false,
|
|
||||||
"columns": 2
|
|
||||||
},
|
|
||||||
"Show": {
|
|
||||||
"collapsed": true,
|
|
||||||
"columns": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
* (documents ProPresenter has shown or lists — see below)
|
* (documents ProPresenter has shown or lists — see below)
|
||||||
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
* GET /note-categories → { options } for a tile's "Item notes" categories:
|
||||||
* the service type's item note categories, by name
|
* the service type's item note categories, by name
|
||||||
* GET /teams → { options } for a Teams tile's "Teams" switches
|
|
||||||
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
|
|
||||||
* GET /timers → snapshot for the Timers module (guide: "Offering
|
* GET /timers → snapshot for the Timers module (guide: "Offering
|
||||||
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
|
||||||
*
|
*
|
||||||
@@ -31,7 +29,6 @@
|
|||||||
* GET …/plans/{id}/plan_times (service start times)
|
* GET …/plans/{id}/plan_times (service start times)
|
||||||
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
* GET …/plans/{id}/items?include=song,arrangement,item_notes
|
||||||
* GET /service_types/{st}/item_note_categories (note category names)
|
* GET /service_types/{st}/item_note_categories (note category names)
|
||||||
* GET …/plans/{id}/team_members?include=team,person (who is scheduled)
|
|
||||||
*
|
*
|
||||||
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
* Following ProPresenter: instead of talking to ProPresenter a second time,
|
||||||
* this module reads the propresenter-now-next module's own SSE feed over
|
* this module reads the propresenter-now-next module's own SSE feed over
|
||||||
@@ -88,19 +85,6 @@ const NOW_NEXT_RETRY_MS = 3000;
|
|||||||
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
const NOW_NEXT_MISSING_RETRY_MS = 15000;
|
||||||
/** How many distinct documents seen live are remembered for the admin selects. */
|
/** How many distinct documents seen live are remembered for the admin selects. */
|
||||||
const SEEN_DOCUMENTS_MAX = 200;
|
const SEEN_DOCUMENTS_MAX = 200;
|
||||||
/**
|
|
||||||
* Team assignments change far more slowly than the plan itself (someone
|
|
||||||
* confirms, a position is filled), so they ride a slow lane: re-read when the
|
|
||||||
* plan changes, and otherwise no more often than this.
|
|
||||||
*/
|
|
||||||
const TEAMS_MIN_MS = 60 * 1000;
|
|
||||||
/** How long the service type's own team list is believed. */
|
|
||||||
const SERVICE_TEAMS_TTL_MS = 10 * MINUTE;
|
|
||||||
/** Avatars live on Planning Center's CDN; browsers only talk to ProdDash. */
|
|
||||||
const AVATAR_CACHE_MAX = 300;
|
|
||||||
const AVATAR_TTL_MS = 6 * 60 * 60 * 1000;
|
|
||||||
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
/** How long a service type's item note categories are believed before Planning Center is asked again. */
|
||||||
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
|
||||||
/**
|
/**
|
||||||
@@ -128,8 +112,6 @@ function clearedState() {
|
|||||||
serviceType: { id: '', name: '' },
|
serviceType: { id: '', name: '' },
|
||||||
plan: null,
|
plan: null,
|
||||||
items: [],
|
items: [],
|
||||||
teams: [], // who is scheduled, by team (the Teams tile)
|
|
||||||
teamsError: '', // why the last team read failed, if it did
|
|
||||||
live: clearedLive(),
|
live: clearedLive(),
|
||||||
service: clearedService(),
|
service: clearedService(),
|
||||||
timing: null, // the admin's service-timing choices (no secrets)
|
timing: null, // the admin's service-timing choices (no secrets)
|
||||||
@@ -390,168 +372,6 @@ function shapePlan(planRec, serviceTypeId, planTimes) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── team assignments (the Teams tile) ──────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Planning Center's scheduling status letters. The API answers "C", "U" or
|
|
||||||
* "D"; some accounts and older records spell the word out, so match on the
|
|
||||||
* first letter and treat anything unknown as not yet confirmed — the state a
|
|
||||||
* booth should chase.
|
|
||||||
*/
|
|
||||||
function teamStatusOf(raw) {
|
|
||||||
const s = String(raw || '').trim().toUpperCase();
|
|
||||||
if (s.startsWith('C')) return 'confirmed';
|
|
||||||
if (s.startsWith('D')) return 'declined';
|
|
||||||
return 'unconfirmed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The person's photo. A PlanPerson usually carries `photo_thumbnail`; when a
|
|
||||||
* plan is read with `include=person` the Person record carries the fuller set
|
|
||||||
* of names Planning Center has used for the same thing over the years. Take
|
|
||||||
* the first that looks like a URL — a missing photo is normal and the tile
|
|
||||||
* draws initials instead.
|
|
||||||
*/
|
|
||||||
function teamPhotoOf(attrs, person) {
|
|
||||||
const candidates = [
|
|
||||||
attrs?.photo_thumbnail,
|
|
||||||
attrs?.photo_url,
|
|
||||||
person?.attributes?.avatar,
|
|
||||||
person?.attributes?.photo_thumbnail_url,
|
|
||||||
person?.attributes?.photo_url,
|
|
||||||
person?.attributes?.thumbnail_url,
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
const url = String(c || '').trim();
|
|
||||||
if (/^https?:\/\//i.test(url)) return url;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/plans/{id}/team_members?include=team,person` → the teams a tile draws,
|
|
||||||
* each with its people. Teams keep Planning Center's own order (a Team's
|
|
||||||
* `sequence`, then name); people sort by position, then by name, so a tile
|
|
||||||
* reads the way the schedule page does.
|
|
||||||
*
|
|
||||||
* Everyone scheduled belongs to a team, but a record whose team relationship
|
|
||||||
* is missing (a deleted team, a partial response) is not dropped on the
|
|
||||||
* floor — it lands under "Unassigned" where somebody will notice it.
|
|
||||||
*
|
|
||||||
* `allTeams` is the service type's own team list. Planning Center only
|
|
||||||
* returns a team here once somebody is scheduled on it, so without that list
|
|
||||||
* a team nobody has signed up for is invisible — which is the one a booth
|
|
||||||
* most wants to see. It also carries the authoritative name and order.
|
|
||||||
*/
|
|
||||||
function shapeTeams(body, allTeams) {
|
|
||||||
const included = new Map();
|
|
||||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
|
||||||
|
|
||||||
const teams = new Map();
|
|
||||||
for (const pp of body.data || []) {
|
|
||||||
const a = pp.attributes || {};
|
|
||||||
const teamRef = pp.relationships?.team?.data;
|
|
||||||
const teamRec = teamRef ? included.get(`Team/${teamRef.id}`) : null;
|
|
||||||
const teamId = teamRef?.id ? String(teamRef.id) : 'unassigned';
|
|
||||||
if (!teams.has(teamId)) {
|
|
||||||
teams.set(teamId, {
|
|
||||||
id: teamId,
|
|
||||||
name: String(teamRec?.attributes?.name || '').trim() || (teamRef?.id ? `Team ${teamId}` : 'Unassigned'),
|
|
||||||
sequence: Number(teamRec?.attributes?.sequence) || 0,
|
|
||||||
members: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const personRef = pp.relationships?.person?.data;
|
|
||||||
const personRec = personRef ? included.get(`Person/${personRef.id}`) : null;
|
|
||||||
teams.get(teamId).members.push({
|
|
||||||
id: String(pp.id),
|
|
||||||
personId: personRef?.id ? String(personRef.id) : '',
|
|
||||||
name: String(a.name || personRec?.attributes?.name || '').trim() || 'Unnamed',
|
|
||||||
position: String(a.team_position_name || '').trim(),
|
|
||||||
status: teamStatusOf(a.status),
|
|
||||||
declineReason: String(a.decline_reason || '').trim(),
|
|
||||||
notes: String(a.notes || '').trim(),
|
|
||||||
// The URL itself stays on the server; the tile asks for /team-avatar/<id>.
|
|
||||||
hasPhoto: Boolean(teamPhotoOf(a, personRec)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Teams nobody is scheduled on yet, and the real name/order for the rest.
|
|
||||||
for (const t of allTeams || []) {
|
|
||||||
const known = teams.get(t.id);
|
|
||||||
if (known) {
|
|
||||||
if (t.name) known.name = t.name;
|
|
||||||
known.sequence = t.sequence;
|
|
||||||
} else {
|
|
||||||
teams.set(t.id, { id: t.id, name: t.name, sequence: t.sequence, members: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = [...teams.values()];
|
|
||||||
out.sort((x, y) => x.sequence - y.sequence || x.name.localeCompare(y.name));
|
|
||||||
for (const team of out) {
|
|
||||||
team.members.sort((x, y) => x.position.localeCompare(y.position) || x.name.localeCompare(y.name));
|
|
||||||
team.counts = {
|
|
||||||
total: team.members.length,
|
|
||||||
confirmed: team.members.filter((m) => m.status === 'confirmed').length,
|
|
||||||
unconfirmed: team.members.filter((m) => m.status === 'unconfirmed').length,
|
|
||||||
declined: team.members.filter((m) => m.status === 'declined').length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Avatars: fetched once, kept a while, and served from ProdDash so no browser
|
|
||||||
ever talks to Planning Center (same reasoning as every other upstream). */
|
|
||||||
const avatarCache = new Map(); // url → { buf, type, at } — survives a re-init
|
|
||||||
const avatarInFlight = new Map();
|
|
||||||
|
|
||||||
function fetchAvatar(url) {
|
|
||||||
const hit = avatarCache.get(url);
|
|
||||||
if (hit && Date.now() - hit.at < AVATAR_TTL_MS) return Promise.resolve(hit);
|
|
||||||
if (avatarInFlight.has(url)) return avatarInFlight.get(url);
|
|
||||||
const p = (async () => {
|
|
||||||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' });
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
const type = String(response.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
|
|
||||||
if (!type.startsWith('image/')) throw new Error(`not an image (${type})`);
|
|
||||||
const buf = Buffer.from(await response.arrayBuffer());
|
|
||||||
if (buf.length > AVATAR_MAX_BYTES) throw new Error('too large');
|
|
||||||
if (avatarCache.size >= AVATAR_CACHE_MAX) avatarCache.delete(avatarCache.keys().next().value);
|
|
||||||
const entry = { buf, type, at: Date.now() };
|
|
||||||
avatarCache.set(url, entry);
|
|
||||||
return entry;
|
|
||||||
})().finally(() => avatarInFlight.delete(url));
|
|
||||||
avatarInFlight.set(url, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** https anywhere, or plain http on this machine — which is the mock, nothing else. */
|
|
||||||
function avatarUrlAllowed(url) {
|
|
||||||
try {
|
|
||||||
const u = new URL(String(url || ''));
|
|
||||||
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer with the photo at `url`; a failure is a 404/502 and the tile draws initials. */
|
|
||||||
async function serveAvatar(res, url) {
|
|
||||||
if (!avatarUrlAllowed(url)) {
|
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
||||||
return void res.end('No photo');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const img = await fetchAvatar(url);
|
|
||||||
res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' });
|
|
||||||
res.end(img.buf);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
|
|
||||||
res.end('Photo unavailable');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
|
/** "9:00 service" / "9:00 First Service" — how a service is named in details and logs. */
|
||||||
function serviceLabel(win) {
|
function serviceLabel(win) {
|
||||||
if (!win || !win.startsAt) return '';
|
if (!win || !win.startsAt) return '';
|
||||||
@@ -729,98 +549,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* — team assignments (the Teams tile) — */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The service type's teams, cached: they change when somebody edits the
|
|
||||||
* service type, not during a service. A failed read is not fatal — the
|
|
||||||
* plan's own teams still draw, just without the empty ones.
|
|
||||||
*/
|
|
||||||
let serviceTeams = { at: 0, list: [] };
|
|
||||||
|
|
||||||
async function readServiceTeams() {
|
|
||||||
if (Date.now() - serviceTeams.at < SERVICE_TEAMS_TTL_MS) return serviceTeams.list;
|
|
||||||
try {
|
|
||||||
const body = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/teams`);
|
|
||||||
const list = (body.data || []).map((t, i) => ({
|
|
||||||
id: String(t.id),
|
|
||||||
name: String(t.attributes?.name || '').trim() || `Team ${t.id}`,
|
|
||||||
sequence: Number(t.attributes?.sequence) || i + 1,
|
|
||||||
}));
|
|
||||||
serviceTeams = { at: Date.now(), list };
|
|
||||||
} catch (err) {
|
|
||||||
log(`service type team list read failed: ${err?.message || err}`);
|
|
||||||
serviceTeams.at = Date.now(); // don't retry every poll
|
|
||||||
}
|
|
||||||
return serviceTeams.list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** planPersonId → photo URL, so a browser never learns the CDN address. */
|
|
||||||
let teamPhotos = new Map();
|
|
||||||
let teamsReadAt = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Who is scheduled on this plan. Asked for with `include=team,person` and
|
|
||||||
* read again defensively without the includes when an account refuses
|
|
||||||
* them, exactly as the items read does: a tile that can name people
|
|
||||||
* without photos is far better than a tile that shows nothing.
|
|
||||||
*/
|
|
||||||
async function readTeams(planId) {
|
|
||||||
const target = `${planPath(planId)}/team_members`;
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await pco.getAll(target, { include: 'team,person' });
|
|
||||||
} catch (err) {
|
|
||||||
if (err.status && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 429) {
|
|
||||||
log(`team include rejected (${err.message}) — reading team members without includes`);
|
|
||||||
body = await pco.getAll(target);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const teams = shapeTeams(body, await readServiceTeams());
|
|
||||||
const photos = new Map();
|
|
||||||
const included = new Map();
|
|
||||||
for (const rec of body.included || []) included.set(`${rec.type}/${rec.id}`, rec);
|
|
||||||
for (const pp of body.data || []) {
|
|
||||||
const personRef = pp.relationships?.person?.data;
|
|
||||||
const url = teamPhotoOf(pp.attributes, personRef ? included.get(`Person/${personRef.id}`) : null);
|
|
||||||
if (url) photos.set(String(pp.id), url);
|
|
||||||
}
|
|
||||||
teamPhotos = photos;
|
|
||||||
teamsReadAt = Date.now();
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the team list when it is worth it: a new plan, or the slow lane
|
|
||||||
* coming round. A failure keeps the last good list and says so in the
|
|
||||||
* state — the plan itself is what the booth needs, and losing the roster
|
|
||||||
* must never cost them the running order.
|
|
||||||
*/
|
|
||||||
async function refreshTeams(planId, force) {
|
|
||||||
if (!force && Date.now() - teamsReadAt < TEAMS_MIN_MS) return;
|
|
||||||
try {
|
|
||||||
const teams = await readTeams(planId);
|
|
||||||
const before = state.teams.reduce((n, t) => n + t.members.length, 0);
|
|
||||||
const after = teams.reduce((n, t) => n + t.members.length, 0);
|
|
||||||
state.teams = teams;
|
|
||||||
state.teamsError = '';
|
|
||||||
if (force || before !== after) {
|
|
||||||
log(`teams: ${after} scheduled across ${teams.length} team${teams.length === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
state.teamsError = err?.message || String(err);
|
|
||||||
log(`team read failed: ${state.teamsError}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for a Teams tile's "Teams" switches — the plan's own teams, in its order. */
|
|
||||||
function teamOptions() {
|
|
||||||
return { options: state.teams.map((t) => ({ value: t.id, label: t.name })) };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function readPlan() {
|
async function readPlan() {
|
||||||
if (!pco.configured || !serviceTypeId) return;
|
if (!pco.configured || !serviceTypeId) return;
|
||||||
let planRec = null;
|
let planRec = null;
|
||||||
@@ -845,8 +573,6 @@ module.exports = {
|
|||||||
if (state.plan) log('no plan found any more — service timing reset');
|
if (state.plan) log('no plan found any more — service timing reset');
|
||||||
state.plan = null;
|
state.plan = null;
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.teams = [];
|
|
||||||
state.teamsError = "";
|
|
||||||
state.reachable = true;
|
state.reachable = true;
|
||||||
state.lastError = '';
|
state.lastError = '';
|
||||||
state.lastFetched = Date.now();
|
state.lastFetched = Date.now();
|
||||||
@@ -863,8 +589,6 @@ module.exports = {
|
|||||||
state.plan = plan;
|
state.plan = plan;
|
||||||
state.items = items;
|
state.items = items;
|
||||||
noteMessageItem(items);
|
noteMessageItem(items);
|
||||||
// The roster rides its own slow lane and never fails the plan read.
|
|
||||||
await refreshTeams(plan.id, planChanged);
|
|
||||||
state.reachable = true;
|
state.reachable = true;
|
||||||
state.lastError = '';
|
state.lastError = '';
|
||||||
state.lastFetched = Date.now();
|
state.lastFetched = Date.now();
|
||||||
@@ -1569,8 +1293,6 @@ module.exports = {
|
|||||||
timersSnapshot,
|
timersSnapshot,
|
||||||
documentOptions,
|
documentOptions,
|
||||||
noteCategoryOptions,
|
noteCategoryOptions,
|
||||||
teamOptions,
|
|
||||||
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1663,28 +1385,6 @@ module.exports = {
|
|||||||
req.on('close', () => timerStreams.delete(res));
|
req.on('close', () => timerStreams.delete(res));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for a Teams tile's "Teams" switches. Like the note
|
|
||||||
* categories, always a 200 with a list: an empty one simply means there
|
|
||||||
* is nothing scheduled to choose from yet, which the gear window says
|
|
||||||
* plainly.
|
|
||||||
*/
|
|
||||||
'GET /teams': (req, res) => {
|
|
||||||
json(res, 200, current ? current.teamOptions() : { options: [] });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A scheduled person's photo, proxied from Planning Center and cached.
|
|
||||||
* The tile asks by plan-person id and never sees the CDN URL; an id the
|
|
||||||
* module does not know (an old tile, a person since unscheduled) is a
|
|
||||||
* 404 and the tile falls back to initials.
|
|
||||||
*/
|
|
||||||
'GET /team-avatar/*': (req, res) => {
|
|
||||||
const id = String(req.wildcard || '').replace(/^\/+/, '');
|
|
||||||
const url = current ? current.avatarUrlFor(id) : '';
|
|
||||||
serveAvatar(res, url);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Options for the admin "Start document" / "End document" selects. */
|
/** Options for the admin "Start document" / "End document" selects. */
|
||||||
'GET /documents': (req, res) => {
|
'GET /documents': (req, res) => {
|
||||||
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });
|
||||||
|
|||||||
@@ -365,227 +365,3 @@
|
|||||||
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
[data-module="pco-plan"] .pp-foot.is-unmatched::before { background: var(--warn); }
|
||||||
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
[data-module="pco-plan"] .pp-foot.is-down { color: var(--danger); }
|
||||||
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
[data-module="pco-plan"] .pp-foot.is-down::before { background: var(--danger); }
|
||||||
|
|
||||||
/* ── the Teams tile ───────────────────────────────────────────────────
|
|
||||||
Who is scheduled, by team. Sizes hang off --pp-size like the Plan tile;
|
|
||||||
colours come from the tile's own settings (--tm-team per section,
|
|
||||||
--tm-status per person) over the shell's palette. */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-wrap {
|
|
||||||
--pp-size: 14px;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-size: var(--pp-size);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-head {
|
|
||||||
flex: none;
|
|
||||||
padding: 0.6em 0.85em 0.5em;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-head[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-title {
|
|
||||||
font-size: 1.1em;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-sub {
|
|
||||||
margin-top: 0.15em;
|
|
||||||
font-size: 0.82em;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-sub[hidden] { display: none; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 0 0 0.6em;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── a team ── */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-team { --tm-team: var(--accent); }
|
|
||||||
|
|
||||||
/* The header band runs edge to edge, like the Plan tile's section headers. */
|
|
||||||
[data-module="pco-plan"] .tm-team-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6em;
|
|
||||||
padding: 0.45em 0.9em 0.4em;
|
|
||||||
margin-top: 0.7em;
|
|
||||||
background: color-mix(in srgb, var(--tm-team) 22%, transparent);
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-team:first-child .tm-team-head { margin-top: 0; }
|
|
||||||
[data-module="pco-plan"] .header-gradient .tm-team-head {
|
|
||||||
background: linear-gradient(90deg,
|
|
||||||
color-mix(in srgb, var(--tm-team) 30%, transparent),
|
|
||||||
color-mix(in srgb, var(--tm-team) 6%, transparent) 70%,
|
|
||||||
transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-team-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-team-count {
|
|
||||||
flex: none;
|
|
||||||
font-size: 0.78em;
|
|
||||||
font-weight: 700;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-people {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(11em, 1fr));
|
|
||||||
gap: 0.15em 0.5em;
|
|
||||||
padding: 0.35em 0.6em 0.2em;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-people.is-fixed-cols {
|
|
||||||
grid-template-columns: repeat(var(--tm-cols, 1), minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-people.is-sub { padding-top: 0.1em; padding-bottom: 0.1em; }
|
|
||||||
|
|
||||||
/* Grouped by position: headings span, each position's people form their own grid. */
|
|
||||||
[data-module="pco-plan"] .tm-groups { padding-bottom: 0.2em; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-position-head {
|
|
||||||
padding: 0.4em 0.9em 0;
|
|
||||||
font-size: 0.72em;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-team-empty {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: 0.2em 0.3em 0.4em;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── one person ── */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-person {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55em;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.3em 0.4em;
|
|
||||||
border-radius: 0.45em;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-person:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
|
|
||||||
[data-module="pco-plan"] .tint-rows .tm-person { background: color-mix(in srgb, var(--tm-team) 8%, transparent); }
|
|
||||||
[data-module="pco-plan"] .tint-rows .tm-person:hover { background: color-mix(in srgb, var(--tm-team) 16%, transparent); }
|
|
||||||
[data-module="pco-plan"] .tm-person.is-declined { opacity: 0.55; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-avatar {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
|
||||||
width: 2.1em;
|
|
||||||
height: 2.1em;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: color-mix(in srgb, var(--tm-team) 28%, var(--panel));
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.78em;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
/* The photo covers the initials; if it fails to load the script drops it. */
|
|
||||||
[data-module="pco-plan"] .tm-avatar img {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-person-main { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-name {
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-person.is-declined .tm-name { text-decoration: line-through; }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-position {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-note {
|
|
||||||
margin-top: 0.1em;
|
|
||||||
font-size: 0.78em;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: color-mix(in srgb, var(--text) 75%, var(--muted));
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-status {
|
|
||||||
flex: none;
|
|
||||||
width: 0.55em;
|
|
||||||
height: 0.55em;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--tm-status, var(--muted));
|
|
||||||
box-shadow: 0 0 0 0.18em color-mix(in srgb, var(--tm-status, var(--muted)) 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── empty / degraded ── */
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-empty {
|
|
||||||
padding: 12% 1.2em 1em;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-empty-title { font-size: 1.1em; font-weight: 700; color: var(--text); }
|
|
||||||
[data-module="pco-plan"] .tm-empty-hint { margin-top: 0.4em; font-size: 0.9em; line-height: 1.4; }
|
|
||||||
[data-module="pco-plan"] .tm-empty-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 0.9em;
|
|
||||||
padding: 0.35em 0.9em;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
[data-module="pco-plan"] .tm-empty-link:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
||||||
|
|
||||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-head { border-bottom-color: color-mix(in srgb, var(--danger) 50%, transparent); }
|
|
||||||
[data-module="pco-plan"] .tm-wrap.is-offline .tm-body { opacity: 0.6; }
|
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
/* PCO Plan — the Teams tile.
|
|
||||||
|
|
||||||
Who is scheduled on the current plan, by team and position, from the same
|
|
||||||
server state the Plan tile draws (state.teams). Everything shown is this
|
|
||||||
tile's own choice (gear menu): which teams and positions, what each row
|
|
||||||
carries, and the colours. Photos come from ProdDash's own avatar route —
|
|
||||||
the browser never talks to Planning Center. */
|
|
||||||
|
|
||||||
const REFRESH_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>';
|
|
||||||
|
|
||||||
const DEFAULT_TEMPLATE = '{serviceType} • {date} | {people} on {teams}';
|
|
||||||
|
|
||||||
/** Colours for "Tint teams", walked in order so neighbouring teams differ. */
|
|
||||||
const TEAM_HUES = [152, 205, 32, 275, 12, 180, 320, 95, 240, 50];
|
|
||||||
|
|
||||||
export default function createTeams({ root, moduleApi }) {
|
|
||||||
let state = null;
|
|
||||||
let feedOffline = false;
|
|
||||||
let stream = null;
|
|
||||||
let lastKey = '';
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="tm-wrap">
|
|
||||||
<header class="tm-head">
|
|
||||||
<div class="tm-title"></div>
|
|
||||||
<div class="tm-sub"></div>
|
|
||||||
</header>
|
|
||||||
<div class="tm-body"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const wrap = root.querySelector('.tm-wrap');
|
|
||||||
const headEl = root.querySelector('.tm-head');
|
|
||||||
const titleEl = root.querySelector('.tm-title');
|
|
||||||
const subEl = root.querySelector('.tm-sub');
|
|
||||||
const bodyEl = root.querySelector('.tm-body');
|
|
||||||
|
|
||||||
const cfg = () => moduleApi.instanceSettings || {};
|
|
||||||
const on = (key) => cfg()[key] !== false; // unset = shown
|
|
||||||
|
|
||||||
/* ── title-bar controls ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const refreshBtn = moduleApi.header.addButton({
|
|
||||||
icon: REFRESH_SVG,
|
|
||||||
title: 'Re-read from Planning Center',
|
|
||||||
onClick() {
|
|
||||||
refreshBtn.classList.add('active');
|
|
||||||
moduleApi.fetch('/refresh', { method: 'POST' })
|
|
||||||
.catch(() => { /* the stream reports the outcome */ })
|
|
||||||
.finally(() => refreshBtn.classList.remove('active'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ── helpers ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function colorOf(value, fallback) {
|
|
||||||
const s = String(value || '').trim();
|
|
||||||
return /^#[0-9a-fA-F]{3,8}$/.test(s) ? s : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A team's colour when "Tint teams" is on. Taken from the team's position
|
|
||||||
* in the plan, not a hash of its name: neighbouring teams then always look
|
|
||||||
* different, which is the whole point of tinting them.
|
|
||||||
*/
|
|
||||||
function teamColor(index) {
|
|
||||||
if (!cfg().tintTeams) return colorOf(cfg().teamColor, '#2ee59a');
|
|
||||||
return `hsl(${TEAM_HUES[index % TEAM_HUES.length]} 70% 55%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status) {
|
|
||||||
if (status === 'confirmed') return colorOf(cfg().confirmedColor, '#2ee59a');
|
|
||||||
if (status === 'declined') return colorOf(cfg().declinedColor, '#f4433c');
|
|
||||||
return colorOf(cfg().unconfirmedColor, '#f0b429');
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_LABEL = { confirmed: 'Confirmed', unconfirmed: 'Unconfirmed', declined: 'Declined' };
|
|
||||||
|
|
||||||
/** "Sarah Miles" → "SM"; one word → its first two letters. */
|
|
||||||
function initialsOf(name) {
|
|
||||||
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (!words.length) return '?';
|
|
||||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
|
||||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A saved multiselect list, or null when the tile has never chosen (= all). */
|
|
||||||
function chosen(key) {
|
|
||||||
const v = cfg()[key];
|
|
||||||
return Array.isArray(v) ? v : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── which teams and people to show ─────────────────────────────── */
|
|
||||||
|
|
||||||
function visibleTeams() {
|
|
||||||
const teams = Array.isArray(state?.teams) ? state.teams : [];
|
|
||||||
const wantTeams = chosen('teams');
|
|
||||||
const out = [];
|
|
||||||
for (const team of teams) {
|
|
||||||
if (wantTeams && !wantTeams.includes(team.id)) continue;
|
|
||||||
const members = team.members.filter((m) => {
|
|
||||||
if (m.status === 'declined' && !on('showDeclined')) return false;
|
|
||||||
if (m.status === 'unconfirmed' && !on('showUnconfirmed')) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (!members.length && !cfg().showEmptyTeams) continue;
|
|
||||||
out.push({ ...team, members });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── status dot ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function renderStatus() {
|
|
||||||
if (feedOffline) return moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
|
||||||
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
if (!state.configured) return moduleApi.setStatus('error', 'No Planning Center credentials — add them in Admin');
|
|
||||||
if (!state.serviceTypeSet) return moduleApi.setStatus('error', 'No service type selected — pick one in Admin');
|
|
||||||
if (!state.reachable) {
|
|
||||||
return moduleApi.setStatus(state.lastFetched ? 'error' : 'connecting', state.lastError || 'Reading the plan…');
|
|
||||||
}
|
|
||||||
if (state.teamsError) return moduleApi.setStatus('error', `Team list: ${state.teamsError}`);
|
|
||||||
const people = (state.teams || []).reduce((n, t) => n + t.members.length, 0);
|
|
||||||
moduleApi.setStatus('ok', `${people} scheduled · read ${new Date(state.lastFetched).toLocaleTimeString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── top bar ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** The counts describe what this tile shows, not what the plan holds. */
|
|
||||||
function templateValues() {
|
|
||||||
const plan = state?.plan || {};
|
|
||||||
const teams = visibleTeams();
|
|
||||||
const people = teams.reduce((n, t) => n + t.members.length, 0);
|
|
||||||
const confirmed = teams.reduce((n, t) => n + t.counts.confirmed, 0);
|
|
||||||
return {
|
|
||||||
series: plan.seriesTitle || '',
|
|
||||||
part: plan.title || '',
|
|
||||||
servicetype: state?.serviceType?.name || '',
|
|
||||||
date: plan.dates || '',
|
|
||||||
shortdate: plan.shortDates || plan.dates || '',
|
|
||||||
time: plan.serviceStartsAt
|
|
||||||
? new Date(plan.serviceStartsAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
||||||
: '',
|
|
||||||
people: people ? `${people} ${people === 1 ? 'person' : 'people'}` : '',
|
|
||||||
teams: teams.length ? `${teams.length} ${teams.length === 1 ? 'team' : 'teams'}` : '',
|
|
||||||
confirmed: confirmed ? `${confirmed} confirmed` : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "{serviceType} • {date} | {people} on {teams}" → up to two lines. Within
|
|
||||||
* a line • separates segments and a segment whose placeholders all came out
|
|
||||||
* empty is dropped, so a plan with no series never leaves a stray bullet.
|
|
||||||
*/
|
|
||||||
function renderTemplate(template, values) {
|
|
||||||
return String(template || DEFAULT_TEMPLATE).split('|').slice(0, 2).map((line) => {
|
|
||||||
const kept = [];
|
|
||||||
for (const seg of line.split(/\s*[•·]\s*/)) {
|
|
||||||
let sawVar = false;
|
|
||||||
let allEmpty = true;
|
|
||||||
const text = seg.replace(/\{([a-zA-Z]+)\}/g, (m, name) => {
|
|
||||||
sawVar = true;
|
|
||||||
const v = values[name.toLowerCase()];
|
|
||||||
if (v) allEmpty = false;
|
|
||||||
return v || '';
|
|
||||||
}).trim();
|
|
||||||
if (sawVar && allEmpty) continue;
|
|
||||||
if (text) kept.push(text);
|
|
||||||
}
|
|
||||||
return kept.join(' • ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHead() {
|
|
||||||
const show = on('showTopBar') && state?.plan;
|
|
||||||
headEl.hidden = !show;
|
|
||||||
if (!show) return;
|
|
||||||
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
|
||||||
titleEl.textContent = line1 || line2 || state.serviceType?.name || 'Teams';
|
|
||||||
subEl.textContent = line1 ? line2 || '' : '';
|
|
||||||
subEl.hidden = !subEl.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── the list ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function emptyMessage() {
|
|
||||||
if (feedOffline && !state) return { title: 'Feed offline', hint: 'Not receiving updates from the ProdDash server.' };
|
|
||||||
if (!state) return { title: 'Connecting…', hint: '' };
|
|
||||||
if (!state.configured) {
|
|
||||||
return {
|
|
||||||
title: 'Planning Center not connected',
|
|
||||||
hint: 'Enter the Planning Center credentials under PCO Plan in Admin.',
|
|
||||||
link: { href: '/admin', text: 'Open Admin' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!state.serviceTypeSet) {
|
|
||||||
return {
|
|
||||||
title: 'No service type selected',
|
|
||||||
hint: 'Pick the service type to follow under PCO Plan in Admin.',
|
|
||||||
link: { href: '/admin', text: 'Open Admin' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!state.reachable && !state.plan) {
|
|
||||||
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
|
||||||
}
|
|
||||||
if (!state.plan) {
|
|
||||||
return {
|
|
||||||
title: 'No plan found',
|
|
||||||
hint: `${state.serviceType?.name || 'This service type'} has no upcoming or recent plans.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!(state.teams || []).length) {
|
|
||||||
return {
|
|
||||||
title: 'Nobody scheduled yet',
|
|
||||||
hint: state.teamsError || 'This plan has no team members in Planning Center.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!visibleTeams().length) {
|
|
||||||
return { title: 'Nothing to show', hint: 'Every team is hidden by this tile’s settings (gear menu).' };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMember(member) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = `tm-person is-${member.status}`;
|
|
||||||
|
|
||||||
if (on('showAvatars')) {
|
|
||||||
const avatar = document.createElement('div');
|
|
||||||
avatar.className = 'tm-avatar';
|
|
||||||
avatar.textContent = initialsOf(member.name);
|
|
||||||
if (member.hasPhoto) {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.loading = 'lazy';
|
|
||||||
img.alt = '';
|
|
||||||
img.src = `/api/modules/${moduleApi.id}/team-avatar/${encodeURIComponent(member.id)}`;
|
|
||||||
// No photo, or the CDN is unreachable: the initials underneath stand.
|
|
||||||
img.addEventListener('error', () => img.remove());
|
|
||||||
avatar.appendChild(img);
|
|
||||||
}
|
|
||||||
row.appendChild(avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = document.createElement('div');
|
|
||||||
main.className = 'tm-person-main';
|
|
||||||
const nameEl = document.createElement('div');
|
|
||||||
nameEl.className = 'tm-name';
|
|
||||||
nameEl.textContent = member.name;
|
|
||||||
main.appendChild(nameEl);
|
|
||||||
|
|
||||||
if (on('showPosition') && member.position && !cfg().groupByPosition) {
|
|
||||||
const pos = document.createElement('div');
|
|
||||||
pos.className = 'tm-position';
|
|
||||||
pos.textContent = member.position;
|
|
||||||
main.appendChild(pos);
|
|
||||||
}
|
|
||||||
if (cfg().showNotes && (member.notes || member.declineReason)) {
|
|
||||||
const note = document.createElement('div');
|
|
||||||
note.className = 'tm-note';
|
|
||||||
note.textContent = member.declineReason ? `Declined: ${member.declineReason}` : member.notes;
|
|
||||||
main.appendChild(note);
|
|
||||||
}
|
|
||||||
row.appendChild(main);
|
|
||||||
|
|
||||||
if (on('showStatus')) {
|
|
||||||
const status = document.createElement('div');
|
|
||||||
status.className = 'tm-status';
|
|
||||||
status.style.setProperty('--tm-status', statusColor(member.status));
|
|
||||||
status.title = STATUS_LABEL[member.status] || member.status;
|
|
||||||
row.appendChild(status);
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One team: its header band, then its people (optionally under position sub-headings). */
|
|
||||||
function buildTeam(team, index) {
|
|
||||||
const section = document.createElement('section');
|
|
||||||
section.className = 'tm-team';
|
|
||||||
section.style.setProperty('--tm-team', teamColor(index));
|
|
||||||
|
|
||||||
if (on('showTeamHeaders')) {
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'tm-team-head';
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'tm-team-name';
|
|
||||||
name.textContent = team.name;
|
|
||||||
header.appendChild(name);
|
|
||||||
if (on('showTeamCounts')) {
|
|
||||||
const counts = document.createElement('span');
|
|
||||||
counts.className = 'tm-team-count';
|
|
||||||
const pending = team.counts.unconfirmed;
|
|
||||||
counts.textContent = pending ? `${team.members.length} · ${pending} pending` : String(team.members.length);
|
|
||||||
header.appendChild(counts);
|
|
||||||
}
|
|
||||||
section.appendChild(header);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = String(cfg().columns || 'auto');
|
|
||||||
const grouping = Boolean(cfg().groupByPosition) && on('showPosition');
|
|
||||||
// Grouped, the container is a plain block so each position heading spans
|
|
||||||
// the width and only its own people form a grid.
|
|
||||||
const list = document.createElement('div');
|
|
||||||
list.className = grouping ? 'tm-groups' : 'tm-people';
|
|
||||||
if (!grouping && columns !== 'auto') {
|
|
||||||
list.style.setProperty('--tm-cols', columns);
|
|
||||||
list.classList.add('is-fixed-cols');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grouping) {
|
|
||||||
// Positions keep the order the members already sort in.
|
|
||||||
const groups = new Map();
|
|
||||||
for (const m of team.members) {
|
|
||||||
const key = m.position || '(no position)';
|
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
|
||||||
groups.get(key).push(m);
|
|
||||||
}
|
|
||||||
for (const [position, members] of groups) {
|
|
||||||
const label = document.createElement('div');
|
|
||||||
label.className = 'tm-position-head';
|
|
||||||
label.textContent = position;
|
|
||||||
list.appendChild(label);
|
|
||||||
const sub = document.createElement('div');
|
|
||||||
sub.className = 'tm-people is-sub';
|
|
||||||
if (columns !== 'auto') sub.style.setProperty('--tm-cols', columns);
|
|
||||||
sub.classList.toggle('is-fixed-cols', columns !== 'auto');
|
|
||||||
for (const m of members) sub.appendChild(buildMember(m));
|
|
||||||
list.appendChild(sub);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const m of team.members) list.appendChild(buildMember(m));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!team.members.length) {
|
|
||||||
const none = document.createElement('div');
|
|
||||||
none.className = 'tm-team-empty';
|
|
||||||
none.textContent = 'Nobody scheduled';
|
|
||||||
list.appendChild(none);
|
|
||||||
}
|
|
||||||
section.appendChild(list);
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody() {
|
|
||||||
const empty = emptyMessage();
|
|
||||||
if (empty) {
|
|
||||||
lastKey = '';
|
|
||||||
bodyEl.innerHTML = '';
|
|
||||||
const box = document.createElement('div');
|
|
||||||
box.className = 'tm-empty';
|
|
||||||
const t = document.createElement('div');
|
|
||||||
t.className = 'tm-empty-title';
|
|
||||||
t.textContent = empty.title;
|
|
||||||
box.appendChild(t);
|
|
||||||
if (empty.hint) {
|
|
||||||
const h = document.createElement('div');
|
|
||||||
h.className = 'tm-empty-hint';
|
|
||||||
h.textContent = empty.hint;
|
|
||||||
box.appendChild(h);
|
|
||||||
}
|
|
||||||
if (empty.link) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.className = 'tm-empty-link';
|
|
||||||
a.href = empty.link.href;
|
|
||||||
a.target = '_blank';
|
|
||||||
a.rel = 'noopener';
|
|
||||||
a.textContent = empty.link.text;
|
|
||||||
box.appendChild(a);
|
|
||||||
}
|
|
||||||
bodyEl.appendChild(box);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild only when the roster or the settings actually changed: the
|
|
||||||
// avatars are <img>s and a needless rebuild makes them flicker.
|
|
||||||
const teams = visibleTeams();
|
|
||||||
const key = JSON.stringify([teams, cfg()]);
|
|
||||||
if (key === lastKey) return;
|
|
||||||
lastKey = key;
|
|
||||||
bodyEl.innerHTML = '';
|
|
||||||
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
wrap.classList.toggle('is-offline', feedOffline);
|
|
||||||
wrap.classList.toggle('tint-rows', Boolean(cfg().tintTeams && cfg().tintRows));
|
|
||||||
wrap.classList.toggle('header-gradient', on('headerGradient'));
|
|
||||||
renderStatus();
|
|
||||||
renderHead();
|
|
||||||
renderBody();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── live feed ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function accept(payload) {
|
|
||||||
if (!payload || typeof payload !== 'object' || !payload.state) return;
|
|
||||||
state = payload.state;
|
|
||||||
feedOffline = false;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
stream = moduleApi.sse('/stream', {
|
|
||||||
open() { feedOffline = false; render(); },
|
|
||||||
error() { feedOffline = true; render(); },
|
|
||||||
events: {
|
|
||||||
state(ev) {
|
|
||||||
try {
|
|
||||||
accept(JSON.parse(ev.data));
|
|
||||||
} catch { /* a bad frame is not fatal */ }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── lifecycle ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
return {
|
|
||||||
start() {
|
|
||||||
moduleApi.setStatus('connecting', 'Connecting…');
|
|
||||||
moduleApi.fetch('/state')
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((body) => { if (body && !state) accept(body); })
|
|
||||||
.catch(() => { /* the stream will cover it */ });
|
|
||||||
connect();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
stream?.close();
|
|
||||||
stream = null;
|
|
||||||
root.innerHTML = '';
|
|
||||||
},
|
|
||||||
onConfigChange() {
|
|
||||||
// Admin holds the connection only; the server re-inits and re-sends.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -123,13 +123,10 @@ export function framesToTc(n, nominal, df) {
|
|||||||
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
||||||
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
||||||
* forward while running; `startedAt` is the fallback when they are absent.
|
* forward while running; `startedAt` is the fallback when they are absent.
|
||||||
* A timer measured at its own instant carries `asOf` (a phase-locked
|
|
||||||
* ProPresenter value — see the server's phase.js) which beats the source's
|
|
||||||
* snapshot time.
|
|
||||||
*/
|
*/
|
||||||
export function valueAt(timer, sourceUpdatedAt, now) {
|
export function valueAt(timer, sourceUpdatedAt, now) {
|
||||||
const running = timer.state === 'running';
|
const running = timer.state === 'running';
|
||||||
const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now;
|
const at = Number(sourceUpdatedAt) || now;
|
||||||
const dt = running ? Math.max(0, now - at) : 0;
|
const dt = running ? Math.max(0, now - at) : 0;
|
||||||
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
||||||
if (timer.kind === 'countdown') {
|
if (timer.kind === 'countdown') {
|
||||||
@@ -650,21 +647,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
statusTone = timer.status?.tone || 'muted';
|
statusTone = timer.status?.tone || 'muted';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let value = valueAt(timer, source.updatedAt, now);
|
const value = valueAt(timer, source.updatedAt, now);
|
||||||
// A running card must never tick backwards: an anchor correction or
|
|
||||||
// clock-offset wobble can re-place a second boundary by a few
|
|
||||||
// hundred ms, and a digit that regresses reads as broken. A small
|
|
||||||
// step against the direction of travel holds the shown value (time
|
|
||||||
// re-passes it within the jitter bound); a big one is a real
|
|
||||||
// reset/seek and snaps.
|
|
||||||
if (timer.state === 'running' && value !== null) {
|
|
||||||
const dir = timer.kind === 'countdown' ? -1 : 1;
|
|
||||||
const prev = card.mono;
|
|
||||||
if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev;
|
|
||||||
card.mono = value;
|
|
||||||
} else {
|
|
||||||
card.mono = null;
|
|
||||||
}
|
|
||||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||||
tone = toneFor(timer, value);
|
tone = toneFor(timer, value);
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.4.2",
|
"version": "1.4.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase lock for whole-second timer reports (the ProPresenter poller).
|
|
||||||
*
|
|
||||||
* ProPresenter reports a running timer as whole seconds — trunc(value):
|
|
||||||
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
|
|
||||||
* old poller re-anchored a tile's local count at the bottom of that interval
|
|
||||||
* on every poll, so the displayed digit ran up to a second ahead and then
|
|
||||||
* got yanked back a step whenever a fresh poll happened to sample the next
|
|
||||||
* flip later than the last one did — the poll clock and ProPresenter's
|
|
||||||
* second boundaries drift through each other, so tiles flickered
|
|
||||||
* "…12:33 → 12:34 → 12:33…" about once a second.
|
|
||||||
*
|
|
||||||
* This tracker turns the quantized reports into ONE stable anchor
|
|
||||||
* { valueMs, at } — the continuous value at a fixed instant — which the
|
|
||||||
* client extrapolates from. Rules:
|
|
||||||
*
|
|
||||||
* - The only real information in a poll is a FLIP: the report stepping one
|
|
||||||
* second in the direction of travel. At the flip instant the true value
|
|
||||||
* equals the interval boundary just crossed, and the flip happened between
|
|
||||||
* the last two polls, so their midpoint dates it to within half a poll.
|
|
||||||
* - While a poll's report matches what the anchor predicts (within TOL_MS
|
|
||||||
* around the reported second's interval), the anchor HOLDS — the same
|
|
||||||
* numbers are republished, so consumers count along one steady clock
|
|
||||||
* instead of a freshly re-phased one per sample.
|
|
||||||
* - A report the anchor can't explain re-anchors: at the flip boundary when
|
|
||||||
* this poll saw a clean one-second flip, else mid-interval (first sight,
|
|
||||||
* an operator reset/seek, a resume, or clock drift past TOL_MS).
|
|
||||||
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
|
|
||||||
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
|
|
||||||
* whole-second bias one way.
|
|
||||||
*
|
|
||||||
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
|
|
||||||
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
|
|
||||||
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
|
|
||||||
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
|
|
||||||
* crosses +1 s.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** How far a report may sit outside the anchor's prediction before it means
|
|
||||||
something real. A flip is dated to the midpoint of two polls, so its own
|
|
||||||
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
|
|
||||||
runs long); the tolerance must sit ABOVE that or consecutive flips
|
|
||||||
re-anchor each other forever, and safely below a full second so a real
|
|
||||||
one-second seek still registers. */
|
|
||||||
const TOL_MS = 450;
|
|
||||||
|
|
||||||
/** The continuous-value interval implied by a whole-second report. */
|
|
||||||
function intervalFor(kind, wholeMs) {
|
|
||||||
if (kind === 'countdown') {
|
|
||||||
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
|
|
||||||
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
|
|
||||||
}
|
|
||||||
return { lo: wholeMs, hi: wholeMs + 1000 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
|
|
||||||
function boundaryFor(kind, wholeMs) {
|
|
||||||
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
|
|
||||||
return wholeMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One tracker per timer. Feed one observation per poll; get back what to
|
|
||||||
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
|
|
||||||
* stable across polls while the reports stay consistent with it. Not
|
|
||||||
* running (or no value): the report is returned verbatim, dated at the poll.
|
|
||||||
*/
|
|
||||||
function createTimerPhase() {
|
|
||||||
let anchor = null; // { valueMs, at }
|
|
||||||
let kind = '';
|
|
||||||
let lastWhole = null;
|
|
||||||
let lastAt = 0;
|
|
||||||
let wasRunning = false;
|
|
||||||
|
|
||||||
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
|
|
||||||
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
|
|
||||||
const dir = k === 'countdown' ? -1 : 1;
|
|
||||||
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
|
|
||||||
&& lastWhole !== null && wholeMs !== null
|
|
||||||
&& wholeMs - lastWhole === dir * 1000;
|
|
||||||
const prevAt = lastAt;
|
|
||||||
lastWhole = wholeMs;
|
|
||||||
lastAt = pollAt;
|
|
||||||
wasRunning = Boolean(running);
|
|
||||||
if (k !== kind) anchor = null;
|
|
||||||
kind = k;
|
|
||||||
if (!running || wholeMs === null) {
|
|
||||||
anchor = null;
|
|
||||||
return { valueMs: wholeMs, asOf: pollAt };
|
|
||||||
}
|
|
||||||
const { lo, hi } = intervalFor(k, wholeMs);
|
|
||||||
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
|
|
||||||
// tolerance check may fail to catch for a long time (its error only
|
|
||||||
// shows at a poll landing more than TOL_MS past a flip). The first
|
|
||||||
// clean flip is strictly better evidence (±½ poll), so it always wins.
|
|
||||||
if (anchor && !(anchor.provisional && flip)) {
|
|
||||||
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
|
|
||||||
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
|
|
||||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
anchor = flip
|
|
||||||
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
|
|
||||||
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
|
|
||||||
return { valueMs: anchor.valueMs, asOf: anchor.at };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { observe };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createTimerPhase, TOL_MS };
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-test for phase.js — the whole-second phase lock — plus the client's
|
|
||||||
* never-tick-backwards guard, driven by a simulated ProPresenter:
|
|
||||||
*
|
|
||||||
* node modules/propresenter-timers/phase.selftest.js
|
|
||||||
*
|
|
||||||
* The simulation runs a true continuous timer, reports it the way
|
|
||||||
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
|
|
||||||
* jitter), feeds the reports through createTimerPhase(), then renders the
|
|
||||||
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
|
|
||||||
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
|
|
||||||
*
|
|
||||||
* - the displayed digits NEVER step against the direction of travel
|
|
||||||
* (the reported bug: countdown tiles flicking back to the previous
|
|
||||||
* second about once a second),
|
|
||||||
* - each displayed second dwells ~1 s,
|
|
||||||
* - the published anchor stays within tolerance of the true value,
|
|
||||||
* - operator resets/seeks snap within a poll or two,
|
|
||||||
* - pause → resume re-locks,
|
|
||||||
* - a ProPresenter whose clock drifts against the server's stays locked.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { createTimerPhase, TOL_MS } = require('./phase');
|
|
||||||
|
|
||||||
let checks = 0;
|
|
||||||
let failures = 0;
|
|
||||||
function ok(name, cond, detail) {
|
|
||||||
checks += 1;
|
|
||||||
if (!cond) {
|
|
||||||
failures += 1;
|
|
||||||
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deterministic pseudo-random (so a failure reproduces). */
|
|
||||||
function makeRand(seed) {
|
|
||||||
let s = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
s = (s * 1664525 + 1013904223) >>> 0;
|
|
||||||
return s / 2 ** 32;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ProPresenter's report: whole seconds, truncated toward zero. */
|
|
||||||
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
|
|
||||||
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
|
|
||||||
* timers report a frozen value via `frozen`).
|
|
||||||
*/
|
|
||||||
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
|
|
||||||
const rand = makeRand(seed);
|
|
||||||
const phase = createTimerPhase();
|
|
||||||
const dir = kind === 'countdown' ? -1 : 1;
|
|
||||||
|
|
||||||
const polls = [];
|
|
||||||
for (let t = 0; t <= durationMs;) {
|
|
||||||
const running = runningAt(t);
|
|
||||||
const value = trueValueAt(t);
|
|
||||||
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
|
|
||||||
t += pollMs + Math.floor(rand() * jitterMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render like client.js: between polls, extrapolate from the latest
|
|
||||||
// anchor; clamp sub-1.5 s regressions while running.
|
|
||||||
const shown = []; // { t, sec, err }
|
|
||||||
let mono = null;
|
|
||||||
let p = 0;
|
|
||||||
for (let t = polls[0].t; t <= durationMs; t += 25) {
|
|
||||||
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
|
|
||||||
const { out, running } = polls[p];
|
|
||||||
if (!running || out.valueMs === null) {
|
|
||||||
mono = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let value = out.valueMs + dir * (t - out.asOf);
|
|
||||||
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
|
|
||||||
mono = value;
|
|
||||||
const truth = trueValueAt(t);
|
|
||||||
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
|
|
||||||
}
|
|
||||||
return { name, dir, shown };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
|
|
||||||
from legitimate corrections (absorbing clock drift re-places a boundary
|
|
||||||
by up to TOL_MS once in a while). */
|
|
||||||
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
|
|
||||||
let regressions = 0;
|
|
||||||
let maxAbsErr = 0;
|
|
||||||
const changes = [];
|
|
||||||
for (let i = 1; i < shown.length; i += 1) {
|
|
||||||
const step = shown[i].sec - shown[i - 1].sec;
|
|
||||||
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
|
|
||||||
if (step !== 0) changes.push(shown[i].t);
|
|
||||||
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
|
|
||||||
}
|
|
||||||
// Whole dwells only (drop the partial first and trailing segments), after
|
|
||||||
// the lock settles — the first observed flip corrects the provisional
|
|
||||||
// mid-interval anchor once, stretching or squeezing a boundary in the
|
|
||||||
// timer's first moments, which no one can see on a wall. A countdown
|
|
||||||
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
|
|
||||||
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
|
|
||||||
// allow one ~2 s dwell; everything else must be ~1 s.
|
|
||||||
const settled = shown.length ? shown[0].t + 2500 : 0;
|
|
||||||
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
|
|
||||||
const near = (d, target) => Math.abs(d - target) <= 200;
|
|
||||||
const long = dwells.filter((d) => near(d, 2000)).length;
|
|
||||||
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
|
|
||||||
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
|
|
||||||
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
|
|
||||||
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
|
|
||||||
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 1. Plain countdown, many sub-second phases and poll seeds. */
|
|
||||||
for (let i = 0; i < 12; i += 1) {
|
|
||||||
const phase0 = (i * 83) % 1000;
|
|
||||||
const run = simulate({
|
|
||||||
name: `countdown q=${phase0}ms seed=${i}`,
|
|
||||||
kind: 'countdown',
|
|
||||||
seed: 1000 + i,
|
|
||||||
durationMs: 30000,
|
|
||||||
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
|
|
||||||
});
|
|
||||||
assertSteady(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Elapsed timer (counts up). */
|
|
||||||
for (let i = 0; i < 6; i += 1) {
|
|
||||||
const run = simulate({
|
|
||||||
name: `elapsed seed=${i}`,
|
|
||||||
kind: 'elapsed',
|
|
||||||
seed: 2000 + i,
|
|
||||||
durationMs: 25000,
|
|
||||||
trueValueAt: (t) => 400 + i * 137 + t,
|
|
||||||
});
|
|
||||||
assertSteady(run);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 3. Operator reset mid-run: value jumps back up to full length. */
|
|
||||||
{
|
|
||||||
const run = simulate({
|
|
||||||
name: 'reset',
|
|
||||||
kind: 'countdown',
|
|
||||||
seed: 7,
|
|
||||||
durationMs: 24000,
|
|
||||||
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
|
|
||||||
});
|
|
||||||
// After the reset the shown value must reach the new region within ~2 polls.
|
|
||||||
const after = run.shown.filter((s) => s.t >= 13400);
|
|
||||||
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
|
|
||||||
// Before and after the jump, digits stay monotonic within each phase.
|
|
||||||
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
|
|
||||||
assertSteady(pre, { maxErr: 700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. Pause, then resume: no stale-anchor explosion. */
|
|
||||||
{
|
|
||||||
const frozen = 8000;
|
|
||||||
const run = simulate({
|
|
||||||
name: 'pause/resume',
|
|
||||||
kind: 'countdown',
|
|
||||||
seed: 11,
|
|
||||||
durationMs: 26000,
|
|
||||||
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
|
|
||||||
runningAt: (t) => t < 6000 || t >= 14000,
|
|
||||||
});
|
|
||||||
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
|
|
||||||
assertSteady(resumed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
|
|
||||||
re-locks over time instead of accumulating a visible error. */
|
|
||||||
{
|
|
||||||
const run = simulate({
|
|
||||||
name: 'clock drift',
|
|
||||||
kind: 'countdown',
|
|
||||||
seed: 13,
|
|
||||||
durationMs: 120000,
|
|
||||||
trueValueAt: (t) => 130000 - t * 1.002,
|
|
||||||
});
|
|
||||||
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
|
|
||||||
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
|
|
||||||
// a backwards step (the separate regression check still demands zero).
|
|
||||||
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 6. Idle timers pass through untouched. */
|
|
||||||
{
|
|
||||||
const phase = createTimerPhase();
|
|
||||||
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
|
|
||||||
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
|
|
||||||
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
|
|
||||||
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures) {
|
|
||||||
console.error(`\n${failures} of ${checks} checks FAILED`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`phase.selftest: all ${checks} checks passed`);
|
|
||||||
@@ -82,7 +82,6 @@ const http = require('http');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener } = require('./ltc-listener');
|
const { createLtcListener } = require('./ltc-listener');
|
||||||
const { createTimerPhase } = require('./phase');
|
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
||||||
@@ -210,7 +209,6 @@ function sanitizeTimer(t) {
|
|||||||
label: String(t.label || id).slice(0, 120),
|
label: String(t.label || id).slice(0, 120),
|
||||||
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
||||||
state: STATES.has(t.state) ? t.state : 'stopped',
|
state: STATES.has(t.state) ? t.state : 'stopped',
|
||||||
asOf: numOrNull(t.asOf),
|
|
||||||
startedAt: numOrNull(t.startedAt),
|
startedAt: numOrNull(t.startedAt),
|
||||||
elapsedMs: numOrNull(t.elapsedMs),
|
elapsedMs: numOrNull(t.elapsedMs),
|
||||||
remainingMs: numOrNull(t.remainingMs),
|
remainingMs: numOrNull(t.remainingMs),
|
||||||
@@ -252,8 +250,6 @@ function createProPresenterSource({ config, log }) {
|
|||||||
let updatedAt = 0;
|
let updatedAt = 0;
|
||||||
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
||||||
let timerConfigs = new Map();
|
let timerConfigs = new Map();
|
||||||
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
|
||||||
const phases = new Map();
|
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${target}`);
|
const built = new URL(`http://${host}:${port}${target}`);
|
||||||
@@ -329,21 +325,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
||||||
state = atStart ? 'idle' : 'paused';
|
state = atStart ? 'idle' : 'paused';
|
||||||
}
|
}
|
||||||
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
|
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||||
// local count at the bottom of that second every poll made the digits run
|
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
||||||
// ahead and flick back a step about once a second — phase.js turns the
|
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
|
||||||
// reports into one stable anchor { valueMs, asOf } the tiles count from.
|
|
||||||
let phase = phases.get(uuid);
|
|
||||||
if (!phase) {
|
|
||||||
phase = createTimerPhase();
|
|
||||||
phases.set(uuid, phase);
|
|
||||||
}
|
|
||||||
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
|
|
||||||
const v = anchored.valueMs;
|
|
||||||
const asOf = anchored.asOf;
|
|
||||||
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
|
||||||
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
|
|
||||||
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
|
|
||||||
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
||||||
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
||||||
return {
|
return {
|
||||||
@@ -351,7 +335,6 @@ function createProPresenterSource({ config, log }) {
|
|||||||
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
||||||
kind,
|
kind,
|
||||||
state,
|
state,
|
||||||
asOf,
|
|
||||||
startedAt,
|
startedAt,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
@@ -384,9 +367,6 @@ function createProPresenterSource({ config, log }) {
|
|||||||
// Timers reported live but missing from the (possibly stale) config
|
// Timers reported live but missing from the (possibly stale) config
|
||||||
// list still deserve a card — type unknown until the slow lane catches up.
|
// list still deserve a card — type unknown until the slow lane catches up.
|
||||||
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
|
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
|
||||||
for (const uuid of phases.keys()) {
|
|
||||||
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,17 +992,12 @@ module.exports = {
|
|||||||
for (const t of v.timers) {
|
for (const t of v.timers) {
|
||||||
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
|
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
|
||||||
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
|
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
|
||||||
// A timer measured at its own instant (asOf — a phase-locked
|
if (t.state === 'running' && v.updatedAt > 0) {
|
||||||
// ProPresenter value) re-anchors from there; the rest from the
|
const dt = Math.max(0, now - v.updatedAt);
|
||||||
// source snapshot. Consumers see plain contract values as of `now`.
|
|
||||||
const at = Number(out.asOf) || v.updatedAt;
|
|
||||||
if (t.state === 'running' && at > 0) {
|
|
||||||
const dt = Math.max(0, now - at);
|
|
||||||
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
|
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
|
||||||
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
|
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
|
||||||
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
|
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
|
||||||
}
|
}
|
||||||
delete out.asOf;
|
|
||||||
timers.push(out);
|
timers.push(out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "resi-cues",
|
"id": "resi-cues",
|
||||||
"name": "Resi Cues",
|
"name": "Resi Cues",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||||
"provides": ["timers"],
|
"provides": ["timers"],
|
||||||
|
|||||||
+89
-14
@@ -39,6 +39,9 @@ function msToPosition(total) {
|
|||||||
|
|
||||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||||
|
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
|
||||||
|
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
||||||
|
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||||
@@ -61,6 +64,8 @@ function createPlayhead(options) {
|
|||||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||||
let gaps = []; // how long between the playhead actually moving
|
let gaps = []; // how long between the playhead actually moving
|
||||||
|
let changes = 0; // readings that differed from the one before
|
||||||
|
let rejected = false; // the last gap looked like a pause and was skipped
|
||||||
let base; // the trusted offset
|
let base; // the trusted offset
|
||||||
let lastMedia;
|
let lastMedia;
|
||||||
let lastNewAt;
|
let lastNewAt;
|
||||||
@@ -90,15 +95,25 @@ function createPlayhead(options) {
|
|||||||
spreads = spreads.concat(-delta).slice(-20);
|
spreads = spreads.concat(-delta).slice(-20);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastNewAt !== undefined) {
|
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
|
||||||
|
// from it to the next one is only a fraction of the real cadence. Learning
|
||||||
|
// from it locked in a 2s cadence that rejected every honest 30s gap.
|
||||||
|
if (lastNewAt !== undefined && changes > 0) {
|
||||||
const gap = t - lastNewAt;
|
const gap = t - lastNewAt;
|
||||||
// The gap either side of a pause is not Resi's cadence — recording it
|
// The gap either side of a pause is not Resi's cadence — recording it
|
||||||
// would inflate the estimate and make the next pause slow to notice.
|
// would inflate the estimate and make the next pause slow to notice.
|
||||||
|
// But a pause is one gap; twice in a row is the cadence having changed.
|
||||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
if (gap > 0 && gap < 60000) {
|
||||||
|
if (!worst || gap <= worst * 3 || rejected) {
|
||||||
gaps = gaps.concat(gap).slice(-8);
|
gaps = gaps.concat(gap).slice(-8);
|
||||||
|
rejected = false;
|
||||||
|
} else {
|
||||||
|
rejected = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (lastNewAt !== undefined) changes++;
|
||||||
offsets = offsets.concat({ at: t, off });
|
offsets = offsets.concat({ at: t, off });
|
||||||
lastMedia = mediaMs;
|
lastMedia = mediaMs;
|
||||||
lastNewAt = t;
|
lastNewAt = t;
|
||||||
@@ -118,7 +133,9 @@ function createPlayhead(options) {
|
|||||||
// Only call it paused once the playhead has been still for clearly longer than
|
// Only call it paused once the playhead has been still for clearly longer than
|
||||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||||
// countdown that freezes mid-service is the worse failure of the two.
|
// countdown that freezes mid-service is the worse failure of the two.
|
||||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
// Until a full gap has been measured the cadence is unknown; assume the slowest
|
||||||
|
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
|
||||||
|
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
|
||||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||||
@@ -161,13 +178,43 @@ function createResiClient(options) {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A failed login used to be retried on the very next poll -- once a second, for
|
||||||
|
ever. Resi answers that with 429s, and since the retries never let up, the
|
||||||
|
account never got out of them. So a failure now holds off further attempts,
|
||||||
|
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
|
||||||
|
call in between fails at once with the same reason instead of signing in. */
|
||||||
|
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||||
|
let loginFailures = 0;
|
||||||
|
let loginBlockedUntil = 0;
|
||||||
|
let loginError = '';
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
if (now() < loginBlockedUntil) {
|
||||||
|
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||||
|
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
} catch (err) {
|
||||||
|
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
|
||||||
|
throw new Error(loginError);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
|
||||||
|
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
|
||||||
|
: `Resi login failed (${res.status})`;
|
||||||
|
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||||
|
holdOff(reason, retryAfter * 1000);
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
loginFailures = 0;
|
||||||
|
loginBlockedUntil = 0;
|
||||||
|
loginError = '';
|
||||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||||
const jar = {};
|
const jar = {};
|
||||||
@@ -179,24 +226,49 @@ function createResiClient(options) {
|
|||||||
token = jar.livingas1 || '';
|
token = jar.livingas1 || '';
|
||||||
if (!token) throw new Error('Resi login returned no session cookie');
|
if (!token) throw new Error('Resi login returned no session cookie');
|
||||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||||
loggedInAt = Date.now();
|
loggedInAt = now();
|
||||||
const me = await get('/api_v2.svc/users/me');
|
const me = await get('/api_v2.svc/users/me');
|
||||||
customerId = (me && me.customerId) || '';
|
customerId = (me && me.customerId) || '';
|
||||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||||
return me;
|
return me;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSession() {
|
function holdOff(reason, askedMs) {
|
||||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
loginFailures++;
|
||||||
|
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
|
||||||
|
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
|
||||||
|
loginError = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same hold-off covers every request, not just the login. A 401/403 used to
|
||||||
|
sign in again on the spot, every time: for a login that simply isn't allowed to
|
||||||
|
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
|
||||||
|
after signing in is taken as what it is -- a permission -- and held off like a
|
||||||
|
failed login; only an older session is renewed. A 429 holds off too. */
|
||||||
async function get(path) {
|
async function get(path) {
|
||||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
if (now() < loginBlockedUntil) {
|
||||||
if (res.status === 401 || res.status === 403) {
|
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
||||||
|
throw new Error(`${loginError} — retrying in ${wait}s`);
|
||||||
|
}
|
||||||
|
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||||
|
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
|
||||||
await login();
|
await login();
|
||||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
}
|
||||||
return retry.json();
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const where = path.includes('/venues') ? 'its venues'
|
||||||
|
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
|
||||||
|
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
|
||||||
|
throw new Error(loginError);
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
||||||
|
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
|
||||||
|
throw new Error(loginError);
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -216,7 +288,10 @@ function createResiClient(options) {
|
|||||||
try {
|
try {
|
||||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
// Only an objection to the parameter itself; a refusal or a "slow down"
|
||||||
|
// (401/403/429) is about the login, and dropping it then lost fresh readings.
|
||||||
|
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
|
||||||
|
if (!code || ['401', '403', '429'].includes(code)) throw err;
|
||||||
cacheBust = false;
|
cacheBust = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
|||||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
assert.strictEqual(surroundingCues([], 1000).next, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── starting partway through Resi's cycle ─────────────────────────────── */
|
||||||
|
|
||||||
|
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
|
||||||
|
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
|
||||||
|
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
|
||||||
|
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
|
||||||
|
// and the next cue (and its name) stopped moving on.
|
||||||
|
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
|
||||||
|
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
|
||||||
|
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
|
||||||
|
w.lastPublish = w.t - into;
|
||||||
|
const head = createPlayhead({ now: () => w.t });
|
||||||
|
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
|
||||||
|
let paused = 0;
|
||||||
|
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
|
||||||
|
if (burst) {
|
||||||
|
w.media += 15 * 60000; // a seek, published at once...
|
||||||
|
w.lastPublish = w.t - w.nextCadence;
|
||||||
|
second(); second();
|
||||||
|
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
|
||||||
|
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── the matrix ────────────────────────────────────────────────────────── */
|
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const argv = process.argv.slice(2).map(Number);
|
const argv = process.argv.slice(2).map(Number);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function emptyTimers(now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current = null; // the live handle, shared with routes()
|
let current = null; // the live handle, shared with routes()
|
||||||
|
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
init({ config, log }) {
|
init({ config, log }) {
|
||||||
@@ -146,7 +147,9 @@ module.exports = {
|
|||||||
elapsedMs: null,
|
elapsedMs: null,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
targetMs: Math.max(1000, state.next.ms - from),
|
targetMs: Math.max(1000, state.next.ms - from),
|
||||||
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
|
// The label already is the cue's name, and it is what the card is titled
|
||||||
|
// with; repeating it on the status line only crowded the card.
|
||||||
|
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
|
||||||
detail: state.event || '',
|
detail: state.event || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -271,6 +274,7 @@ module.exports = {
|
|||||||
stateStreams,
|
stateStreams,
|
||||||
timerStreams,
|
timerStreams,
|
||||||
resi,
|
resi,
|
||||||
|
credentialsKey: `${username}\n${password}`,
|
||||||
configured: Boolean(username && password),
|
configured: Boolean(username && password),
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -332,9 +336,21 @@ module.exports = {
|
|||||||
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const client = createResiClient({
|
// The running module's client when it has these credentials, else one kept
|
||||||
username: String(config.username), password: String(config.password),
|
// per login: a fresh client per request signed in on every admin page load,
|
||||||
});
|
// and its own hold-off never applied -- which kept Resi rate-limiting.
|
||||||
|
const username = String(config.username).trim();
|
||||||
|
const key = `${username}\n${config.password}`;
|
||||||
|
let client;
|
||||||
|
if (current && current.resi && current.credentialsKey === key) client = current.resi;
|
||||||
|
else {
|
||||||
|
if (!pickerClient || pickerClient.key !== key) {
|
||||||
|
pickerClient = { key, client: createResiClient({
|
||||||
|
username, password: String(config.password),
|
||||||
|
}) };
|
||||||
|
}
|
||||||
|
client = pickerClient.client;
|
||||||
|
}
|
||||||
const options = [];
|
const options = [];
|
||||||
for (const v of await client.venues()) {
|
for (const v of await client.venues()) {
|
||||||
for (const p of await client.players(v.uuid)) {
|
for (const p of await client.players(v.uuid)) {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
function test(name, fn) {
|
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||||
fn();
|
async function test(name, fn) {
|
||||||
|
await fn();
|
||||||
passed++;
|
passed++;
|
||||||
console.log(` ok ${name}`);
|
console.log(` ok ${name}`);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
|||||||
|
|
||||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
||||||
|
|
||||||
test('signs in once and finds the configured decoder', () => {
|
await test('signs in once and finds the configured decoder', () => {
|
||||||
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||||
const state = handle.state;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.status, 'ok', state.message);
|
assert.strictEqual(state.status, 'ok', state.message);
|
||||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.cues.length, 3);
|
assert.strictEqual(state.cues.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('works out the cue either side of the playhead', () => {
|
await test('works out the cue either side of the playhead', () => {
|
||||||
const state = handle.state;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.next.name, 'Campus Host');
|
assert.strictEqual(state.next.name, 'Campus Host');
|
||||||
assert.strictEqual(state.prev.name, 'Welcome');
|
assert.strictEqual(state.prev.name, 'Welcome');
|
||||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/state answers the same snapshot as the stream', () => {
|
await test('/state answers the same snapshot as the stream', () => {
|
||||||
const res = collect(routes['GET /state']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/stream opens with an event so a new tile paints at once', () => {
|
await test('/stream opens with an event so a new tile paints at once', () => {
|
||||||
const res = collect(routes['GET /stream']);
|
const res = collect(routes['GET /stream']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
||||||
@@ -131,7 +132,7 @@ function collect(route, req) {
|
|||||||
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
|
||||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||||
assert.strictEqual(snap.source, 'resi-cues');
|
assert.strictEqual(snap.source, 'resi-cues');
|
||||||
assert.strictEqual(snap.label, 'Resi');
|
assert.strictEqual(snap.label, 'Resi');
|
||||||
@@ -145,7 +146,7 @@ function collect(route, req) {
|
|||||||
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
|
||||||
// the countdown runs from the cue before it, so a consumer can colour progress
|
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||||
assert.strictEqual(next.targetMs, 450000);
|
assert.strictEqual(next.targetMs, 450000);
|
||||||
assert.strictEqual(next.status.text, 'Campus Host');
|
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
|
||||||
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||||
|
|
||||||
assert.strictEqual(playhead.id, 'playhead');
|
assert.strictEqual(playhead.id, 'playhead');
|
||||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
|||||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a cue fired at the host moves the countdown on the next poll', async () => {
|
await test('a cue fired at the host moves the countdown on the next poll', async () => {
|
||||||
world.position = '00:12:40.000'; // jumped past Campus Host
|
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.next.name, 'Message');
|
assert.strictEqual(state.next.name, 'Message');
|
||||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||||
assert.strictEqual(snap.timers[0].label, 'Message');
|
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
|
||||||
world.eventId = '';
|
world.eventId = '';
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(handle.state.event, '');
|
assert.strictEqual(handle.state.event, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('health speaks about the upstream, not about itself', () => {
|
await test('health speaks about the upstream, not about itself', () => {
|
||||||
const h = handle.health();
|
const h = handle.health();
|
||||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
assert.ok(h.message.length > 0, 'health should say something useful');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unconfigured module idles instead of hammering Resi', () => {
|
await test('an unconfigured module idles instead of hammering Resi', () => {
|
||||||
const before = world.logins;
|
const before = world.logins;
|
||||||
const idle = server.init({ config: {}, log: () => {} });
|
const idle = server.init({ config: {}, log: () => {} });
|
||||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||||
@@ -189,12 +190,90 @@ function collect(route, req) {
|
|||||||
idle.stop();
|
idle.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stop() leaves nothing running', () => {
|
await test('stop() leaves nothing running', () => {
|
||||||
handle.stop();
|
handle.stop();
|
||||||
const res = collect(routes['GET /state']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
|
||||||
|
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
|
||||||
|
await test('a failed login holds off instead of retrying every poll', async () => {
|
||||||
|
const { createResiClient } = require('./resi');
|
||||||
|
let t = 1700000000000;
|
||||||
|
let logins = 0;
|
||||||
|
let limited = true;
|
||||||
|
const client = createResiClient({
|
||||||
|
username: 'a@b.c',
|
||||||
|
password: 'secret',
|
||||||
|
now: () => t,
|
||||||
|
fetch: async (url) => {
|
||||||
|
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||||
|
if (path === '/api/v3/login') {
|
||||||
|
logins++;
|
||||||
|
if (limited) return reply({}, { ok: false, status: 429 });
|
||||||
|
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||||
|
}
|
||||||
|
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||||
|
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
|
||||||
|
return reply({}, { ok: false, status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||||
|
|
||||||
|
assert.match(await poll(), /rate-limiting/);
|
||||||
|
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
|
||||||
|
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
|
||||||
|
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
|
||||||
|
|
||||||
|
limited = false;
|
||||||
|
t += 5 * 60000; // past the longest hold-off
|
||||||
|
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
|
||||||
|
const after = logins;
|
||||||
|
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
|
||||||
|
assert.strictEqual(logins, after, 'a good session is not signed in again');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('a rejected password says so', async () => {
|
||||||
|
const { createResiClient } = require('./resi');
|
||||||
|
const client = createResiClient({
|
||||||
|
username: 'a@b.c', password: 'wrong', now: () => 0,
|
||||||
|
fetch: async () => reply({}, { ok: false, status: 400 }),
|
||||||
|
});
|
||||||
|
await assert.rejects(client.venues(), /check the password/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seen live: media@waters.church signs in fine but may not list venues (403). The
|
||||||
|
// client answered every 403 by signing in again -- a fresh login on every poll.
|
||||||
|
await test('a login that may not see venues is held off, not signed in every poll', async () => {
|
||||||
|
const { createResiClient } = require('./resi');
|
||||||
|
let t = 1700000000000;
|
||||||
|
let logins = 0;
|
||||||
|
let venueCalls = 0;
|
||||||
|
const client = createResiClient({
|
||||||
|
username: 'a@b.c', password: 'secret', now: () => t,
|
||||||
|
fetch: async (url) => {
|
||||||
|
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||||
|
if (path === '/api/v3/login') {
|
||||||
|
logins++;
|
||||||
|
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
|
||||||
|
}
|
||||||
|
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
|
||||||
|
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
|
||||||
|
return reply({}, { ok: false, status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
|
||||||
|
|
||||||
|
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
|
||||||
|
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
|
||||||
|
// At most one renewal once the session is over a minute old (in case it really
|
||||||
|
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
|
||||||
|
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
|
||||||
|
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
|
||||||
|
assert.match(await poll(), /ask a Resi admin/);
|
||||||
|
});
|
||||||
|
|
||||||
globalThis.setInterval = realSetInterval;
|
globalThis.setInterval = realSetInterval;
|
||||||
globalThis.clearInterval = realClearInterval;
|
globalThis.clearInterval = realClearInterval;
|
||||||
console.log(`\n${passed} tests passed`);
|
console.log(`\n${passed} tests passed`);
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
* GET /services/v2/service_types every service type, with its
|
* GET /services/v2/service_types every service type, with its
|
||||||
* parent folder relationship
|
* parent folder relationship
|
||||||
* GET /services/v2/service_types/{id}
|
* GET /services/v2/service_types/{id}
|
||||||
* GET /services/v2/service_types/{id}/teams every team, including one
|
|
||||||
* nobody is scheduled on
|
|
||||||
* GET /services/v2/service_types/{id}/item_note_categories
|
* GET /services/v2/service_types/{id}/item_note_categories
|
||||||
* the note categories the
|
* the note categories the
|
||||||
* items below use, plus one
|
* items below use, plus one
|
||||||
@@ -27,15 +25,6 @@
|
|||||||
* "Starts in" state is visible
|
* "Starts in" state is visible
|
||||||
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||||
* ItemNote compound documents
|
* ItemNote compound documents
|
||||||
* GET …/plans/{id}/team_members?include=… who is scheduled: PlanPerson
|
|
||||||
* records + Team / Person, with
|
|
||||||
* confirmed / unconfirmed /
|
|
||||||
* declined statuses, a team
|
|
||||||
* nobody is on, and people with
|
|
||||||
* and without a photo
|
|
||||||
* GET /avatars/{personId}.svg those photos (initials on a
|
|
||||||
* disc) — the module proxies
|
|
||||||
* them like the real CDN
|
|
||||||
*
|
*
|
||||||
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
|
||||||
* playlist, so running both mocks exercises the ProPresenter matching.
|
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||||
@@ -205,107 +194,6 @@ function itemsDocument(planId, include) {
|
|||||||
|
|
||||||
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
/**
|
|
||||||
* Who is scheduled, as Planning Center returns it from
|
|
||||||
* /plans/{id}/team_members?include=team,person: teams in their own order,
|
|
||||||
* people with a position and a scheduling status ("C" confirmed,
|
|
||||||
* "U" unconfirmed, "D" declined). `photo` false is somebody Planning Center
|
|
||||||
* has no picture for — the tile draws their initials instead.
|
|
||||||
*/
|
|
||||||
const TEAMS = [
|
|
||||||
{
|
|
||||||
id: 't1', name: 'Worship', sequence: 1, members: [
|
|
||||||
{ id: 'tp1', person: 'p1', name: 'Sarah Miles', position: 'Worship Leader', status: 'C', photo: true },
|
|
||||||
{ id: 'tp2', person: 'p2', name: 'Dan Whitfield', position: 'Acoustic Guitar', status: 'C', photo: true },
|
|
||||||
{ id: 'tp3', person: 'p3', name: 'Priya Raman', position: 'Keys', status: 'U', photo: true },
|
|
||||||
{ id: 'tp4', person: 'p4', name: 'Marcus Webb', position: 'Drums', status: 'C', photo: false },
|
|
||||||
{ id: 'tp5', person: 'p5', name: 'Elena Fischer', position: 'Bass', status: 'D', photo: true, decline: 'Out of town' },
|
|
||||||
{ id: 'tp6', person: 'p6', name: 'Joy Adeyemi', position: 'Vocals', status: 'C', photo: true },
|
|
||||||
{ id: 'tp7', person: 'p7', name: 'Tom Brennan', position: 'Vocals', status: 'U', photo: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 't2', name: 'Production', sequence: 2, members: [
|
|
||||||
{ id: 'tp8', person: 'p8', name: 'Chris Nolan', position: 'FOH Audio', status: 'C', photo: true, notes: 'Arrives 7:15 for line check' },
|
|
||||||
{ id: 'tp9', person: 'p9', name: 'Amara Okafor', position: 'ProPresenter', status: 'C', photo: true },
|
|
||||||
{ id: 'tp10', person: 'p10', name: 'Nate Sørensen', position: 'Lighting', status: 'U', photo: false },
|
|
||||||
{ id: 'tp11', person: 'p11', name: 'Bethany Cruz', position: 'Camera 1', status: 'C', photo: true },
|
|
||||||
{ id: 'tp12', person: 'p12', name: 'Owen Hart', position: 'Camera 2', status: 'C', photo: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 't3', name: 'Hosting', sequence: 3, members: [
|
|
||||||
{ id: 'tp13', person: 'p13', name: 'Pastor Mike Alvarez', position: 'MC', status: 'C', photo: true },
|
|
||||||
{ id: 'tp14', person: 'p14', name: 'Grace Lin', position: 'Greeter', status: 'C', photo: true },
|
|
||||||
{ id: 'tp15', person: 'p15', name: 'Sam Okonkwo', position: 'Greeter', status: 'U', photo: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// A team with nobody on it — a tile hides it unless "Teams with nobody" is on.
|
|
||||||
{ id: 't4', name: 'Kids', sequence: 4, members: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** A stand-in avatar: initials on a coloured disc, as an SVG the module can proxy. */
|
|
||||||
function avatarSvg(name) {
|
|
||||||
const words = String(name).trim().split(/\s+/);
|
|
||||||
const initials = (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2)).toUpperCase();
|
|
||||||
let hash = 0;
|
|
||||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) % 360;
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">`
|
|
||||||
+ `<circle cx="48" cy="48" r="48" fill="hsl(${hash} 55% 42%)"/>`
|
|
||||||
+ `<text x="48" y="48" fill="#fff" font-family="system-ui,sans-serif" font-size="38" font-weight="700"`
|
|
||||||
+ ` text-anchor="middle" dominant-baseline="central">${initials}</text></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function teamMembersDocument(planId, include, port) {
|
|
||||||
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
||||||
const data = [];
|
|
||||||
const included = [];
|
|
||||||
const seenTeams = new Set();
|
|
||||||
for (const team of TEAMS) {
|
|
||||||
for (const m of team.members) {
|
|
||||||
data.push({
|
|
||||||
type: 'PlanPerson',
|
|
||||||
id: m.id,
|
|
||||||
attributes: {
|
|
||||||
name: m.name,
|
|
||||||
status: m.status,
|
|
||||||
team_position_name: m.position,
|
|
||||||
notes: m.notes || null,
|
|
||||||
decline_reason: m.decline || null,
|
|
||||||
photo_thumbnail: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
|
||||||
status_updated_at: iso(bootedAt - 3600000),
|
|
||||||
},
|
|
||||||
relationships: {
|
|
||||||
plan: { data: { type: 'Plan', id: planId } },
|
|
||||||
team: { data: { type: 'Team', id: team.id } },
|
|
||||||
person: { data: { type: 'Person', id: m.person } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (wants.has('person')) {
|
|
||||||
included.push({
|
|
||||||
type: 'Person',
|
|
||||||
id: m.person,
|
|
||||||
attributes: {
|
|
||||||
name: m.name,
|
|
||||||
first_name: m.name.split(' ')[0],
|
|
||||||
last_name: m.name.split(' ').slice(-1)[0],
|
|
||||||
avatar: m.photo ? `http://127.0.0.1:${port}/avatars/${m.person}.svg` : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (wants.has('team') && !seenTeams.has(team.id)) {
|
|
||||||
seenTeams.add(team.id);
|
|
||||||
included.push({
|
|
||||||
type: 'Team',
|
|
||||||
id: team.id,
|
|
||||||
attributes: { name: team.name, sequence: team.sequence, schedule_to: 'plan' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { data, included };
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderRecord(f) {
|
function folderRecord(f) {
|
||||||
return {
|
return {
|
||||||
type: 'Folder',
|
type: 'Folder',
|
||||||
@@ -354,16 +242,6 @@ const server = http.createServer((req, res) => {
|
|||||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
console.log(`[pco-mock] ${req.method} ${url.pathname}${url.search}`);
|
||||||
|
|
||||||
// Photos are plain images on Planning Center's CDN — no auth there, none here.
|
|
||||||
const avatar = url.pathname.match(/^\/avatars\/([A-Za-z0-9_-]+)\.svg$/);
|
|
||||||
if (avatar) {
|
|
||||||
const person = [].concat(...TEAMS.map((t) => t.members)).find((m) => m.person === avatar[1]);
|
|
||||||
if (!person) return notFound(res);
|
|
||||||
const body = avatarSvg(person.name);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'public, max-age=3600' });
|
|
||||||
return void res.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
if (!/^(Basic|Bearer) \S+/.test(String(req.headers.authorization || ''))) {
|
||||||
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
return send(res, 401, { errors: [{ code: 'unauthorized', title: 'Unauthorized', status: '401', detail: 'This request could not be authenticated.' }] });
|
||||||
}
|
}
|
||||||
@@ -388,13 +266,6 @@ const server = http.createServer((req, res) => {
|
|||||||
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
const st = SERVICE_TYPES.find((x) => x.id === parts[1]);
|
||||||
if (!st) return notFound(res);
|
if (!st) return notFound(res);
|
||||||
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
if (parts.length === 2) return send(res, 200, { data: serviceTypeRecord(st) });
|
||||||
if (parts[2] === 'teams' && parts.length === 3) {
|
|
||||||
return collection(res, TEAMS.map((t) => ({
|
|
||||||
type: 'Team', id: t.id,
|
|
||||||
attributes: { name: t.name, sequence: t.sequence, schedule_to: 'plan' },
|
|
||||||
relationships: { service_type: { data: { type: 'ServiceType', id: st.id } } },
|
|
||||||
})), url);
|
|
||||||
}
|
|
||||||
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
if (parts[2] === 'item_note_categories' && parts.length === 3) {
|
||||||
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
|
||||||
}
|
}
|
||||||
@@ -415,11 +286,6 @@ const server = http.createServer((req, res) => {
|
|||||||
if (!plan) return notFound(res);
|
if (!plan) return notFound(res);
|
||||||
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
if (parts.length === 4) return send(res, 200, { data: planRecord(plan) });
|
||||||
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
if (parts[4] === 'plan_times') return collection(res, plan.times.map(planTimeRecord), url);
|
||||||
if (parts[4] === 'team_members') {
|
|
||||||
const doc = teamMembersDocument(plan.id, url.searchParams.get('include'), PORT);
|
|
||||||
doc.links = { self: url.pathname + url.search };
|
|
||||||
return send(res, 200, doc);
|
|
||||||
}
|
|
||||||
if (parts[4] === 'items') {
|
if (parts[4] === 'items') {
|
||||||
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||||
doc.links = { self: url.pathname + url.search };
|
doc.links = { self: url.pathname + url.search };
|
||||||
|
|||||||
Reference in New Issue
Block a user