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:
co-authored by
Claude Opus 5
parent
8ca88ba10b
commit
6d0f8828c1
@@ -11,6 +11,8 @@
|
||||
* GET /services/v2/service_types every service type, with its
|
||||
* parent folder relationship
|
||||
* 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
|
||||
* the note categories the
|
||||
* items below use, plus one
|
||||
@@ -25,6 +27,15 @@
|
||||
* "Starts in" state is visible
|
||||
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
|
||||
* 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
|
||||
* playlist, so running both mocks exercises the ProPresenter matching.
|
||||
@@ -194,6 +205,107 @@ function itemsDocument(planId, include) {
|
||||
|
||||
/* ── 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) {
|
||||
return {
|
||||
type: 'Folder',
|
||||
@@ -242,6 +354,16 @@ const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||
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 || ''))) {
|
||||
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]);
|
||||
if (!st) return notFound(res);
|
||||
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) {
|
||||
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 (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] === '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') {
|
||||
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
|
||||
doc.links = { self: url.pathname + url.search };
|
||||
|
||||
Reference in New Issue
Block a user