diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 45ce6a5..fd75e93 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -261,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; } +/* ---------- team selector ---------- */ +/* The global control for which team the app is scoped to. Hidden (via the + `hidden` attribute, set from teamselector.js) for anybody in fewer than two + teams, the same rule every other team-aware control in this file follows. */ + +.nav-team-selector, +.team-selector-mobile { + display: inline-flex; align-items: center; gap: 8px; + border: 1px solid var(--border-strong); border-radius: 999px; + background: var(--surface); color: var(--text); + font-size: 13px; font-weight: 600; cursor: pointer; + padding: 4px 12px; max-width: 100%; +} +.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; } +.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* A team's identity colour — not a status, so never the severity palette. Six + colours, then they repeat; teamColorClass() in format.js picks one by the + team's id, the same rcN convention the rota's per-person chips use. */ +.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); } +.team-dot.rc1 { background: var(--accent); } +.team-dot.rc2 { background: var(--ok); } +.team-dot.rc3 { background: var(--snooze); } +.team-dot.rc4 { background: var(--warn); } +.team-dot.rc5 { background: var(--teal); } +.team-dot.rc6 { background: var(--pink); } + .view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); } .view-page { padding-left: 16px; padding-right: 16px; } .view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; } @@ -282,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh } .chips::-webkit-scrollbar { display: none; } .chip { + display: inline-flex; align-items: center; gap: 6px; flex: none; min-height: 34px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 999px; @@ -645,6 +673,7 @@ kbd { display: flex; align-items: center; gap: 10px; padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em; } + .nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); } .nav-link { flex-direction: row; justify-content: flex-start; gap: 12px; min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); @@ -784,7 +813,6 @@ kbd { .stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; } .stacked-form label.checkbox { gap: 8px; } .stacked-form input.wide { min-width: min(420px, 100%); } -.team-picker { margin-top: 8px; max-width: 100%; } /* The rota, a month at a time. A name is too wide to print thirty times and too alike down a column to read, so a day carries an initial in that diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 492cd41..3dce7cc 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -86,6 +86,9 @@ terdut + + Queue @@ -126,6 +129,7 @@ +

Queue

diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 5e2cfa4..17ff5c3 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -11,6 +11,7 @@ import * as alerts from './alerts.js'; import * as stats from './stats.js'; import * as account from './account.js'; import * as team from './team.js'; +import * as teamselector from './teamselector.js'; import * as admin from './admin.js'; import * as adminuser from './adminuser.js'; import * as adminteam from './adminteam.js'; @@ -230,6 +231,7 @@ async function boot() { $('login-form').addEventListener('submit', onLogin); $('signup-form').addEventListener('submit', onSignup); $('menu-btn').addEventListener('click', openNavMenu); + teamselector.init(); ssoErrorCode = takeSSOError(); // /signup is the one route that works without a session. @@ -245,6 +247,7 @@ async function boot() { await loadAuthConfig(); state.me = await api.me(); await loadTeams(); + teamselector.render(); // The Admin tab exists only for an administrator. Somebody who types /admin // anyway gets the view's own "ask an administrator" card, not a blank page. $('nav-admin').hidden = !state.me?.user?.is_admin; @@ -328,6 +331,7 @@ async function onSignup(e) { history.replaceState({ depth: 0 }, '', '/'); route = parseRoute('/'); await loadTeams(); + teamselector.render(); $('nav-admin').hidden = !state.me?.user?.is_admin; showApp(); } catch (ex) { diff --git a/internal/web/static/js/format.js b/internal/web/static/js/format.js index 8861f0a..49ed148 100644 --- a/internal/web/static/js/format.js +++ b/internal/web/static/js/format.js @@ -98,6 +98,14 @@ export function severityClass(sev) { return ''; } +// A stable identity colour for a team, so the same team always reads the same +// colour without the server needing to store one. Teams have no colour field; +// this hashes the id into the six-colour rcN palette app.css already has for +// the rota's per-person chips (a team is not a status, so never severity). +export function teamColorClass(teamID) { + return `rc${(((teamID % 6) + 6) % 6) + 1}`; +} + // A one-line summary of the group labels, without the one the title already shows. export function labelSummary(labels, skip = 'alertname') { return Object.entries(labels || {}) diff --git a/internal/web/static/js/queue.js b/internal/web/static/js/queue.js index 16c4923..9bf4480 100644 --- a/internal/web/static/js/queue.js +++ b/internal/web/static/js/queue.js @@ -2,8 +2,8 @@ import * as api from './api.js'; import { h, clear, badge, emptyState, spinner } from './ui.js'; -import { age, until, isFuture, severityClass, labelSummary } from './format.js'; -import { state, myID } from './state.js'; +import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js'; +import { state, myID, setSelectedTeam, onTeamChange } from './state.js'; import * as onboarding from './onboarding.js'; import { navigate } from './app.js'; @@ -27,34 +27,21 @@ const EMPTY = { }; onboarding.onRerender(() => renderList()); +// The queue used to keep its own team filter (a per-tab sessionStorage value, +// out of step with team.js's own picker); both now defer to the global +// selector's shared state, so re-render whenever it changes. +onTeamChange(() => { + renderChips(); + refresh({ fresh: true }); +}); let filter = loadFilter(); -let teamFilter = loadTeamFilter(); // '' for every team the viewer is in let items = null; // null while loading let error = null; let selected = null; let cursor = -1; // keyboard position in the list let built = false; -function loadTeamFilter() { - try { - return sessionStorage.getItem('terdut.queue.team') || ''; - } catch { - return ''; - } -} - -function setTeamFilter(id) { - teamFilter = id; - try { - sessionStorage.setItem('terdut.queue.team', id); - } catch { - /* storage unavailable */ - } - renderChips(); - refresh({ fresh: true }); -} - function loadFilter() { try { const f = sessionStorage.getItem('terdut.queue.filter'); @@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) { // The open list is already fetched for the badges; no need to ask twice. // The cached open queue covers every team, so it can only be reused when // no team filter is applied. - const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query; - const cached = filter === 'open' && !fresh && !teamFilter; + const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query; + const cached = filter === 'open' && !fresh && state.selectedTeamID == null; const result = cached ? state.open : await api.incidents(query); await onboarding.load(); if (requested !== filter) return; @@ -136,19 +123,20 @@ function renderChips() { class: 'chip', type: 'button', role: 'tab', - 'aria-selected': String(teamFilter === ''), - onclick: () => setTeamFilter(''), - text: 'All teams', - })); + 'aria-selected': String(state.selectedTeamID == null), + onclick: () => setSelectedTeam(null), + }, h('span', { class: 'team-dot' }), ' All teams')); for (const team of state.teams) { chips.push(h('button', { class: 'chip', type: 'button', role: 'tab', - 'aria-selected': String(teamFilter === String(team.id)), - onclick: () => setTeamFilter(String(team.id)), - text: team.name, - })); + 'aria-selected': String(team.id === state.selectedTeamID), + onclick: () => setSelectedTeam(team.id), + }, + h('span', { class: `team-dot ${teamColorClass(team.id)}` }), + ' ' + team.name, + )); } } diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js index 522792a..5d806d4 100644 --- a/internal/web/static/js/state.js +++ b/internal/web/static/js/state.js @@ -10,12 +10,53 @@ export const state = { auth: { password_login: true, oidc: { enabled: false, name: '' } }, open: [], // the default queue: open, not snoozed teams: [], // the teams the viewer belongs to, each with their role + // Which team the whole app is scoped to right now; null means "All teams". + // Set only through setSelectedTeam below, never assigned directly, so every + // view stays in sync and the choice is remembered across reloads. + selectedTeamID: loadSelectedTeam(), }; -// The team whose schedule and settings the views act on. A viewer in one team — -// which is everybody until somebody makes a second — never has to choose. +const SELECTED_TEAM_KEY = 'terdut.selectedTeam'; + +function loadSelectedTeam() { + try { + const raw = localStorage.getItem(SELECTED_TEAM_KEY); + return raw ? Number(raw) : null; + } catch { + return null; // storage unavailable, or nothing saved yet + } +} + +// Callbacks to run whenever the selected team changes, so every view that +// cares — the queue's filter, the Team settings page, the selector's own +// trigger — stays in sync without a general event bus, following the one +// precedent for this in the codebase: onboarding.js's onRerender. +const teamListeners = []; +export function onTeamChange(cb) { + teamListeners.push(cb); +} + +// setSelectedTeam changes which team the app is scoped to (id, or null for +// "All teams"), persists it — a durable preference, unlike the per-tab +// sessionStorage filter this replaces — and tells every registered listener. +export function setSelectedTeam(id) { + state.selectedTeamID = id; + try { + if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY); + else localStorage.setItem(SELECTED_TEAM_KEY, String(id)); + } catch { + /* storage unavailable */ + } + for (const cb of teamListeners) cb(); +} + +// The team whose schedule and settings the views act on: the selected team, +// falling back to the first one the viewer belongs to — which is everybody's +// only team until somebody makes a second, or the stored selection naming a +// team the account has since left. export function currentTeam() { - return state.teams[0] || null; + const teams = state.teams || []; + return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null; } export function myID() { @@ -36,6 +77,12 @@ export async function users() { export async function loadTeams() { state.teams = await api.teams(); + // A stored id that no longer names one of the account's teams — left it, or + // this is simply a different account signed in on the same browser — is as + // good as unset. + if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) { + state.selectedTeamID = null; + } return state.teams; } diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index e2e70cb..dc8b56c 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -19,7 +19,7 @@ import * as api from './api.js'; import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js'; -import { state, currentTeam, users as allUsers, myID } from './state.js'; +import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js'; const view = () => document.getElementById('view-team'); @@ -41,6 +41,9 @@ export const TABS = [ { tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' }, ]; +// Cached from currentTeam() on each refresh(), for the many actions below +// (assignSchedule, addTeamMember, ...) that need a plain id rather than a +// round trip through state. let teamID = null; // Which sub-section is open. Remembered rather than passed, because the poll // loop calls refresh() with no route. @@ -49,6 +52,13 @@ let data = null; // { team, ... }; which fields are present varies by tab let error = null; let freshKey = null; // an integration key, shown once, until the view is left +// The global team selector is what changes which team this page shows now; +// re-fetch under whichever sub-section is open when it fires. +onTeamChange(() => { + data = null; + refresh(); +}); + export function show(route) { const next = route?.tab ?? null; // A different sub-section wants different data, so the old answer goes @@ -61,13 +71,8 @@ export function show(route) { refresh(); } -function selectedTeam() { - const teams = state.teams || []; - return teams.find((t) => t.id === teamID) || currentTeam(); -} - export async function refresh() { - const team = selectedTeam(); + const team = currentTeam(); if (!team) { data = null; render(); @@ -172,24 +177,12 @@ function subnav() { }))); } -// Only shown to somebody in more than one team, like the queue's filter chips. -// It is above the sections rather than inside one because it changes the -// subject of all six. +// Names which team's settings the six sections below belong to. It used to be +// a picker of its own for somebody in more than one team; that job now belongs +// to the global team selector in the nav, which is what onTeamChange above +// reacts to. function teamPicker() { - if ((state.teams || []).length < 2) { - return h('div', { class: 'card' }, h('h2', { text: data.team.name })); - } - const select = h('select', { class: 'team-picker' }, - ...state.teams.map((t) => h('option', { - value: String(t.id), text: t.name, selected: t.id === teamID, - }))); - select.addEventListener('change', () => { - teamID = Number(select.value); - data = null; - freshKey = null; - refresh(); - }); - return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select); + return h('div', { class: 'card' }, h('h2', { text: data.team.name })); } // --- overview -------------------------------------------------------------- diff --git a/internal/web/static/js/teamselector.js b/internal/web/static/js/teamselector.js new file mode 100644 index 0000000..0093692 --- /dev/null +++ b/internal/web/static/js/teamselector.js @@ -0,0 +1,63 @@ +// The global team selector: a small control, once per layout (the desktop +// sidebar and the mobile topbar each have their own button in index.html), +// showing the current team's colour and name — or "All teams" — and opening a +// sheet to switch. Shown only once there is more than one team to choose +// between, the same rule every other team-aware control in this app follows; +// see state.js's currentTeam() for why nobody with just one ever has to. + +import { h, openSheet, closeSheet } from './ui.js'; +import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js'; +import { teamColorClass } from './format.js'; + +// Not a real team id (ids are positive), so it can never collide with one — +// the value closeSheet resolves with for "All teams", distinct from the null +// a dismissed sheet resolves with. +const ALL_TEAMS = '__all__'; + +const buttons = () => [ + document.getElementById('team-selector'), + document.getElementById('team-selector-mobile'), +].filter(Boolean); + +// init wires the buttons once, at boot. render (below) is what actually fills +// them in and is called again by state.js whenever the selection changes. +export function init() { + for (const btn of buttons()) btn.addEventListener('click', open); + onTeamChange(render); +} + +export function render() { + const multiTeam = (state.teams || []).length > 1; + const team = currentTeam(); + const label = team ? team.name : 'All teams'; + const dotClass = team ? `team-dot ${teamColorClass(team.id)}` : 'team-dot'; + for (const btn of buttons()) { + btn.hidden = !multiTeam; + btn.replaceChildren( + h('span', { class: dotClass }), + h('span', { class: 'team-selector-label', text: label }), + ); + } +} + +function open() { + const teams = state.teams || []; + openSheet(() => [ + h('h2', { class: 'sheet-title', text: 'Switch team' }), + h('ul', { class: 'menu', role: 'menu' }, + h('li', {}, h('button', { + class: 'menu-item', type: 'button', role: 'menuitemradio', + 'aria-checked': String(state.selectedTeamID == null), + onclick: () => closeSheet(ALL_TEAMS), + }, h('span', { class: 'team-dot' }), ' All teams')), + teams.map((t) => h('li', {}, h('button', { + class: 'menu-item', type: 'button', role: 'menuitemradio', + 'aria-checked': String(t.id === state.selectedTeamID), + onclick: () => closeSheet(t.id), + }, h('span', { class: `team-dot ${teamColorClass(t.id)}` }), ' ' + t.name))), + ), + ]).then((choice) => { + if (choice == null) return; // dismissed: backdrop, escape, or cancel + setSelectedTeam(choice === ALL_TEAMS ? null : choice); + }); +}