// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md // §2.4, §2.8, §2.9). // // Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth // splitting here specifically: this screen's job is to tell an operator the // difference between "the shard has no Teams" and "core has not been able to ask // for two hours", and those two produce almost the same page. Getting that // wording right is logic, not markup. /** Tones the screen uses. Names, not colours — the view maps them. */ export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' } /** * How to describe the projection's freshness. * * The four states are genuinely different and an operator needs to tell them * apart: * * - no provider registered — nothing to sync, and not a fault; * - never synced — core has an empty projection it has never confirmed, which * must NOT read as "there are no Teams"; * - stale — the projection is real but old, and the reason is usually in * `lastError`; * - current. */ export function freshnessOf(sync = {}) { if (!sync.configured) { return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' } } if (!sync.lastSyncAt) { return { tone: TONE.bad, label: 'Never synced', detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.', } } if (sync.stale) { return { tone: TONE.warn, label: 'Stale', detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`, } } return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` } } /** * A short, human age. Deliberately coarse: this exists so a sentence reads * "confirmed 14 minutes ago", and second-level precision would be false comfort * about a projection whose interval is fifteen minutes. */ export function ago(value) { if (!value) return 'never' const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000)) if (seconds < 90) return 'just now' const minutes = Math.round(seconds / 60) if (minutes < 60) return `${minutes} minutes ago` const hours = Math.round(minutes / 60) if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago` return `${Math.round(hours / 24)} days ago` } /** The status pill for one Team row. */ export function statusOf(team = {}) { if (team.status === 'archived') { return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' } } if (team.hidden && team.hiddenReason === 'reserved_name') { return { tone: TONE.bad, label: 'Hidden — reserved name' } } if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' } return { tone: TONE.ok, label: 'Public' } } /** * What a staff member is told will happen when they press the button. * * The gate is decided server-side from the caller's live role, so this only * describes it. Saying "Request" to a moderator and "Apply" to an admin is what * stops the pending result being a surprise. */ export function gateLabelFor(role, verb) { return role === 'admin' ? verb : `Request ${verb.toLowerCase()}` } /** The three gated actions, for the note under the buttons. */ export const GATED_NOTE = 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change ' + 'is filed for approval. Hiding is not gated — suppression is always safe.' /** A one-line description of a queued request, for the approval queue. */ export function describeRequest(request = {}) { const payload = parsePayload(request.payload) const who = request.requested_username || 'a deleted user' switch (request.action) { case 'unhide': return `${who} asks to publish “${request.team_name}”` case 'display_name_override': return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”` case 'clear_display_name_override': return `${who} asks to clear the display name on “${request.team_name}”` default: return `${who} asks for “${request.action}” on “${request.team_name}”` } } /** * The payload may arrive parsed or as a JSON string depending on the driver, so * this normalises rather than assuming either. The server has the same note. */ export function parsePayload(payload) { if (payload == null) return {} if (typeof payload === 'object') return payload try { return JSON.parse(payload) } catch { return {} } } /** * How a member's leadership should read. * * An override is shown AS an override rather than folded into the answer: staff * looking at a roster need to see that a decision was made, not a fact that looks * like the game's. */ export function leadershipOf(member = {}) { if (!member.leaderOverride) { return { isLeader: Boolean(member.isLeader), overridden: false, note: null } } const granted = member.leaderOverride.effect === 'grant' return { isLeader: granted, overridden: true, note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}` + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}` + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`, } }