Files
ProdDash/tools/pco-mock.js
T
Waters ChurchandClaude Fable 5.1 0c4828eb26 pco-plan: note categories as switches, top-bar variables as chips, no text-size field
Item notes get their own gear group, after Colors, with the existing
showNotes switch as the group's on/off in its title row. Its categories
come from Planning Center (GET /note-categories → the service type's
item_note_categories, by name, cached 10 min, an empty list rather than
an error when unconfigured or unreachable) and render as switches, three
across, all on until someone flips one; the tile reads an unset value as
every category, an array as exactly those, and a layout's old
comma-separated string as that list (blank = all).

The top bar template is a `template` field: one chip per placeholder
({series} {part} {serviceType} {date} {shortDate} {time} {length}
{items}) to drag or click into the text, with the help down to the
joining rules. Rendering is unchanged.

Text size leaves the gear window: the title bar's A− / A+ already size
the text and save it with the tile; the client falls back to 14 px when
nothing is saved.

tools/pco-mock.js serves item_note_categories (the categories its notes
use plus "Stage", which none does yet) so the route can be exercised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:35:47 -04:00

305 lines
14 KiB
JavaScript

/**
* Mock Planning Center Services API for developing the pco-plan module
* without a Planning Center account.
*
* node tools/pco-mock.js [port] (default 24700)
* PCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js
*
* Serves JSON:API shapes matching api.planningcenteronline.com/services/v2:
* GET /services/v2/folders nested folders (parent rel)
* GET /services/v2/folders/{id}
* 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}/item_note_categories
* the note categories the
* items below use, plus one
* ("Stage") no note uses yet
* GET /services/v2/service_types/{id}/plans ?filter=future|past, order,
* per_page — today's plan,
* next week's and last week's
* GET …/plans/{id}
* GET …/plans/{id}/plan_times rehearsal + two services; the
* first service starts 90 s
* after the mock boots so the
* "Starts in" state is visible
* GET …/plans/{id}/items?include=… items + Song / Arrangement /
* ItemNote compound documents
*
* Item titles deliberately match band-lineup-display/tmp/pp-mock.js's
* playlist, so running both mocks exercises the ProPresenter matching.
*
* Any request without an Authorization header (Basic or Bearer) gets a
* Planning-Center-style 401, so a missing/blank credential shows up the way
* it would in production. Every request is logged.
*/
'use strict';
const http = require('http');
const PORT = Number.parseInt(process.argv[2] || '24700', 10);
const bootedAt = Date.now();
/* ── data ───────────────────────────────────────────────────────────── */
const FOLDERS = [
{ id: '100', name: 'Campuses', parentId: '' },
{ id: '110', name: 'North Campus', parentId: '100' },
{ id: '120', name: 'Downtown', parentId: '100' },
{ id: '200', name: 'Archive', parentId: '' },
];
const SERVICE_TYPES = [
{ id: '1001', name: 'Sunday Service', frequency: 'Every week', parentId: '110' },
{ id: '1002', name: 'Wednesday Night', frequency: 'Every week', parentId: '110' },
{ id: '1003', name: 'Downtown Sunday', frequency: 'Every week', parentId: '120' },
{ id: '1004', name: 'Christmas Eve 2024', frequency: 'Every 12 months', parentId: '200' },
{ id: '1005', name: 'Youth Night', frequency: 'Every 2 weeks', parentId: '' },
];
function iso(ms) {
return new Date(ms).toISOString();
}
function longDate(ms) {
return new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
function shortDate(ms) {
return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const DAY = 86400000;
const firstServiceStart = bootedAt + 90 * 1000;
/** Plans for service type 1001: last week, today, next week. */
function plansFor(stId) {
if (stId !== '1001' && stId !== '1003') return [];
const mk = (id, serviceStart, title, series) => ({
id,
serviceTypeId: stId,
title,
series,
sortDate: serviceStart,
times: [
{ id: `${id}-r`, name: 'Rehearsal', type: 'rehearsal', start: serviceStart - 60 * 60000, end: serviceStart - 15 * 60000 },
{ id: `${id}-s1`, name: 'First Service', type: 'service', start: serviceStart, end: serviceStart + 75 * 60000 },
{ id: `${id}-s2`, name: 'Second Service', type: 'service', start: serviceStart + 120 * 60000, end: serviceStart + 195 * 60000 },
],
});
return [
mk(`${stId}-P1`, firstServiceStart - 7 * DAY, 'Part 1', 'Living Hope'),
mk(`${stId}-P2`, firstServiceStart, 'Part 2', 'Living Hope'),
mk(`${stId}-P3`, firstServiceStart + 7 * DAY, 'Part 3', 'Living Hope'),
];
}
function planRecord(p) {
const items = ITEMS.filter((it) => it.type !== 'header');
return {
type: 'Plan',
id: p.id,
attributes: {
title: p.title,
series_title: p.series,
dates: longDate(p.sortDate),
short_dates: shortDate(p.sortDate),
sort_date: iso(p.sortDate),
total_length: items.reduce((s, it) => s + it.length, 0),
items_count: ITEMS.length,
plan_notes_count: 0,
planning_center_url: `https://services.planningcenteronline.com/plans/${p.id}`,
},
relationships: { service_type: { data: { type: 'ServiceType', id: p.serviceTypeId } } },
links: { self: `/services/v2/service_types/${p.serviceTypeId}/plans/${p.id}` },
};
}
// Titles match pp-mock's playlist: Welcome, This Is The Day, Sermon: Living
// Hope, Bumper Video, Closing Song, Benediction.
const ITEMS = [
{ id: 'i1', type: 'header', title: 'Pre-Service', pos: 'pre', length: 0 },
{ id: 'i2', type: 'media', title: 'Countdown', pos: 'pre', length: 300, desc: 'Start 5 min before', notes: [['Video', 'Loop the countdown from 8:55']] },
{ id: 'i3', type: 'header', title: 'Worship', pos: 'during', length: 0 },
{ id: 'i4', type: 'song', title: 'This Is The Day', pos: 'during', length: 270, key: 'G', song: { id: 's1', title: 'This Is The Day', author: 'Fred Hammond' }, arr: 'Default Arrangement', notes: [['Audio', 'Lead vocal: Sarah'], ['Lighting', 'Bright — house at 40%']] },
{ id: 'i5', type: 'song', title: 'Goodness of God', pos: 'during', length: 320, key: 'A', song: { id: 's2', title: 'Goodness of God', author: 'Bethel Music', ccli: '7117726' }, arr: 'Acoustic', notes: [['ProPresenter', 'Use the [ Full ] arrangement']] },
{ id: 'i6', type: 'item', title: 'Welcome', pos: 'during', length: 180, desc: 'Pastor Mike — mention Fall Kickoff', notes: [['Audio', 'Handheld 2 hot']] },
{ id: 'i7', type: 'media', title: 'Bumper Video', pos: 'during', length: 60, notes: [['Video', 'Program to Playback A']] },
{ id: 'i8', type: 'header', title: 'Message', pos: 'during', length: 0 },
{ id: 'i9', type: 'item', title: 'Sermon: Living Hope', pos: 'during', length: 2100, desc: '1 Peter 1:3-9', notes: [['ProPresenter', 'Sermon slides from PCO'], ['Lighting', 'Stage wash, house at 20%']] },
{ id: 'i10', type: 'song', title: 'Closing Song', pos: 'during', length: 240, key: 'D', song: { id: 's3', title: 'Build My Life', author: 'Housefires' }, arr: 'Default Arrangement' },
{ id: 'i11', type: 'item', title: 'Benediction', pos: 'during', length: 90 },
{ id: 'i12', type: 'header', title: 'Post-Service', pos: 'post', length: 0 },
{ id: 'i13', type: 'media', title: 'Walk-out Loop', pos: 'post', length: 600, notes: [['Video', 'Announcements loop until the room clears']] },
];
/**
* Item note categories, per service type in Planning Center: every category
* the items above use, in the order the mock account lists them, plus one no
* note uses yet — a tile that has never chosen shows it on as well.
*/
const NOTE_CATEGORIES = ['Audio', 'Video', 'Lighting', 'ProPresenter', 'Stage'];
function noteCategoryRecord(name, index, stId) {
return {
type: 'ItemNoteCategory',
id: `${stId}-nc${index + 1}`,
attributes: { name, sequence: index + 1, frequently_used: index < 2, deleted_at: null },
relationships: { service_type: { data: { type: 'ServiceType', id: stId } } },
};
}
function itemsDocument(planId, include) {
const wants = new Set(String(include || '').split(',').map((s) => s.trim()).filter(Boolean));
const data = [];
const included = [];
ITEMS.forEach((it, index) => {
const relationships = {};
if (it.song) {
relationships.song = { data: { type: 'Song', id: it.song.id } };
if (wants.has('song')) {
included.push({ type: 'Song', id: it.song.id, attributes: { title: it.song.title, author: it.song.author, ccli_number: it.song.ccli ? Number(it.song.ccli) : null } });
}
if (it.arr) {
const arrId = `${it.song.id}-arr`;
relationships.arrangement = { data: { type: 'Arrangement', id: arrId } };
if (wants.has('arrangement')) included.push({ type: 'Arrangement', id: arrId, attributes: { name: it.arr } });
}
}
const noteRefs = (it.notes || []).map((n, i) => ({ type: 'ItemNote', id: `${it.id}-n${i}` }));
relationships.item_notes = { data: noteRefs };
if (wants.has('item_notes')) {
(it.notes || []).forEach(([category, content], i) => {
included.push({ type: 'ItemNote', id: `${it.id}-n${i}`, attributes: { category_name: category, content } });
});
}
data.push({
type: 'Item',
id: `${planId}-${it.id}`,
attributes: {
title: it.title,
sequence: index + 1,
item_type: it.type,
length: it.length,
service_position: it.pos,
description: it.desc || '',
html_details: '',
key_name: it.key || null,
},
relationships,
});
});
return { data, included, meta: { total_count: data.length, count: data.length } };
}
/* ── JSON:API plumbing ──────────────────────────────────────────────── */
function folderRecord(f) {
return {
type: 'Folder',
id: f.id,
attributes: { name: f.name, container: f.parentId ? 'Folder' : 'Organization' },
relationships: { parent: { data: f.parentId ? { type: 'Folder', id: f.parentId } : null } },
};
}
function serviceTypeRecord(st) {
return {
type: 'ServiceType',
id: st.id,
attributes: { name: st.name, frequency: st.frequency, sequence: Number(st.id) },
relationships: { parent: { data: st.parentId ? { type: 'Folder', id: st.parentId } : null } },
};
}
function planTimeRecord(t) {
return {
type: 'PlanTime',
id: t.id,
attributes: { name: t.name, time_type: t.type, starts_at: iso(t.start), ends_at: iso(t.end), live_starts_at: null, live_ends_at: null },
};
}
function send(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/vnd.api+json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
function collection(res, records, url) {
send(res, 200, {
links: { self: url.pathname + url.search },
data: records,
included: [],
meta: { total_count: records.length, count: records.length },
});
}
function notFound(res) {
send(res, 404, { errors: [{ code: 'not_found', title: 'Not Found', status: '404', detail: 'The resource you requested could not be found' }] });
}
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}`);
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.' }] });
}
const parts = url.pathname.replace(/^\/services\/v2\/?/, '').split('/').filter(Boolean);
if (!url.pathname.startsWith('/services/v2')) return notFound(res);
// /folders[/{id}]
if (parts[0] === 'folders') {
if (parts.length === 1) return collection(res, FOLDERS.map(folderRecord), url);
const f = FOLDERS.find((x) => x.id === parts[1]);
if (!f) return notFound(res);
if (parts.length === 2) return send(res, 200, { data: folderRecord(f) });
if (parts[2] === 'folders') return collection(res, FOLDERS.filter((x) => x.parentId === f.id).map(folderRecord), url);
if (parts[2] === 'service_types') return collection(res, SERVICE_TYPES.filter((x) => x.parentId === f.id).map(serviceTypeRecord), url);
return notFound(res);
}
// /service_types[/{id}[/plans[/{pid}[/plan_times|items]]]]
if (parts[0] === 'service_types') {
if (parts.length === 1) return collection(res, SERVICE_TYPES.map(serviceTypeRecord), url);
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] === 'item_note_categories' && parts.length === 3) {
return collection(res, NOTE_CATEGORIES.map((name, i) => noteCategoryRecord(name, i, st.id)), url);
}
if (parts[2] !== 'plans') return notFound(res);
const plans = plansFor(st.id);
if (parts.length === 3) {
const filter = url.searchParams.get('filter') || '';
const now = Date.now();
let list = plans.slice();
if (filter === 'future') list = list.filter((p) => p.sortDate >= new Date(now).setHours(0, 0, 0, 0));
else if (filter === 'past') list = list.filter((p) => p.sortDate < now);
const order = url.searchParams.get('order') || 'sort_date';
list.sort((a, b) => (order.startsWith('-') ? b.sortDate - a.sortDate : a.sortDate - b.sortDate));
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('per_page')) || 25));
return collection(res, list.slice(0, perPage).map(planRecord), url);
}
const plan = plans.find((p) => p.id === parts[3]);
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] === 'items') {
const doc = itemsDocument(plan.id, url.searchParams.get('include'));
doc.links = { self: url.pathname + url.search };
return send(res, 200, doc);
}
return notFound(res);
}
notFound(res);
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[pco-mock] Planning Center mock on http://127.0.0.1:${PORT}/services/v2`);
console.log(`[pco-mock] today's plan "${plansFor('1001')[1].title}" — first service starts at ${new Date(firstServiceStart).toLocaleTimeString()}`);
console.log('[pco-mock] run ProdDash with: PCO_PLAN_API_BASE=http://127.0.0.1:%d node server.js', PORT);
});