Compare commits

...

2 Commits

Author SHA1 Message Date
Niklas Ye 949d6595ba Set the chart's placeholder version to 0.31.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m48s
Release / test (push) Successful in 5s
Release / binaries (push) Successful in 41s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e5b4df7 and 97a4814, so a
tree heading for v0.31.0 doesn't say 0.30.0.
2026-09-27 18:23:43 +02:00
Niklas Ye 33356ca978 Add a global, colour-coded team selector to the nav
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.
2026-09-27 18:14:54 +02:00
9 changed files with 197 additions and 62 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.30.0
appVersion: "v0.30.0"
version: 0.31.0
appVersion: "v0.31.0"
+29 -1
View File
@@ -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
+4
View File
@@ -86,6 +86,9 @@
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
in more than one; teamselector.js fills it in and wires the click. -->
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span>
@@ -126,6 +129,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
<span class="open-pill" id="open-pill" hidden></span>
+4
View File
@@ -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) {
+8
View File
@@ -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 || {})
+20 -32
View File
@@ -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,
));
}
}
+50 -3
View File
@@ -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;
}
+16 -23
View File
@@ -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,25 +177,13 @@ 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);
}
// --- overview --------------------------------------------------------------
+63
View File
@@ -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);
});
}