Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 065b557860 | |||
| a8dc89e23d |
@@ -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.40.0
|
version: 0.41.0
|
||||||
appVersion: "v0.40.0"
|
appVersion: "v0.41.0"
|
||||||
|
|||||||
+113
-35
@@ -10,11 +10,11 @@
|
|||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #eff1f4;
|
--surface-2: #eff1f4;
|
||||||
--surface-hover: #f7f8fa;
|
--surface-hover: #f7f8fa;
|
||||||
--border: #e2e5ea;
|
--border: #d9dde4;
|
||||||
--border-strong: #cfd3da;
|
--border-strong: #c5cad3;
|
||||||
--text: #16181d;
|
--text: #16181d;
|
||||||
--muted: #5b626e;
|
--muted: #5b626e;
|
||||||
--faint: #8a909b;
|
--faint: #676d79;
|
||||||
|
|
||||||
--accent: #2f5bd3;
|
--accent: #2f5bd3;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
@@ -72,17 +72,21 @@
|
|||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||||
|
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||||
|
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||||
|
a declaration list between a media query and an attribute selector. */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
--surface: #171a20;
|
--surface: #1a1e26;
|
||||||
--surface-2: #1f232b;
|
--surface-2: #232834;
|
||||||
--surface-hover: #1c2027;
|
--surface-hover: #20252e;
|
||||||
--border: #2a2f38;
|
--border: #343b49;
|
||||||
--border-strong: #394050;
|
--border-strong: #444d5f;
|
||||||
--text: #e7e9ed;
|
--text: #e7e9ed;
|
||||||
--muted: #a0a7b3;
|
--muted: #a0a7b3;
|
||||||
--faint: #737a87;
|
--faint: #8a92a0;
|
||||||
|
|
||||||
--accent: #6d8ff0;
|
--accent: #6d8ff0;
|
||||||
--accent-text: #0b0d12;
|
--accent-text: #0b0d12;
|
||||||
@@ -108,6 +112,42 @@
|
|||||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--surface: #1a1e26;
|
||||||
|
--surface-2: #232834;
|
||||||
|
--surface-hover: #20252e;
|
||||||
|
--border: #343b49;
|
||||||
|
--border-strong: #444d5f;
|
||||||
|
--text: #e7e9ed;
|
||||||
|
--muted: #a0a7b3;
|
||||||
|
--faint: #8a92a0;
|
||||||
|
|
||||||
|
--accent: #6d8ff0;
|
||||||
|
--accent-text: #0b0d12;
|
||||||
|
--accent-soft: #1d2640;
|
||||||
|
|
||||||
|
--crit: #ff6b61;
|
||||||
|
--crit-soft: #3a1c1b;
|
||||||
|
--warn: #f0b140;
|
||||||
|
--warn-soft: #362a14;
|
||||||
|
--info: #74a3ff;
|
||||||
|
--info-soft: #1a2640;
|
||||||
|
--ok: #4cc488;
|
||||||
|
--ok-soft: #15301f;
|
||||||
|
--snooze: #a89bff;
|
||||||
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||||
.open-pill.has-triggered::before { background: var(--crit); }
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
.open-pill.all-acked::before { background: var(--warn); }
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||||
|
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.open-pill.all-clear::before { content: none; }
|
||||||
|
.open-pill .icon { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||||
left sidebar from 900px, where the desktop block below redeclares display
|
left sidebar from 900px, where the desktop block below redeclares display
|
||||||
@@ -290,13 +334,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
|
|
||||||
.nav-team-selector,
|
.nav-team-selector,
|
||||||
.team-selector-mobile {
|
.team-selector-mobile {
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
align-items: center; gap: 8px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
padding: 4px 12px; max-width: 100%;
|
padding: 4px 12px; max-width: 100%;
|
||||||
}
|
}
|
||||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||||
|
|
||||||
@@ -340,7 +384,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
background: var(--surface); color: var(--muted);
|
background: var(--surface); color: var(--muted);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||||
|
.theme-picker { padding: 0 0 8px; }
|
||||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||||
box stays flush with its real right edge regardless of scroll position,
|
box stays flush with its real right edge regardless of scroll position,
|
||||||
@@ -650,7 +695,6 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||||
|
|
||||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
|
||||||
.avatar {
|
.avatar {
|
||||||
flex: none; display: grid; place-items: center;
|
flex: none; display: grid; place-items: center;
|
||||||
width: 44px; height: 44px; border-radius: 50%;
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
@@ -658,8 +702,6 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
|
||||||
.now-name { font-size: 20px; font-weight: 750; }
|
|
||||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||||
is easier to spot there than this plain accent-coloured text. */
|
is easier to spot there than this plain accent-coloured text. */
|
||||||
@@ -673,25 +715,49 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
caught by the unrelated .label > span styling meant for label chips. */
|
caught by the unrelated .label > span styling meant for label chips. */
|
||||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||||
.days { list-style: none; margin: 0 auto; padding: 0; }
|
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||||
.day + .day { border-top: 1px solid var(--border); }
|
.oncall-grid { display: grid; gap: 0; }
|
||||||
.day-name { font-weight: 650; }
|
.oncall-main, .oncall-side { min-width: 0; }
|
||||||
.day-date { color: var(--faint); font-size: 13px; }
|
.hero-list { display: grid; gap: 12px; }
|
||||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||||
/* A run of several days held by the same person (or left empty), replacing
|
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||||
.day.range { grid-template-columns: 1fr auto; }
|
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||||
.day-range { font-weight: 650; }
|
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.day.today { background: var(--accent-soft); }
|
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
|
||||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||||
.day.past { opacity: 0.6; }
|
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||||
read fine layered (today's own row is almost always one of yours too). */
|
crowding the date between them. */
|
||||||
.day.mine { background: var(--accent-soft); }
|
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||||
|
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||||
|
.strip-day {
|
||||||
|
position: relative; min-width: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||||
|
.strip-date { font-size: 15px; font-weight: 650; }
|
||||||
|
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||||
|
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||||
|
.strip-day.past { opacity: 0.6; }
|
||||||
|
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||||
|
reads as a marker and not as a pressed button. The accent stays for things
|
||||||
|
you can act on. */
|
||||||
|
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||||
|
.strip-day.today::before {
|
||||||
|
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||||
|
border-radius: 0 0 3px 3px; background: var(--text);
|
||||||
|
}
|
||||||
|
.strip-day.today .strip-name { color: var(--text); }
|
||||||
|
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||||
|
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||||
@@ -1038,6 +1104,11 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
overflow-x: auto; scrollbar-width: none;
|
overflow-x: auto; scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.subnav::-webkit-scrollbar { display: none; }
|
.subnav::-webkit-scrollbar { display: none; }
|
||||||
|
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||||
|
the desktop width fits every entry, so it is phone-only. */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||||
|
}
|
||||||
.subnav-link {
|
.subnav-link {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 8px 12px; margin-bottom: -1px;
|
padding: 8px 12px; margin-bottom: -1px;
|
||||||
@@ -1097,3 +1168,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||||
|
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||||
|
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/app.css">
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ function render() {
|
|||||||
|
|
||||||
...passwordSection(user, hasPassword),
|
...passwordSection(user, hasPassword),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||||
|
themePicker(),
|
||||||
|
|
||||||
h('div', { class: 'only-desktop' },
|
h('div', { class: 'only-desktop' },
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||||
h('div', { class: 'card' }, shortcuts())),
|
h('div', { class: 'card' }, shortcuts())),
|
||||||
@@ -39,6 +42,23 @@ function render() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System / Light / Dark. Per browser, not per account: it lives in
|
||||||
|
// localStorage (see js/theme.js), the same place the selected team does.
|
||||||
|
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||||
|
|
||||||
|
function themePicker() {
|
||||||
|
const theme = window.terdutTheme;
|
||||||
|
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||||
|
class: 'chip', type: 'button', role: 'radio', text,
|
||||||
|
onclick: () => { theme.set(value); sync(); },
|
||||||
|
}));
|
||||||
|
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||||
|
sync();
|
||||||
|
return h('div', { class: 'card card-pad' },
|
||||||
|
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||||
|
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||||
|
}
|
||||||
|
|
||||||
// Where this user's pages go. The onboarding checklist's first step sends
|
// Where this user's pages go. The onboarding checklist's first step sends
|
||||||
// people here for it, and until now there was nothing here to send them to:
|
// people here for it, and until now there was nothing here to send them to:
|
||||||
// the topic could only be set with curl or by an administrator.
|
// the topic could only be set with curl or by an administrator.
|
||||||
|
|||||||
@@ -102,13 +102,18 @@ function section() {
|
|||||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- overview --------------------------------------------------------------
|
// --- overview --------------------------------------------------------------
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ function updateBadges() {
|
|||||||
|
|
||||||
const pill = $('open-pill');
|
const pill = $('open-pill');
|
||||||
pill.hidden = false;
|
pill.hidden = false;
|
||||||
pill.textContent = open ? `${open} open` : 'All clear';
|
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||||
|
pill.classList.toggle('all-clear', open === 0);
|
||||||
pill.classList.toggle('has-triggered', triggered > 0);
|
pill.classList.toggle('has-triggered', triggered > 0);
|
||||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||||
// Read-only for now; the TUI edits the schedule.
|
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||||
|
// button here.
|
||||||
//
|
//
|
||||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||||
// team has nothing to choose between. "On call now" is the exception and shows
|
// team has nothing to choose between. "On call now" is the exception and shows
|
||||||
@@ -59,11 +60,17 @@ function render() {
|
|||||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const team = currentTeam();
|
||||||
clear(view(),
|
clear(view(),
|
||||||
|
// The top bar carries the title on a phone; the desktop has none.
|
||||||
|
h('div', { class: 'only-desktop oncall-title' },
|
||||||
|
h('h1', { text: 'On-call' }),
|
||||||
|
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
nowCard(),
|
h('div', { class: 'oncall-grid' },
|
||||||
weekCard(),
|
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||||
myShifts(),
|
h('div', { class: 'oncall-side' }, myShifts()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,75 +85,73 @@ function nowCard() {
|
|||||||
const entries = data.now || [];
|
const entries = data.now || [];
|
||||||
const showTeam = entries.length > 1;
|
const showTeam = entries.length > 1;
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return h('div', { class: 'card now-card' },
|
return h('div', { class: 'card hero' },
|
||||||
h('div', { class: 'avatar none', text: '–' }),
|
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||||
h('div', {},
|
h('div', { class: 'hero-top' },
|
||||||
h('div', { class: 'now-label', text: 'On call now' }),
|
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return h('div', {}, ...entries.map((n) =>
|
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||||
h('div', { class: 'card now-card' },
|
const until = shiftEnd(n);
|
||||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
return h('div', { class: 'card hero' },
|
||||||
h('div', {},
|
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||||
h('div', {
|
h('div', { class: 'hero-top' },
|
||||||
class: 'now-label',
|
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
h('div', {},
|
||||||
}),
|
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
until && h('div', { class: 'hero-until', text: until }))));
|
||||||
),
|
}));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups the week's 7 days into runs held by the same person (or the same
|
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||||
// empty slot) — the week's own version of the consecutive-day grouping
|
// end is read off the rota (the run of consecutive days from today held by the
|
||||||
// myShifts does for a single person's own dates, below. Seven identical rows
|
// same person), and that is only loaded for one team. The same midnight
|
||||||
// for one person all week collapses to the one bar this way.
|
// boundary the "Current shift" row below uses.
|
||||||
function weekRuns(byDate) {
|
function shiftEnd(n) {
|
||||||
const runs = [];
|
const team = currentTeam();
|
||||||
for (let i = 0; i < 7; i++) {
|
if (!team || n.team_id !== team.id) return null;
|
||||||
const date = isoDate(addDays(weekStart, i));
|
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||||
const e = byDate.get(date) || null;
|
let day = new Date();
|
||||||
const uid = e ? e.user_id : null;
|
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||||
const last = runs[runs.length - 1];
|
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||||
if (last && last.uid === uid) last.to = date;
|
// Still holding the last day loaded: the shift may run on past it.
|
||||||
else runs.push({ uid, entry: e, from: date, to: date });
|
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||||
}
|
const end = addDays(parse(isoDate(day)), 1);
|
||||||
return runs;
|
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stable colour per person from the six-colour rcN palette. The week page
|
||||||
|
// has no member list to take an index from (team.js does), so the id decides.
|
||||||
|
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||||
|
|
||||||
|
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||||
|
// A hand-over in the middle of the week is visible without reading anything.
|
||||||
function weekCard() {
|
function weekCard() {
|
||||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||||
const today = isoDate(new Date());
|
const today = isoDate(new Date());
|
||||||
const mine = myID();
|
const mine = myID();
|
||||||
const days = weekRuns(byDate).map((r) => {
|
const seen = new Map();
|
||||||
const single = r.from === r.to;
|
const cells = [];
|
||||||
const cls = [
|
for (let i = 0; i < 7; i++) {
|
||||||
'day',
|
const d = addDays(weekStart, i);
|
||||||
single ? '' : 'range',
|
const date = isoDate(d);
|
||||||
r.from <= today && today <= r.to ? 'today' : '',
|
const e = byDate.get(date) || null;
|
||||||
r.to < today ? 'past' : '',
|
if (e) seen.set(e.user_id, e.username);
|
||||||
r.uid === mine ? 'mine' : '',
|
cells.push(h('li', {
|
||||||
].filter(Boolean).join(' ');
|
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||||
const label = single
|
'aria-current': date === today ? 'date' : null,
|
||||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
},
|
||||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||||
: [h('span', {
|
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||||
class: 'day-range',
|
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||||
})];
|
}
|
||||||
return h('li', { class: cls },
|
|
||||||
...label,
|
|
||||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
return [
|
return h('div', { class: 'card week-card' },
|
||||||
h('div', { class: 'page-head' },
|
h('div', { class: 'week-head' },
|
||||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||||
h('div', { class: 'week-nav' },
|
h('div', { class: 'week-nav' },
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||||
icon('chevronLeft')),
|
icon('chevronLeft')),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-ghost week-label',
|
class: 'btn btn-ghost week-label',
|
||||||
@@ -155,12 +160,13 @@ function weekCard() {
|
|||||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||||
icon('chevronRight')),
|
icon('chevronRight')))),
|
||||||
),
|
h('ul', { class: 'strip' }, cells),
|
||||||
),
|
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||||
h('ul', { class: 'card days' }, days),
|
[...seen].map(([id, name]) => h('span', {},
|
||||||
];
|
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||||
|
id === mine ? `${name} (you)` : name))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||||
|
|||||||
@@ -168,13 +168,18 @@ function section() {
|
|||||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names which team's settings the six sections below belong to. It used to be
|
// Names which team's settings the six sections below belong to. It used to be
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Theme override: System (no attribute, the OS decides), Light or Dark.
|
||||||
|
//
|
||||||
|
// A classic script loaded from <head>, not a module, so the saved choice is on
|
||||||
|
// <html> before the first paint and a Dark user on a light OS never sees a
|
||||||
|
// flash. The CSP allows no inline script, hence a file of its own. Account
|
||||||
|
// (account.js) reads and writes the choice through window.terdutTheme.
|
||||||
|
(function () {
|
||||||
|
var KEY = 'terdut.theme';
|
||||||
|
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
try {
|
||||||
|
var v = localStorage.getItem(KEY);
|
||||||
|
return v === 'light' || v === 'dark' ? v : 'system';
|
||||||
|
} catch (e) {
|
||||||
|
return 'system'; // storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser chrome colour follows the choice too; the two <meta>s are
|
||||||
|
// media-keyed to the OS, so a forced theme sets both to the same colour.
|
||||||
|
function apply(theme) {
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (theme === 'system') root.removeAttribute('data-theme');
|
||||||
|
else root.setAttribute('data-theme', theme);
|
||||||
|
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||||
|
for (var i = 0; i < metas.length; i++) {
|
||||||
|
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
|
||||||
|
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(theme) {
|
||||||
|
try {
|
||||||
|
if (theme === 'system') localStorage.removeItem(KEY);
|
||||||
|
else localStorage.setItem(KEY, theme);
|
||||||
|
} catch (e) {
|
||||||
|
/* storage unavailable: applies for this page view only */
|
||||||
|
}
|
||||||
|
apply(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.terdutTheme = { get: get, set: set };
|
||||||
|
apply(get());
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user