// 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: 'topic', title: 'Set where your pages go', text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.', done: Boolean(state.me?.user?.ntfy_topic), 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 topic step is the only one this page can finish by itself, and // the only proof that matters is a phone buzzing. step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', { class: 'btn-sm', type: 'button', text: 'Send a test push', disabled: busy, onclick: sendTest, }), )), ); } async function sendTest() { busy = true; try { await api.testNotification(); testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' }; } 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; }