Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4c357bf5 | ||
|
|
7a736b3db1 | ||
|
|
6c0aee5abf | ||
|
|
6d0f8828c1 |
@@ -323,7 +323,10 @@ 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** — the order of service from Planning Center Services: every
|
- **PCO Plan** — Planning Center Services, as two tiles in the + picker:
|
||||||
|
**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
|
||||||
@@ -342,6 +345,17 @@ 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,6 +684,7 @@ 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,5 +1,9 @@
|
|||||||
/* 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 →
|
||||||
@@ -8,6 +12,8 @@
|
|||||||
(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 */
|
||||||
@@ -15,6 +21,12 @@ 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.6.0",
|
"version": "1.7.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Order of service from Planning Center Services",
|
"description": "Order of service and team assignments from Planning Center Services",
|
||||||
"provides": [
|
"provides": [
|
||||||
"timers"
|
"timers"
|
||||||
],
|
],
|
||||||
@@ -383,5 +383,232 @@
|
|||||||
"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,6 +17,8 @@
|
|||||||
* (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")
|
||||||
*
|
*
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
* 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
|
||||||
@@ -85,6 +88,19 @@ 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;
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +128,8 @@ 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)
|
||||||
@@ -372,6 +390,168 @@ 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 '';
|
||||||
@@ -549,6 +729,98 @@ 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;
|
||||||
@@ -573,6 +845,8 @@ 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();
|
||||||
@@ -589,6 +863,8 @@ 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();
|
||||||
@@ -1293,6 +1569,8 @@ module.exports = {
|
|||||||
timersSnapshot,
|
timersSnapshot,
|
||||||
documentOptions,
|
documentOptions,
|
||||||
noteCategoryOptions,
|
noteCategoryOptions,
|
||||||
|
teamOptions,
|
||||||
|
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1385,6 +1663,28 @@ 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,3 +365,227 @@
|
|||||||
[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; }
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/* 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,10 +123,13 @@ 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(sourceUpdatedAt) || now;
|
const at = Number(timer.asOf) || 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') {
|
||||||
@@ -647,7 +650,21 @@ export default function create({ root, moduleApi }) {
|
|||||||
statusTone = timer.status?.tone || 'muted';
|
statusTone = timer.status?.tone || 'muted';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const value = valueAt(timer, source.updatedAt, now);
|
let 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.1",
|
"version": "1.4.2",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'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 };
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'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,6 +82,7 @@ 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). */
|
||||||
@@ -209,6 +210,7 @@ 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),
|
||||||
@@ -250,6 +252,8 @@ 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}`);
|
||||||
@@ -325,9 +329,21 @@ 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';
|
||||||
}
|
}
|
||||||
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
|
||||||
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
// local count at the bottom of that second every poll made the digits run
|
||||||
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
|
// ahead and flick back a step about once a second — phase.js turns the
|
||||||
|
// 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 {
|
||||||
@@ -335,6 +351,7 @@ 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,
|
||||||
@@ -367,6 +384,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,12 +1012,17 @@ 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' };
|
||||||
if (t.state === 'running' && v.updatedAt > 0) {
|
// A timer measured at its own instant (asOf — a phase-locked
|
||||||
const dt = Math.max(0, now - v.updatedAt);
|
// ProPresenter value) re-anchors from there; the rest from the
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* 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
|
||||||
@@ -25,6 +27,15 @@
|
|||||||
* "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.
|
||||||
@@ -194,6 +205,107 @@ 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',
|
||||||
@@ -242,6 +354,16 @@ 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.' }] });
|
||||||
}
|
}
|
||||||
@@ -266,6 +388,13 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -286,6 +415,11 @@ 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