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
@@ -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.
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user