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);
+ });
+}