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
+437
View File
@@ -0,0 +1,437 @@
/* 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 wantPositions = chosen('positions');
const out = [];
for (const team of teams) {
if (wantTeams && !wantTeams.includes(team.id)) continue;
const members = team.members.filter((m) => {
if (wantPositions && !wantPositions.includes(m.position || '(no position)')) return false;
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.
},
};
}