From 33356ca978733e26829e26b81068047984d8565e Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sun, 27 Sep 2026 18:14:54 +0200 Subject: [PATCH] Add a global, colour-coded team selector to the nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state.js's currentTeam() was hard-coded to teams[0] and never really meant "the team currently selected" — team.js's settings page and queue.js's filter chips each kept their own separate, unsynchronized notion of "which team" instead, so picking one on one page had no effect on the other. Replaces both with a single state.selectedTeamID, set only through the new setSelectedTeam (persisted in localStorage, unlike the queue's old per-tab sessionStorage filter) and broadcast to listeners via onTeamChange. A new teamselector.js control — a coloured dot plus the team's name, or "All teams" — sits at the top of both the desktop sidebar and the mobile topbar, opening the existing bottom-sheet menu to switch. Shown only once someone is in more than one team, matching every other team-aware control in this app. Colours come from a new teamColorClass() in format.js, hashing a team's id into the six-colour rc1..rc6 palette already used for the rota's per-person chips, so no schema or API change is needed. The queue's team filter chips pick up the same colours. --- internal/web/static/app.css | 30 +++++++++++- internal/web/static/index.html | 4 ++ internal/web/static/js/app.js | 4 ++ internal/web/static/js/format.js | 8 ++++ internal/web/static/js/queue.js | 52 ++++++++------------- internal/web/static/js/state.js | 53 ++++++++++++++++++++-- internal/web/static/js/team.js | 41 +++++++---------- internal/web/static/js/teamselector.js | 63 ++++++++++++++++++++++++++ 8 files changed, 195 insertions(+), 60 deletions(-) create mode 100644 internal/web/static/js/teamselector.js 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); + }); +}