b114251f45
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
152 lines
5.2 KiB
JavaScript
152 lines
5.2 KiB
JavaScript
// The first-run checklist: the four things a new install or a new person has
|
||
// to do before an alert reaches a phone.
|
||
//
|
||
// It is computed from what the server already knows rather than from stored
|
||
// progress — a topic is set or it is not, an integration exists or it does not
|
||
// — so it cannot claim a step is done when it is not, and it comes back by
|
||
// itself if somebody deletes their integration a month later.
|
||
//
|
||
// Dismissal is the one piece of state, kept per user so finishing on a laptop
|
||
// does not leave the phone nagging.
|
||
|
||
import * as api from './api.js';
|
||
import { h, clear, spinner } from './ui.js';
|
||
import { state, currentTeam } from './state.js';
|
||
import { navigate } from './app.js';
|
||
import { isoDate } from './format.js';
|
||
|
||
let steps = null;
|
||
let error = null;
|
||
let busy = false;
|
||
let testResult = null;
|
||
|
||
// done() is deliberately a question about the world, not a flag: each step asks
|
||
// the data whether it happened.
|
||
export async function load() {
|
||
const team = currentTeam();
|
||
if (!team) {
|
||
steps = null;
|
||
return;
|
||
}
|
||
try {
|
||
const [schedule, integrations, alerts] = await Promise.all([
|
||
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
|
||
api.integrations(team.id),
|
||
api.alerts({ limit: 1 }),
|
||
]);
|
||
steps = [
|
||
{
|
||
id: 'channel',
|
||
title: 'Set where your pages go',
|
||
text: 'A notification channel on your account. Without one, incidents assigned to you page the team’s fallback instead of you.',
|
||
done: Boolean(firstChannel()),
|
||
action: { label: 'Account', go: '/more' },
|
||
},
|
||
{
|
||
id: 'rota',
|
||
title: 'Put somebody on call',
|
||
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
|
||
done: (schedule || []).length > 0,
|
||
action: { label: 'Team', go: '/team' },
|
||
},
|
||
{
|
||
id: 'integration',
|
||
title: 'Create an alert source',
|
||
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
|
||
done: (integrations || []).length > 0,
|
||
action: { label: 'Team', go: '/team' },
|
||
},
|
||
{
|
||
id: 'alert',
|
||
title: 'Send a test alert',
|
||
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
|
||
done: (alerts || []).length > 0,
|
||
action: { label: 'How', go: '/team' },
|
||
},
|
||
];
|
||
error = null;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
}
|
||
|
||
// visible reports whether there is anything worth showing: something undone,
|
||
// and not dismissed.
|
||
export function visible() {
|
||
if (!steps || state.me?.onboarding_dismissed) return false;
|
||
return steps.some((s) => !s.done);
|
||
}
|
||
|
||
export function card() {
|
||
if (!visible()) return null;
|
||
const remaining = steps.filter((s) => !s.done).length;
|
||
|
||
return h('div', { class: 'card onboarding' },
|
||
h('div', { class: 'onboarding-head' },
|
||
h('h2', { text: 'Finish setting up' }),
|
||
h('span', { class: 'muted small', text: `${remaining} left` }),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Hide',
|
||
title: 'Hide this checklist for good',
|
||
onclick: async () => {
|
||
try {
|
||
await api.dismissOnboarding(true);
|
||
if (state.me) state.me.onboarding_dismissed = true;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
rerender();
|
||
},
|
||
})),
|
||
error && h('p', { class: 'load-error', text: error }),
|
||
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
|
||
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
|
||
);
|
||
}
|
||
|
||
function stepRow(step) {
|
||
return h('li', { class: step.done ? 'step done' : 'step' },
|
||
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
|
||
h('div', {},
|
||
h('strong', { text: step.title }),
|
||
h('p', { class: 'muted small', text: step.text }),
|
||
!step.done && h('div', { class: 'step-actions' },
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: step.action.label,
|
||
onclick: () => navigate(step.action.go),
|
||
}),
|
||
// The channel step is the only one this page can finish by itself, and
|
||
// the only proof that matters is a phone buzzing.
|
||
step.id === 'channel' && firstChannel() && h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Send a test',
|
||
disabled: busy,
|
||
onclick: sendTest,
|
||
}),
|
||
)),
|
||
);
|
||
}
|
||
|
||
// The first channel that is switched on: the one a page would reach first.
|
||
const firstChannel = () => (state.me?.user?.channels || []).find((c) => c.enabled);
|
||
|
||
async function sendTest() {
|
||
busy = true;
|
||
try {
|
||
const channel = firstChannel();
|
||
await api.testChannel(state.me.user.id, channel.id);
|
||
testResult = { ok: true, text: 'Sent. If nothing arrives, the address is wrong or the server cannot reach the provider.' };
|
||
} catch (err) {
|
||
testResult = { ok: false, text: err.message };
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
rerender();
|
||
}
|
||
|
||
// The queue owns the card's place on the page, so ask it to redraw rather than
|
||
// reaching into its list.
|
||
let rerender = () => {};
|
||
export function onRerender(fn) {
|
||
rerender = fn;
|
||
}
|