Compare commits

...

4 Commits

Author SHA1 Message Date
Niklas Ye 0ee576f793 Set the chart's placeholder version to 0.32.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m2s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 1m10s
Release / scan-image (push) Successful in 3s
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 949d659 and e5b4df7, so a
tree heading for v0.32.0 doesn't say 0.31.0.
2026-09-27 22:05:26 +02:00
Niklas Ye b610b1817a Fold the account page's ntfy and password forms behind disclosures
Both sat open by default, competing with the rest of the page for
attention on every visit even though most visits need neither. Team's
rota already has the same problem for its bulk-assign form and solves
it with a native <details>/<summary> disclosure, styled generically in
app.css; this reuses that idiom rather than inventing a JS toggle.

Each section now shows a one-line status (the topic, or whether a
password is set) with the actual form folded under a summary naming
the action ("Set a topic" / "Change topic", "Set a password" /
"Change password"). A successful save closes the fold and confirms
with a toast, since the point of folding is that a saved form goes
back to being just a status line; a validation or API error keeps the
fold open and shows inline, next to the field it's about.

The password section's heading no longer says "Change password" or
"Set a password" itself, since that verb now lives on the summary; it
just says "Password", matching the existing SSO-off case.
2026-09-27 22:04:59 +02:00
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
10 changed files with 252 additions and 103 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: # 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 # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.30.0 version: 0.32.0
appVersion: "v0.30.0" appVersion: "v0.32.0"
+31 -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; 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 { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; } .view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; } .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; } .chips::-webkit-scrollbar { display: none; }
.chip { .chip {
display: inline-flex; align-items: center; gap: 6px;
flex: none; flex: none;
min-height: 34px; padding: 0 12px; min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px; border: 1px solid var(--border-strong); border-radius: 999px;
@@ -611,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
.account-name { font-size: 18px; font-weight: 750; } .account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } .account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; } .pw-form { display: grid; gap: 12px; padding: 16px; }
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
.account-fold .stacked-form { margin-top: 8px; }
.form-ok { .form-ok {
margin: 0; padding: 10px 12px; margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok); background: var(--ok-soft); color: var(--ok);
@@ -645,6 +675,7 @@ kbd {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em; 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 { .nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -784,7 +815,6 @@ kbd {
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; } .stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; } .stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); } .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 /* 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 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"> <img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span> <span>terdut</span>
</a> </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"> <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> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span> <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> <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> <span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button> </button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1> <h1 class="topbar-title" id="topbar-title">Queue</h1>
</div> </div>
<span class="open-pill" id="open-pill" hidden></span> <span class="open-pill" id="open-pill" hidden></span>
+53 -41
View File
@@ -43,9 +43,12 @@ function render() {
// //
// The topic is the whole address — the server it is published to is the // The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks. // install's one ntfy, set in the deployment and not something a user picks.
function notifyStatus(topic) {
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
}
function notifyForm(user) { function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', { const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off', name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, autocapitalize: 'none', spellcheck: false,
@@ -54,29 +57,7 @@ function notifyForm(user) {
}); });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' }); const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the const form = h('form', { class: 'stacked-form' },
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
h('label', {}, h('label', {},
h('span', { text: 'ntfy topic' }), h('span', { text: 'ntfy topic' }),
topic), topic),
@@ -89,25 +70,46 @@ function notifyForm(user) {
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ', 'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'), 'pick something unguessable rather than your name.'),
err, ok, err,
h('div', { class: 'row-actions' }, submit, test), submit,
); );
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
const details = h('details', { class: 'account-fold' }, summary, form);
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
test.disabled = true;
try {
await api.testNotification();
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
} catch (ex) {
toast(ex.message, 'error');
} finally {
test.disabled = false;
}
},
});
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
submit.disabled = true; submit.disabled = true;
try { try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim()); const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops // Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload. // asking for this and the test button appears without a reload.
state.me.user = updated; state.me.user = updated;
ok.textContent = updated.ntfy_topic status.textContent = notifyStatus(updated.ntfy_topic);
? 'Topic saved.' summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_topic; test.hidden = !updated.ntfy_topic;
details.open = false;
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -115,7 +117,11 @@ function notifyForm(user) {
submit.disabled = false; submit.disabled = false;
} }
}); });
return form;
return h('div', { class: 'card pw-form' },
status,
h('div', { class: 'row-actions' }, test),
details);
} }
// With password login switched off a password opens nothing, so somebody who // With password login switched off a password opens nothing, so somebody who
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
]; ];
} }
return [ return [
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
passwordForm(user, hasPassword), passwordForm(user, hasPassword),
]; ];
} }
function passwordForm(user, hasPassword) { function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const current = hasPassword const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true }) ? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null; : null;
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
// A hidden username field lets password managers file the new password // A hidden username field lets password managers file the new password
// under the right account. // under the right account.
const form = h('form', { class: 'card pw-form', autocomplete: 'on' }, const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }), h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current), current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next), h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again), h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, ok, submit, err, submit,
); );
const status = h('p', {
class: 'muted',
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
});
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
const details = h('details', { class: 'account-fold' }, summary, form);
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) { if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.'; err.textContent = 'The new passwords do not match.';
err.hidden = false; err.hidden = false;
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
state.me.has_password = true; state.me.has_password = true;
form.reset(); form.reset();
if (!current) { if (!current) {
// From now on the form needs the current-password field. // From now on the form needs the current-password field, and a fresh
// render already comes up with the fold closed.
render(); render();
toast('Password saved'); toast('Password saved');
return; return;
} }
ok.textContent = 'Password saved. Other devices have been signed out.'; details.open = false;
ok.hidden = false; toast('Password saved. Other devices have been signed out.');
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
submit.disabled = false; submit.disabled = false;
} }
}); });
return form; return h('div', { class: 'card pw-form' }, status, details);
} }
function shortcuts() { function shortcuts() {
+4
View File
@@ -11,6 +11,7 @@ import * as alerts from './alerts.js';
import * as stats from './stats.js'; import * as stats from './stats.js';
import * as account from './account.js'; import * as account from './account.js';
import * as team from './team.js'; import * as team from './team.js';
import * as teamselector from './teamselector.js';
import * as admin from './admin.js'; import * as admin from './admin.js';
import * as adminuser from './adminuser.js'; import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js'; import * as adminteam from './adminteam.js';
@@ -230,6 +231,7 @@ async function boot() {
$('login-form').addEventListener('submit', onLogin); $('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup); $('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu); $('menu-btn').addEventListener('click', openNavMenu);
teamselector.init();
ssoErrorCode = takeSSOError(); ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session. // /signup is the one route that works without a session.
@@ -245,6 +247,7 @@ async function boot() {
await loadAuthConfig(); await loadAuthConfig();
state.me = await api.me(); state.me = await api.me();
await loadTeams(); await loadTeams();
teamselector.render();
// The Admin tab exists only for an administrator. Somebody who types /admin // 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. // anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin; $('nav-admin').hidden = !state.me?.user?.is_admin;
@@ -328,6 +331,7 @@ async function onSignup(e) {
history.replaceState({ depth: 0 }, '', '/'); history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/'); route = parseRoute('/');
await loadTeams(); await loadTeams();
teamselector.render();
$('nav-admin').hidden = !state.me?.user?.is_admin; $('nav-admin').hidden = !state.me?.user?.is_admin;
showApp(); showApp();
} catch (ex) { } catch (ex) {
+8
View File
@@ -98,6 +98,14 @@ export function severityClass(sev) {
return ''; 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. // A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') { export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {}) return Object.entries(labels || {})
+20 -32
View File
@@ -2,8 +2,8 @@
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js'; import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js'; import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID } from './state.js'; import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js'; import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
@@ -27,34 +27,21 @@ const EMPTY = {
}; };
onboarding.onRerender(() => renderList()); 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 filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading let items = null; // null while loading
let error = null; let error = null;
let selected = null; let selected = null;
let cursor = -1; // keyboard position in the list let cursor = -1; // keyboard position in the list
let built = false; 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() { function loadFilter() {
try { try {
const f = sessionStorage.getItem('terdut.queue.filter'); 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 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 // The cached open queue covers every team, so it can only be reused when
// no team filter is applied. // no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query; const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter; const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
const result = cached ? state.open : await api.incidents(query); const result = cached ? state.open : await api.incidents(query);
await onboarding.load(); await onboarding.load();
if (requested !== filter) return; if (requested !== filter) return;
@@ -136,19 +123,20 @@ function renderChips() {
class: 'chip', class: 'chip',
type: 'button', type: 'button',
role: 'tab', role: 'tab',
'aria-selected': String(teamFilter === ''), 'aria-selected': String(state.selectedTeamID == null),
onclick: () => setTeamFilter(''), onclick: () => setSelectedTeam(null),
text: 'All teams', }, h('span', { class: 'team-dot' }), ' All teams'));
}));
for (const team of state.teams) { for (const team of state.teams) {
chips.push(h('button', { chips.push(h('button', {
class: 'chip', class: 'chip',
type: 'button', type: 'button',
role: 'tab', role: 'tab',
'aria-selected': String(teamFilter === String(team.id)), 'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setTeamFilter(String(team.id)), onclick: () => setSelectedTeam(team.id),
text: team.name, },
})); 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: '' } }, auth: { password_login: true, oidc: { enabled: false, name: '' } },
open: [], // the default queue: open, not snoozed open: [], // the default queue: open, not snoozed
teams: [], // the teams the viewer belongs to, each with their role 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 — const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
// which is everybody until somebody makes a second — never has to choose.
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() { 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() { export function myID() {
@@ -36,6 +77,12 @@ export async function users() {
export async function loadTeams() { export async function loadTeams() {
state.teams = await api.teams(); 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; return state.teams;
} }
+17 -24
View File
@@ -19,7 +19,7 @@
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.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'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team'); 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' }, { 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; let teamID = null;
// Which sub-section is open. Remembered rather than passed, because the poll // Which sub-section is open. Remembered rather than passed, because the poll
// loop calls refresh() with no route. // 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 error = null;
let freshKey = null; // an integration key, shown once, until the view is left 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) { export function show(route) {
const next = route?.tab ?? null; const next = route?.tab ?? null;
// A different sub-section wants different data, so the old answer goes // A different sub-section wants different data, so the old answer goes
@@ -61,13 +71,8 @@ export function show(route) {
refresh(); refresh();
} }
function selectedTeam() {
const teams = state.teams || [];
return teams.find((t) => t.id === teamID) || currentTeam();
}
export async function refresh() { export async function refresh() {
const team = selectedTeam(); const team = currentTeam();
if (!team) { if (!team) {
data = null; data = null;
render(); render();
@@ -172,24 +177,12 @@ function subnav() {
}))); })));
} }
// Only shown to somebody in more than one team, like the queue's filter chips. // Names which team's settings the six sections below belong to. It used to be
// It is above the sections rather than inside one because it changes the // a picker of its own for somebody in more than one team; that job now belongs
// subject of all six. // to the global team selector in the nav, which is what onTeamChange above
// reacts to.
function teamPicker() { function teamPicker() {
if ((state.teams || []).length < 2) { return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
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 -------------------------------------------------------------- // --- 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);
});
}