2026-09-24 12:42:57 -04:00
|
|
|
|
/* 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];
|
|
|
|
|
|
|
2026-10-06 14:32:19 -04:00
|
|
|
|
import { viewOf, onViewedType, addServiceTypeSwitch } from "./viewed-type.js";
|
|
|
|
|
|
|
2026-09-24 12:42:57 -04:00
|
|
|
|
export default function createTeams({ root, moduleApi }) {
|
|
|
|
|
|
let state = null;
|
|
|
|
|
|
let feedOffline = false;
|
|
|
|
|
|
let stream = null;
|
|
|
|
|
|
let lastKey = '';
|
2026-10-06 14:32:19 -04:00
|
|
|
|
let view = viewOf(null);
|
|
|
|
|
|
let offViewed = null;
|
2026-09-24 12:42:57 -04:00
|
|
|
|
|
|
|
|
|
|
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 ─────────────────────────────────────────── */
|
|
|
|
|
|
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const switcher = addServiceTypeSwitch({ moduleApi, getState: () => state });
|
|
|
|
|
|
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const teams = Array.isArray(view.teams) ? view.teams : [];
|
2026-09-24 12:42:57 -04:00
|
|
|
|
const wantTeams = chosen('teams');
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
for (const team of teams) {
|
|
|
|
|
|
if (wantTeams && !wantTeams.includes(team.id)) continue;
|
|
|
|
|
|
const members = team.members.filter((m) => {
|
|
|
|
|
|
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…');
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (view.teamsError) return moduleApi.setStatus('error', `Team list: ${view.teamsError}`);
|
|
|
|
|
|
const people = (view.teams || []).reduce((n, t) => n + t.members.length, 0);
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const plan = view.plan || {};
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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 || '',
|
2026-10-06 14:32:19 -04:00
|
|
|
|
servicetype: view.name || '',
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const show = on('showTopBar') && view.plan;
|
2026-09-24 12:42:57 -04:00
|
|
|
|
headEl.hidden = !show;
|
|
|
|
|
|
if (!show) return;
|
|
|
|
|
|
const [line1, line2] = renderTemplate(cfg().headerTemplate, templateValues());
|
2026-10-06 14:32:19 -04:00
|
|
|
|
titleEl.textContent = line1 || line2 || view.name || 'Teams';
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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' },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!state.reachable && !view.plan) {
|
2026-09-24 12:42:57 -04:00
|
|
|
|
return { title: state.lastFetched ? 'Planning Center unavailable' : 'Reading the plan…', hint: state.lastError || '' };
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!view.plan) {
|
2026-09-24 12:42:57 -04:00
|
|
|
|
return {
|
|
|
|
|
|
title: 'No plan found',
|
2026-10-06 14:32:19 -04:00
|
|
|
|
hint: `${view.name || 'This service type'} has no upcoming or recent plans.`,
|
2026-09-24 12:42:57 -04:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-10-06 14:32:19 -04:00
|
|
|
|
if (!(view.teams || []).length) {
|
2026-09-24 12:42:57 -04:00
|
|
|
|
return {
|
|
|
|
|
|
title: 'Nobody scheduled yet',
|
2026-10-06 14:32:19 -04:00
|
|
|
|
hint: view.teamsError || 'This plan has no team members in Planning Center.',
|
2026-09-24 12:42:57 -04:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
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();
|
2026-10-06 14:32:19 -04:00
|
|
|
|
const key = JSON.stringify([view.id, teams, cfg()]);
|
2026-09-24 12:42:57 -04:00
|
|
|
|
if (key === lastKey) return;
|
|
|
|
|
|
lastKey = key;
|
|
|
|
|
|
bodyEl.innerHTML = '';
|
|
|
|
|
|
teams.forEach((team, i) => bodyEl.appendChild(buildTeam(team, i)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
view = viewOf(state);
|
|
|
|
|
|
switcher.sync();
|
2026-09-24 12:42:57 -04:00
|
|
|
|
wrap.classList.toggle('is-offline', feedOffline);
|
2026-10-06 14:32:19 -04:00
|
|
|
|
wrap.classList.toggle('is-other-type', Boolean(state) && !view.live);
|
2026-09-24 12:42:57 -04:00
|
|
|
|
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();
|
2026-10-06 14:32:19 -04:00
|
|
|
|
offViewed = onViewedType(() => { lastKey = ''; render(); });
|
2026-09-24 12:42:57 -04:00
|
|
|
|
render();
|
|
|
|
|
|
},
|
|
|
|
|
|
stop() {
|
2026-10-06 14:32:19 -04:00
|
|
|
|
offViewed?.();
|
|
|
|
|
|
offViewed = null;
|
2026-09-24 12:42:57 -04:00
|
|
|
|
stream?.close();
|
|
|
|
|
|
stream = null;
|
|
|
|
|
|
root.innerHTML = '';
|
|
|
|
|
|
},
|
|
|
|
|
|
onConfigChange() {
|
|
|
|
|
|
// Admin holds the connection only; the server re-inits and re-sends.
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|