PCO Plan 1.7.0: a Teams tile — who is scheduled, with photos

The module now offers two tiles in the + picker, Plan and Teams, sharing
the one Planning Center connection and the one poll.

Teams draws the current plan's team and position assignments: each team as
a band of its people, with the person's photo, their position and a dot for
confirmed / unconfirmed / declined. Photos are proxied and cached by the
module (GET /team-avatar/<planPersonId>) so no browser ever talks to
Planning Center; a person with no photo gets their initials.

Everything shown is the tile's own choice, so two tiles can differ:
- Teams and Positions — a switch per team and per position, all on until
  someone flips one (GET /teams, GET /positions feed the lists)
- Colours — a picker for the team headers, or Tint teams to give each its
  own, optionally carried into the rows, plus the header fade and pickers
  for the three statuses
- Show — twelve switches (top bar, team headers and counts, avatars,
  positions, group by position, status, notes, declined and unconfirmed
  people, teams nobody is on, columns)

Server: team_members?include=team,person rides a slow lane (re-read on a
plan change, else at most once a minute) and can never fail the plan read —
a lost roster keeps the last good one and says so. The service type's own
team list is read too, cached ten minutes: Planning Center only returns a
team once somebody is scheduled on it, so that list is the only way to show
a team nobody has signed up for.

No shell changes: picker entries carrying their own instanceSchema, the
colour field and multiselects all already exist.

tools/pco-mock.js grows team_members, the service type's teams and stand-in
avatar images, including a declined person, people with no photo and a team
nobody is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 12:42:57 -04:00
co-authored by Claude Opus 5
parent 8ca88ba10b
commit 6d0f8828c1
7 changed files with 1398 additions and 4 deletions
+324
View File
@@ -17,6 +17,9 @@
* (documents ProPresenter has shown or lists — see below)
* GET /note-categories → { options } for a tile's "Item notes" categories:
* the service type's item note categories, by name
* GET /teams → { options } for a Teams tile's "Teams" switches
* GET /positions → { options } for a Teams tile's "Positions" switches
* GET /team-avatar/<planPersonId> → that person's photo, proxied + cached
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
*
@@ -29,6 +32,7 @@
* GET …/plans/{id}/plan_times (service start times)
* GET …/plans/{id}/items?include=song,arrangement,item_notes
* 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,
* this module reads the propresenter-now-next module's own SSE feed over
@@ -85,6 +89,19 @@ const NOW_NEXT_RETRY_MS = 3000;
const NOW_NEXT_MISSING_RETRY_MS = 15000;
/** How many distinct documents seen live are remembered for the admin selects. */
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. */
const NOTE_CATEGORIES_TTL_MS = 10 * MINUTE;
/**
@@ -112,6 +129,8 @@ function clearedState() {
serviceType: { id: '', name: '' },
plan: null,
items: [],
teams: [], // who is scheduled, by team (the Teams tile)
teamsError: '', // why the last team read failed, if it did
live: clearedLive(),
service: clearedService(),
timing: null, // the admin's service-timing choices (no secrets)
@@ -372,6 +391,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. */
function serviceLabel(win) {
if (!win || !win.startsAt) return '';
@@ -549,6 +730,116 @@ 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 })) };
}
/**
* Options for a Teams tile's "Positions" switches. Positions are named,
* not numbered, in Planning Center, so the name is the value; the team
* name groups them so two "Leader" positions stay apart in the list.
*/
function positionOptions() {
const seen = new Set();
const options = [];
for (const team of state.teams) {
for (const m of team.members) {
const value = m.position || '(no position)';
if (seen.has(value)) continue;
seen.add(value);
options.push({ value, label: value, group: team.name });
}
}
return { options };
}
async function readPlan() {
if (!pco.configured || !serviceTypeId) return;
let planRec = null;
@@ -573,6 +864,8 @@ module.exports = {
if (state.plan) log('no plan found any more — service timing reset');
state.plan = null;
state.items = [];
state.teams = [];
state.teamsError = "";
state.reachable = true;
state.lastError = '';
state.lastFetched = Date.now();
@@ -589,6 +882,8 @@ module.exports = {
state.plan = plan;
state.items = 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.lastError = '';
state.lastFetched = Date.now();
@@ -1293,6 +1588,9 @@ module.exports = {
timersSnapshot,
documentOptions,
noteCategoryOptions,
teamOptions,
positionOptions,
avatarUrlFor: (id) => teamPhotos.get(String(id)) || "",
};
return {
@@ -1385,6 +1683,32 @@ module.exports = {
req.on('close', () => timerStreams.delete(res));
},
/**
* Options for a Teams tile's "Teams" and "Positions" 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: [] });
},
'GET /positions': (req, res) => {
json(res, 200, current ? current.positionOptions() : { 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. */
'GET /documents': (req, res) => {
if (!current) return json(res, 200, { options: [{ value: '', label: 'None' }], note: 'Module not running.' });