diff --git a/apps/web/src/lab/entries/oncall.ts b/apps/web/src/lab/entries/oncall.ts new file mode 100644 index 0000000000..c99d3b3349 --- /dev/null +++ b/apps/web/src/lab/entries/oncall.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../oncall-lab" + +// `/lab/oncall`: on-call schedules, their form and calendar, chain targets and paged events (`?state=`). + +export const lab = defineShellLab("On-call schedules", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/oncall", + breadcrumbs, +}) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 6c9ed3de41..91f0462182 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -5,13 +5,8 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import * as Animation from "@foldkit/ui/animation" -import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" -import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" -import * as Tooltip from "@maple/views/ui/tooltip" import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" @@ -36,6 +31,7 @@ import { import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { keepLocal } from "./local-commands" // `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker // and the incident rail, over fixtures. `?state=` picks one. @@ -90,27 +86,6 @@ const RULES = [ ruleRow(3, { name: "Payments webhook failures" }), ] -/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ -const LOCAL_COMMANDS: ReadonlySet = new Set([ - LoadChartRuntime.name, - Animation.WaitForPaint.name, - Animation.WaitForAnimationSettled.name, - Dialog.WaitForDialogTransition.name, - Menu.FocusMenuItem.name, - Menu.WaitSubmenuDelay.name, - Menu.WaitForMenuExit.name, - Select.FocusSelectItem.name, - Select.WaitForSelectExit.name, - Popover.WaitHoverDelay.name, - Popover.WaitForPopoverExit.name, - Tooltip.WaitOpenDelay.name, - Tooltip.CheckFocusVisible.name, -]) - -/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ -const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) - /** Runs messages through a page's update, keeping the local commands (a dialog's open animation). */ const fold = ( start: Step, diff --git a/apps/web/src/lab/local-commands.ts b/apps/web/src/lab/local-commands.ts new file mode 100644 index 0000000000..3f8bbcf7f2 --- /dev/null +++ b/apps/web/src/lab/local-commands.ts @@ -0,0 +1,31 @@ +import { Array as Arr } from "effect" +import type { Command } from "foldkit" +import * as Animation from "@foldkit/ui/animation" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import * as Popover from "@maple/views/ui/popover" +import * as Select from "@maple/views/ui/select" +import * as Tooltip from "@maple/views/ui/tooltip" +import type { Resources } from "../platform/resources" + +/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ +const LOCAL_COMMANDS: ReadonlySet = new Set([ + LoadChartRuntime.name, + Animation.WaitForPaint.name, + Animation.WaitForAnimationSettled.name, + Dialog.WaitForDialogTransition.name, + Menu.FocusMenuItem.name, + Menu.WaitSubmenuDelay.name, + Menu.WaitForMenuExit.name, + Select.FocusSelectItem.name, + Select.WaitForSelectExit.name, + Popover.WaitHoverDelay.name, + Popover.WaitForPopoverExit.name, + Tooltip.WaitOpenDelay.name, + Tooltip.CheckFocusVisible.name, +]) + +/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ +export const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) diff --git a/apps/web/src/lab/oncall-lab.test.ts b/apps/web/src/lab/oncall-lab.test.ts new file mode 100644 index 0000000000..bf5a885358 --- /dev/null +++ b/apps/web/src/lab/oncall-lab.test.ts @@ -0,0 +1,16 @@ +import { Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import * as IncidentMessage from "../page/alerts/incident/message" +import { init, Message, update } from "./oncall-lab" + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +describe("on-call lab", () => { + it("drops page writes so fixture ids never reach the API", () => { + const { model } = init({ state: "incident-paged" }) + const incident = (message: IncidentMessage.Message) => + names(update(model, Message.GotIncidentMessage({ message })).commands) + expect(incident(IncidentMessage.Message.ClickedAcknowledge({ acknowledged: true }))).toEqual([]) + }) +}) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts new file mode 100644 index 0000000000..c5e488aba3 --- /dev/null +++ b/apps/web/src/lab/oncall-lab.ts @@ -0,0 +1,493 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallOverrideId } from "@maple/domain/http" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as Menu from "@maple/views/ui/menu" +import { keepLocal } from "./local-commands" +import * as HubMessage from "../page/alerts/hub/message" +import * as HubModel from "../page/alerts/hub/model" +import * as HubUpdate from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import * as IncidentMessage from "../page/alerts/incident/message" +import * as IncidentModel from "../page/alerts/incident/model" +import * as IncidentUpdate from "../page/alerts/incident/update" +import { breadcrumbsOf as incidentBreadcrumbs, view as incidentView } from "../page/alerts/incident/view" +import * as FormMessage from "../page/alerts/schedule-form/message" +import * as FormModel from "../page/alerts/schedule-form/model" +import * as FormUpdate from "../page/alerts/schedule-form/update" +import { view as formView } from "../page/alerts/schedule-form/view" +import * as ScheduleMessage from "../page/alerts/schedule/message" +import * as ScheduleModel from "../page/alerts/schedule/model" +import * as ScheduleUpdate from "../page/alerts/schedule/update" +import { breadcrumbsOf as scheduleBreadcrumbs, view as scheduleView } from "../page/alerts/schedule/view" +import { chainDestinations, chainUuid } from "../page/alerts/shared/escalation-test-fixtures" +import { + checkoutOverrides, + checkoutSchedule, + checkoutShifts, + DST_NOW, + memberId, + mixedTargetChain, + overrideUuid, + ONCALL_NOW, + oncallMembers, + pagedEvents, + populatedSchedules, + schedulePublicId, +} from "../page/alerts/shared/oncall-test-fixtures" +import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" +import { isoMs } from "../page/alerts/shared/time" + +// `/lab/oncall`: on-call schedules in the hub (the On-call tab and the inbox's "On call now" +// strip), the schedule form, the calendar with overrides and a DST week, the chain dialog with +// people and schedules, and an incident timeline with pages. `?state=` picks one. + +export const LabState = Schema.Literals([ + "schedules-empty", + "schedules", + "strip", + "editor-new", + "editor-edit", + "editor-invalid", + "editor-error", + "calendar", + "calendar-dst", + "override-dialog", + "override-delete", + "chain-dialog", + "incident-paged", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "schedules", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + form: FormModel.Model, + schedule: ScheduleModel.Model, + incident: IncidentModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotFormMessage: { message: FormMessage.Message }, + GotScheduleMessage: { message: ScheduleMessage.Message }, + GotIncidentMessage: { message: IncidentMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return +type Step = Readonly<{ model: PM; commands?: ReadonlyArray> }> + +/** The schedule is shown in Berlin; the viewer reads UTC, so the calendar notes both. */ +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const GRANTS = Policy.grantsOf(["org:engineer"]) + +/** Reads and writes are dropped: the fixture is all the data there is. */ + +const fold = ( + start: Step, + messages: ReadonlyArray, + step: (model: PM, message: PS) => Step, +): Step => + Arr.reduce(messages, { model: start.model, commands: keepLocal(start.commands) }, (current, message) => { + const next = step(current.model, message) + return { model: next.model, commands: [...(current.commands ?? []), ...keepLocal(next.commands)] } + }) + +const failure = (message: string): LoadFailure => ({ + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message, + retryable: false, + recovery: "fix_request", +}) + +// HUB + +const hubStep = (model: HubModel.Model, message: HubMessage.Message) => + HubUpdate.update(model, message, CONTEXT) + +const hubFor = (state: LabState): Step => { + const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "oncall" + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab } }, CONTEXT).model + const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW) + const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")] + return fold( + { model: entered }, + [ + HubMessage.Message.ReadClock({ nowMs: ONCALL_NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + HubMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + HubMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(1, { name: "Checkout error rate" }), ruleRow(2)], + }), + HubMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + HubMessage.Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ rule_name: "Checkout p95 latency" })], + }), + HubMessage.Message.CompletedFetchSchedules({ + key: entered.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(schedules), + }), + HubMessage.Message.CompletedFetchRoutingDeliveries({ + key: entered.routingDeliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchDeliveries({ + key: entered.deliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchChains({ + key: entered.chains.key ?? "", + result: Result.succeed(chains), + }), + ...(state === "chain-dialog" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(1)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + ] + : []), + ], + hubStep, + ) +} + +// FORM + +const formStep = (model: FormModel.Model, message: FormMessage.Message) => + FormUpdate.update(model, message, CONTEXT) + +const formFor = (state: LabState): Step => { + const isEdit = state === "editor-edit" || state === "editor-error" + const start = FormUpdate.init( + { _tag: "AlertsScheduleNew", search: isEdit ? { scheduleId: schedulePublicId(1) } : {} }, + CONTEXT, + ) + const base = [ + FormMessage.Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: Intl.supportedValuesOf("timeZone"), + }), + FormMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + FormMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ...(isEdit + ? [ + FormMessage.Message.CompletedFetchExisting({ + key: start.model.existing.key ?? "", + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + ] + : []), + ] + const extra: ReadonlyArray = + state === "editor-invalid" + ? [ + FormMessage.Message.ChangedLayerText({ key: "l0", field: "handoffTime", value: "" }), + FormMessage.Message.ClickedAddRestriction({ key: "l0" }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "mon", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "tue", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "wed", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "thu", + }), + FormMessage.Message.ToggledRestrictionDay({ + key: "l0", + restrictionKey: "r1", + weekday: "fri", + }), + FormMessage.Message.ClickedSave(), + ] + : state === "editor-error" + ? [ + FormMessage.Message.ClickedSave(), + FormMessage.Message.CompletedSave({ + result: Result.fail( + failure("Omar Haddad is no longer a member of this organization."), + ), + }), + ] + : state === "editor-new" + ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] + : [] + return fold(start, [...base, ...extra], formStep) +} + +// SCHEDULE PAGE + +const scheduleStep = (model: ScheduleModel.Model, message: ScheduleMessage.Message) => + ScheduleUpdate.update(model, message, CONTEXT) + +const scheduleFor = (state: LabState): Step => { + const nowMs = state === "calendar-dst" ? DST_NOW : ONCALL_NOW + const start = ScheduleUpdate.init( + { _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, + CONTEXT, + ) + const schedule = checkoutSchedule(nowMs) + const loaded = fold( + start, + [ + ScheduleMessage.Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + ScheduleMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ScheduleMessage.Message.ReadClock({ nowMs }), + ScheduleMessage.Message.CompletedFetchSchedule({ + key: start.model.schedule.key ?? "", + readAtMs: nowMs, + result: Result.succeed(schedule), + }), + ScheduleMessage.Message.CompletedFetchOverrides({ + key: start.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(nowMs)), + }), + ], + scheduleStep, + ) + const fromMs = zonedDayStart(nowMs, schedule.time_zone) + const read = Option.getOrElse(ScheduleUpdate.readRangeOf(loaded.model), () => ({ fromMs, toMs: fromMs })) + const withShifts = fold( + loaded, + [ + ScheduleMessage.Message.CompletedFetchShifts({ + key: loaded.model.shifts.key ?? "", + result: Result.succeed(checkoutShifts(nowMs, read.fromMs, read.toMs)), + }), + ], + scheduleStep, + ) + return state === "override-dialog" + ? fold( + withShifts, + [ + ScheduleMessage.Message.ClickedShift({ + startMs: fromMs + 33 * 3_600_000, + endMs: fromMs + 41 * 3_600_000, + }), + ScheduleMessage.Message.ChangedOverrideUser({ value: memberId(7) }), + ], + scheduleStep, + ) + : state === "override-delete" + ? fold( + withShifts, + [ + ScheduleMessage.Message.ClickedDeleteOverride({ + overrideId: Schema.decodeSync(OnCallOverrideId)(overrideUuid(2)), + }), + ], + scheduleStep, + ) + : withShifts +} + +// INCIDENT + +const incidentStep = (model: IncidentModel.Model, message: IncidentMessage.Message) => + IncidentUpdate.update(model, message, CONTEXT) + +const incidentFor = (): Step => { + const start = IncidentUpdate.init({ _tag: "AlertIncident", incidentId: INCIDENT_ID, search: {} }, CONTEXT) + const row = incidentRow({ rule_name: "Checkout p95 latency" }) + const synced = fold( + start, + [ + IncidentMessage.Message.ReadClock({ nowMs: isoMs(row.first_triggered_at) + 20 * 60_000 }), + IncidentMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + IncidentMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { name: "Checkout p95 latency" })], + }), + IncidentMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + ], + incidentStep, + ) + return fold( + synced, + [ + IncidentMessage.Message.CompletedFetchActivity({ + key: synced.model.activity.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, isoMs(row.first_triggered_at))), + }), + IncidentMessage.Message.CompletedFetchEscalation({ + key: synced.model.escalation.key ?? "", + result: Result.succeed(null), + }), + IncidentMessage.Message.CompletedFetchDeliveries({ + key: synced.model.deliveries.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchInvestigation({ + key: synced.model.investigation.key ?? "", + result: Result.succeed(null), + }), + ], + incidentStep, + ) +} + +const isHub = (state: LabState) => + state === "schedules" || state === "schedules-empty" || state === "strip" || state === "chain-dialog" +const isForm = (state: LabState) => Str.startsWith("editor-")(state) +const isSchedule = (state: LabState) => + Str.startsWith("calendar")(state) || Str.startsWith("override-")(state) + +export const init = (flags: Flags): Return => { + const hub = hubFor(flags.state) + const form = formFor(flags.state) + const schedule = scheduleFor(flags.state) + const incident = incidentFor() + return { + model: { + state: flags.state, + hub: hub.model, + form: form.model, + schedule: schedule.model, + incident: incident.model, + }, + commands: [ + ...(isHub(flags.state) + ? Command.mapMessages(hub.commands ?? [], (message) => Message.GotHubMessage({ message })) + : []), + ...(isForm(flags.state) + ? Command.mapMessages(form.commands ?? [], (message) => Message.GotFormMessage({ message })) + : []), + ...(isSchedule(flags.state) + ? Command.mapMessages(schedule.commands ?? [], (message) => + Message.GotScheduleMessage({ message }), + ) + : []), + ...(flags.state === "incident-paged" + ? Command.mapMessages(incident.commands ?? [], (message) => + Message.GotIncidentMessage({ message }), + ) + : []), + ], + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = HubUpdate.update(model.hub, child, CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotHubMessage({ message: inner }), + ), + } + }, + GotFormMessage: ({ message: child }) => { + const next = FormUpdate.update(model.form, child, CONTEXT) + return { + model: { ...model, form: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotFormMessage({ message: inner }), + ), + } + }, + GotScheduleMessage: ({ message: child }) => { + const next = ScheduleUpdate.update(model.schedule, child, CONTEXT) + return { + model: { ...model, schedule: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotScheduleMessage({ message: inner }), + ), + } + }, + GotIncidentMessage: ({ message: child }) => { + const next = IncidentUpdate.update(model.incident, child, CONTEXT) + return { + model: { ...model, incident: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotIncidentMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => + isHub(model.state) + ? [{ label: "Alerts" }] + : isForm(model.state) + ? [{ label: "Alerts", href: "/alerts" }, { label: "On-call" }] + : isSchedule(model.state) + ? scheduleBreadcrumbs() + : incidentBreadcrumbs(model.incident) + +export const view = (model: Model, h: HtmlBuilder): Html => + isHub(model.state) + ? h.submodel({ + slotId: "lab-oncall-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) + : isForm(model.state) + ? h.submodel({ + slotId: "lab-oncall-form", + model: model.form, + view: formView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: FormMessage.Message) => Message.GotFormMessage({ message }), + }) + : isSchedule(model.state) + ? h.submodel({ + slotId: "lab-oncall-schedule", + model: model.schedule, + view: scheduleView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: ScheduleMessage.Message) => + Message.GotScheduleMessage({ message }), + }) + : h.submodel({ + slotId: "lab-oncall-incident", + model: model.incident, + view: incidentView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: IncidentMessage.Message) => + Message.GotIncidentMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 4b3e994df4..3e6933419a 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -69,6 +69,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/oncall", + title: "On-call schedules", + description: + "The On-call tab empty and populated, the inbox's who-is-on-call strip, the schedule form new, editing, invalid and failing, the week calendar with overrides and a DST week, the override dialog and its delete confirmation, the chain dialog with people and schedules, and an incident timeline with pages (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/anomaly-alerts", title: "Anomaly alerts", diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts index dbdc4ebc83..47c7a2b37e 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -7,7 +7,12 @@ import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" import { init, Message, openEdit, update } from "./dialog" import { view as dialogView } from "./view" -const view = withViewInputs(dialogView, { id: "chain", destinations: chainDestinations })() +const view = withViewInputs(dialogView, { + id: "chain", + destinations: chainDestinations, + members: [], + schedules: [], +})() const editing = () => openEdit( init("chain"), diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts index ff9cdd0598..61ad9a0595 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -1,7 +1,9 @@ import { Option, Array as Arr, Schema } from "effect" import { AlertDestinationId } from "@maple/domain/http" +import * as Menu from "@maple/views/ui/menu" import { describe, expect, it } from "vitest" import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { memberId, mixedTargetChain, scheduleUuid } from "../shared/oncall-test-fixtures" import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" const asDestination = Schema.decodeSync(AlertDestinationId) @@ -42,6 +44,39 @@ describe("escalation chain dialog", () => { ) }) + it("a chain built through the API keeps its people and schedules through an edit", () => { + const chain = mixedTargetChain(chainDestinationUuid(1)) + const model = openEdit(init("chain"), chain).model + expect(Option.map(payloadOf(model), (payload) => payload.steps)).toEqual(Option.some(chain.steps)) + const key = model.steps[1]?.key ?? "" + const removed = run(model, [ + Message.ClickedRemoveTarget({ key, target: `schedule:${scheduleUuid(1)}` }), + ]) + expect(Option.map(payloadOf(removed), (payload) => payload.steps[1]?.targets)).toEqual( + Option.some([{ type: "user", user_id: memberId(1) }]), + ) + }) + + it("adds a person or a schedule from the step's menu, once each", () => { + const model = opened() + const key = model.steps[0]?.key ?? "" + const pick = (value: string) => + Message.GotStepMenuMessage({ + key, + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value } }), + }) + const added = run(model, [ + pick(`user:${memberId(3)}`), + pick(`schedule:${scheduleUuid(2)}`), + pick(`user:${memberId(3)}`), + ]) + expect(added.steps[0]?.targets).toEqual([ + { type: "destination", destination_id: chainDestinationUuid(1) }, + { type: "user", user_id: memberId(3) }, + { type: "schedule", schedule_id: scheduleUuid(2) }, + ]) + }) + it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { const model = opened() const key = model.steps[0]?.key ?? "" @@ -52,7 +87,12 @@ describe("escalation chain dialog", () => { expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) expect( - broken([Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) })]), + broken([ + Message.ClickedRemoveTarget({ + key, + target: `destination:${asDestination(chainDestinationUuid(1))}`, + }), + ]), ).toBe(true) expect( broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), @@ -80,7 +120,10 @@ describe("escalation chain dialog", () => { Message.ClickedAddStep(), Message.ClickedRemoveStep({ key }), Message.ClickedMoveStep({ key, direction: "down" }), - Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + Message.ClickedRemoveTarget({ + key, + target: `destination:${asDestination(chainDestinationUuid(1))}`, + }), ]) expect(edited).toEqual(saving) }) @@ -121,7 +164,10 @@ describe("escalation chain dialog", () => { }) const key = renamed.steps[0]?.key ?? "" const withoutDestination = run(renamed, [ - Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + Message.ClickedRemoveTarget({ + key, + target: `destination:${asDestination(chainDestinationUuid(1))}`, + }), ]) expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( Option.some([{ type: "user", user_id: "user_ben" }]), diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts index 317c8fe6b7..c89f0ce055 100644 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -9,6 +9,8 @@ import { MAX_ESCALATION_STEPS, MAX_ESCALATION_STEP_TARGETS, MAX_ESCALATION_WAIT_MINUTES, + OnCallScheduleId, + UserId, } from "@maple/domain/http" import { V2EscalationChain, @@ -30,9 +32,8 @@ import { saveEscalationChain } from "../shared/escalation-chains" /** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ export const StepDraft = Schema.Struct({ key: Schema.String, - destinationIds: Schema.Array(AlertDestinationId), - /** Member and schedule targets: not editable here yet, so a save sends them back unchanged. */ - otherTargets: Schema.Array(V2EscalationTarget), + /** Destinations, people and schedules, in the order they were added; all kept on load. */ + targets: Schema.Array(V2EscalationTarget), waitMinutes: Schema.String, }) export type StepDraft = typeof StepDraft.Type @@ -45,7 +46,7 @@ export const Model = Schema.Struct({ steps: Schema.Array(StepDraft), repeatCount: Schema.String, nextKey: Schema.Number, - /** Each step's "Add destination" menu, by step key. */ + /** Each step's "Add target" menu, by step key. */ menus: Schema.Record(Schema.String, Menu.Model), isSaving: Schema.Boolean, /** A save was tried: every field shows what is wrong with it. */ @@ -60,8 +61,7 @@ export const DEFAULT_WAIT_MINUTES = "10" const emptyStep = (key: number): StepDraft => ({ key: `s${key}`, - destinationIds: [], - otherTargets: [], + targets: [], waitMinutes: DEFAULT_WAIT_MINUTES, }) @@ -92,7 +92,7 @@ export const Message = defineMessageUnion({ ClickedRemoveStep: { key: Schema.String }, ClickedMoveStep: { key: Schema.String, direction: Direction }, GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, - ClickedRemoveDestination: { key: Schema.String, id: AlertDestinationId }, + ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, }) @@ -136,20 +136,49 @@ export const waitError = (raw: string): Option.Option => export const repeatError = (raw: string): Option.Option => isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) -/** A step needs one target; its carried member and schedule targets count toward both limits. */ -export const targetsError = ( - step: Pick, -): Option.Option => { - const total = step.destinationIds.length + step.otherTargets.length - return total === 0 - ? Option.some("Pick at least one destination") - : total <= MAX_ESCALATION_STEP_TARGETS - ? Option.none() - : Option.some( - step.otherTargets.length === 0 - ? `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations` - : `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets, members and schedules included`, - ) +export const targetsError = (targets: ReadonlyArray): Option.Option => + targets.length === 0 + ? Option.some("Pick at least one destination, person or schedule") + : targets.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) + : Option.none() + +/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ +export const targetKey = (target: V2EscalationTarget): string => { + switch (target.type) { + case "destination": + return `destination:${target.destination_id}` + case "user": + return `user:${target.user_id}` + case "schedule": + return `schedule:${target.schedule_id}` + } +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) +const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) + +/** The target a menu value names, if it is well formed. */ +export const targetOfKey = (key: string): Option.Option => { + const [kind, ...rest] = Str.split(":")(key) + const id = Arr.join(rest, ":") + switch (kind) { + case "destination": + return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ + type: "destination", + destination_id, + })) + case "user": + return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) + case "schedule": + return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ + type: "schedule", + schedule_id, + })) + default: + return Option.none() + } } export const stepsError = (steps: ReadonlyArray): Option.Option => @@ -160,13 +189,10 @@ export const stepsError = (steps: ReadonlyArray): Option.Option => { - const targets: ReadonlyArray = [ - ...Arr.map(step.destinationIds, (id) => ({ type: "destination" as const, destination_id: id })), - ...step.otherTargets, - ] + const targets = step.targets const minutes = numberOf(step.waitMinutes) return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(targetsError(step)) && + Option.isNone(targetsError(targets)) && minutes !== null && isWait(minutes) ? Option.some({ targets, wait_minutes: minutes }) @@ -256,10 +282,7 @@ export const openEdit = (model: Model, chain: V2EscalationChain): Return => description: chain.description ?? "", steps: Arr.map(chain.steps, (step, index) => ({ key: `s${index}`, - destinationIds: Arr.flatMap(step.targets, (target) => - target.type === "destination" ? [target.destination_id] : [], - ), - otherTargets: Arr.filter(step.targets, (target) => target.type !== "destination"), + targets: step.targets, waitMinutes: String(step.wait_minutes), })), repeatCount: String(chain.repeat_count), @@ -280,8 +303,6 @@ const moveStep = (steps: ReadonlyArray, key: string, direction: "up" return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) } -const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) - const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { const id = menuIdOf(model, key) const result = Menu.update(model.menus[key] ?? Menu.init(id), message) @@ -291,16 +312,16 @@ const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { ) const picked = Option.flatMap( result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), - decodeDestinationId, + targetOfKey, ) // The menu still opens and closes while saving, but a pick no longer edits the step. return Option.match(model.isSaving ? Option.none() : picked, { onNone: () => ({ model: next, commands }), - onSome: (destinationId) => ({ + onSome: (target) => ({ ...editStep(next, key, (step) => - step.destinationIds.includes(destinationId) + Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) ? step - : { ...step, destinationIds: [...step.destinationIds, destinationId] }, + : { ...step, targets: [...step.targets, target] }, ), commands, }), @@ -360,11 +381,11 @@ export const update = (model: Model, message: Message): Return => model: { ...model, steps: moveStep(model.steps, key, direction) }, })), GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), - ClickedRemoveDestination: ({ key, id }) => + ClickedRemoveTarget: ({ key, target }) => unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, - destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), + targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), })), ), ClickedSave: () => save(model), diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts index eb624f216e..b0bc3107ea 100644 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -1,6 +1,7 @@ import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import type { V2EscalationTarget, V2OnCallSchedule } from "@maple/domain/http/v2" import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" @@ -18,14 +19,18 @@ import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import type { FieldState } from "@maple/views/ui/input" import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" import { hstack, vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" -import { destinationPicker } from "../../kit/destination-picker" +import { type PickedEntry, pickList } from "../../kit/pick-list" import { formatWait } from "../shared/escalation-chains" +import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { descriptionError, + targetKey, menuIdOf, Message, type Model, @@ -44,8 +49,118 @@ export type ViewInputs = Readonly<{ id: string /** The synced destinations steps pick from; `null` until they arrive. */ destinations: ReadonlyArray | null + /** The org's members a step can page; `null` until they load. */ + members: ReadonlyArray | null + /** The on-call schedules a step can page; `null` until they load. */ + schedules: ReadonlyArray | null }> +const nameOr = ( + rows: ReadonlyArray | null, + id: string, + fallback: string, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), + ) + +/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ +const targetEntry = ( + h: H, + inputs: ViewInputs, + key: string, + target: V2EscalationTarget, +): PickedEntry => { + const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + switch (target.type) { + case "destination": { + const name = nameOr(inputs.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${name}`, + onRemove, + } + } + case "user": { + const name = memberName(inputs.members, target.user_id) + return { + key: targetKey(target), + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(inputs.members, target.user_id), (member) => member.imageUrl), + ), + }), + detail: "email and chat DM", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + case "schedule": { + const name = nameOr(inputs.schedules, target.schedule_id, "Schedule") + return { + key: targetKey(target), + label: name, + media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), + detail: "whoever is on call", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + } +} + +/** The "Add target" menu: destinations, people and schedules not yet on the step, by kind. */ +const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray => { + const picked = new Set(Arr.map(step.targets, targetKey)) + const group = ( + label: string, + items: ReadonlyArray>, + ): ReadonlyArray => { + const open = Arr.filter(items, (item) => !picked.has(item.value)) + return open.length === 0 + ? [] + : [ + { + _tag: "Group", + label, + entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), + }, + ] + } + return [ + ...group( + "Destinations", + Arr.map(inputs.destinations ?? [], (row) => ({ + value: `destination:${row.id}`, + label: row.name, + })), + ), + ...group( + "People", + Arr.map(inputs.members ?? [], (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + ), + ...group( + "Whoever is on call on", + Arr.map(inputs.schedules ?? [], (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + ), + ] +} + const stateOf = (value: string, isInvalid: boolean): FieldState => ({ isInvalid, isDirty: false, @@ -160,30 +275,11 @@ const waitLabel = (model: Model, index: number): string => "wait before repeating, in minutes (unused unless the chain repeats)" }` -const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}` - -/** The step's member and schedule targets, which this dialog keeps but cannot edit yet. */ -const carriedLine = (h: H, step: StepDraft): ReadonlyArray => { - const members = Arr.filter(step.otherTargets, (target) => target.type === "user").length - const schedules = Arr.filter(step.otherTargets, (target) => target.type === "schedule").length - const parts = [ - ...(members === 0 ? [] : [plural(members, "member", "members")]), - ...(schedules === 0 ? [] : [`whoever is on call on ${plural(schedules, "schedule", "schedules")}`]), - ] - return parts.length === 0 - ? [] - : [ - typography(h, { size: "xs", tone: "muted" }, [ - `Also pages ${Arr.join(parts, " and ")}. Saving keeps them.`, - ]), - ] -} - const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { const count = model.steps.length const previous = index === 0 ? undefined : model.steps[index - 1] const wait = waitError(step.waitMinutes) - const destinations = shownError(model, targetsError(step)) + const targets = shownError(model, targetsError(step.targets)) const key = step.key return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ hstack(h, { gap: "2", justify: "between" }, [ @@ -229,21 +325,17 @@ const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, inde ), ]), ]), - destinationPicker(h, { - rows: inputs.destinations, - selectedIds: step.destinationIds, + pickList(h, { + entries: Arr.map(step.targets, (target) => targetEntry(h, inputs, key, target)), menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), - onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), + addEntries: addEntries(inputs, step), + addLabel: "Add destination, person or schedule", isDisabled: model.isSaving, context: `step ${index + 1}`, - emptyText: - inputs.destinations !== null && inputs.destinations.length === 0 - ? "No destinations exist yet. Add one in Routing first." - : "Pick who this step pages", + emptyText: "Pick who this step pages", }), - ...carriedLine(h, step), - ...Option.match(destinations, { + ...Option.match(targets, { onNone: () => [], onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], }), @@ -309,7 +401,7 @@ const descriptionField = (h: H, model: Model): Html => { ]) } -/** The escalation chain dialog: name, ordered steps of destinations and waits, and repeats. */ +/** The escalation chain dialog: name, ordered steps of targets and waits, and repeats. */ export const view = Submodel.defineView((model, inputs, h) => { const repeat = repeatError(model.repeatCount) const isEditing = model.editingId !== null @@ -320,7 +412,7 @@ export const view = Submodel.defineView((model, inpu className: "sm:max-w-xl", title: isEditing ? "Edit escalation chain" : "New escalation chain", description: - "Each step pages its destinations, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", + "Each step pages its destinations, people and whoever is on call, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", panelClassName: "space-y-5", fields: [ textField(h, { diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index df43135cdd..36c308d34d 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -5,6 +5,7 @@ import { AlertRuleId, AlertSilenceId, EscalationChainId, + OnCallScheduleId, } from "@maple/domain/http" import { V2AlertDestinationUpdateParams } from "@maple/domain/http/v2" import { formatWarehouseDateTime, snapRangeForCache } from "@maple/query-engine" @@ -25,6 +26,8 @@ import { setIncidentAcknowledged, } from "../shared/commands" import { deleteEscalationChain, listEscalationChains } from "../shared/escalation-chains" +import { deleteOnCallSchedule, listOnCallSchedules } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { endSilence, listSilences } from "../shared/silences" @@ -208,6 +211,37 @@ export const DeleteChain = Command.define("DeleteEscalationChain", { ), }) +/** The on-call schedules, each with who is on call as the API reads it now. */ +export const FetchHubSchedules = Command.define("FetchAlertsHubSchedules", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSchedules], + execute: ({ key }) => + Effect.gen(function* () { + const result = yield* Effect.result(listOnCallSchedules) + const readAtMs = yield* Clock.currentTimeMillis + return Message.CompletedFetchSchedules({ key, readAtMs, result }) + }), +}) + +/** The org's members, to name who is on call and to pick from in the chain dialog. */ +export const FetchHubMembers = Command.define("FetchAlertsHubMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const DeleteSchedule = Command.define("DeleteOnCallSchedule", { + args: { scheduleId: OnCallScheduleId }, + messages: [Message.CompletedDeleteSchedule], + execute: ({ scheduleId }) => + deleteOnCallSchedule(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteSchedule({ scheduleId, result })), + ), +}) + export const FetchHubSilences = Command.define("FetchAlertsHubSilences", { args: { key: Schema.String }, messages: [Message.CompletedFetchSilences], diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index d60fb79a67..4f25a883d2 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -115,7 +115,7 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "silences" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "silences" | "oncall" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -128,6 +128,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "slos" case "silences": return "silences" + case "oncall": + return "oncall" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 0ef298a0bb..784dc4a127 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -127,7 +127,7 @@ describe("alerts hub, escalation chains", () => { submit(selector("form")), Command.expectNone(), expectView(text("Name the chain")).toExist(), - expectView(text("Pick at least one destination")).toExist(), + expectView(text("Pick at least one destination, person or schedule")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts new file mode 100644 index 0000000000..4c619a587c --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -0,0 +1,283 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" +import type { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import * as Menu from "@maple/views/ui/menu" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { chainDestinations, chainUuid } from "../shared/escalation-test-fixtures" +import { SCHEDULE_IN_USE_CODE } from "../shared/oncall-schedules" +import { + mixedTargetChain, + ONCALL_NOW, + oncallMembers, + populatedSchedules, + scheduleUuid, +} from "../shared/oncall-test-fixtures" +import { incidentRow, ORG, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, scheduleMenuId, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const SCHEDULES = populatedSchedules(ONCALL_NOW) + +const onTab = ( + tab: string, + grants: ReturnType, + schedules: ReadonlyArray | null, +) => { + const entered = init({ _tag: "Alerts", search: { tab } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + Message.CompletedFetchChains({ + key: entered.model.chains.key ?? "", + result: Result.succeed([mixedTargetChain(chainDestinations[0]?.id ?? "")]), + }), + ...(schedules === null + ? [] + : [ + Message.CompletedFetchSchedules({ + key: entered.model.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(schedules), + }), + ]), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +describe("alerts hub, on-call schedules", () => { + test("an org without schedules learns what one is and can create the first", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", ENGINEER, [])), + expectView(text("No on-call schedules")).toExist(), + expectView(text(/people take turns in layers/u)).toExist(), + expectView(role("link", { name: "Create schedule" })).toExist(), + ) + }) + + test("each schedule names its zone, who is on call until when, and the next handoff", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", ENGINEER, SCHEDULES)), + expectView(text("Checkout primary")).toExist(), + expectView(text("Europe/Berlin · 3 layers")).toExist(), + expectView(text("Sofia Alvarez")).toExist(), + expectView(text(/^until Tue,? 10 Nov,? 17:00$/u)).toExist(), + expectView(text("Hands off in 5h 30m")).toExist(), + expectView(text("Nobody on call")).toExist(), + expectView(role("link", { name: "New schedule" })).toExist(), + expectView(role("button", { name: "Actions for Checkout primary" })).toExist(), + ) + }) + + test("a member sees the schedules but cannot create, edit or delete one", () => { + scene( + { update: pageUpdate, view }, + given(onTab("oncall", MEMBER, SCHEDULES)), + expectView(text("Checkout primary")).toExist(), + expectView(role("link", { name: "New schedule" })).not.toExist(), + expectView(role("button", { name: "Actions for Checkout primary" })).not.toExist(), + ) + }) + + test("the inbox opens with who is on call on each schedule", () => { + scene( + { update: pageUpdate, view }, + given(onTab("inbox", MEMBER, SCHEDULES)), + expectView(role("region", { name: "On call now" })).toExist(), + expectView(text("Sofia Alvarez")).toExist(), + expectView(text("Priya Raman")).toExist(), + // The handoff reads on the schedule's clock, so the strip names the zone. + expectView(text(/^until .+ Europe\/Berlin$/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(onTab("inbox", MEMBER, [])), + expectView(role("region", { name: "On call now" })).not.toExist(), + ) + }) + + test("the clock reads the schedules again once a shown shift has handed off, not before", () => { + const model = onTab("oncall", ENGINEER, SCHEDULES) + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 60_000 })).commands), + ).not.toContain("FetchAlertsHubSchedules") + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 4 * 3_600_000 })).commands), + ).toContain("FetchAlertsHubSchedules") + }) + + test("a failed refresh keeps the strip, and the clock still reads the schedules again", () => { + const hour = 3_600_000 + const refreshing = pageUpdate( + onTab("inbox", ENGINEER, SCHEDULES), + Message.ReadClock({ nowMs: ONCALL_NOW + 4 * hour }), + ) + expect(names(refreshing.commands)).toContain("FetchAlertsHubSchedules") + const failed = pageUpdate( + refreshing.model, + Message.CompletedFetchSchedules({ + key: refreshing.model.schedules.key ?? "", + readAtMs: ONCALL_NOW + 4 * hour, + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: true, + recovery: "retry", + }), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + expectView(role("region", { name: "On call now" })).toExist(), + ) + // Not on every tick while it keeps failing: the next try waits the five-minute max age. + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: ONCALL_NOW + 4 * hour + 30_000 })).commands), + ).not.toContain("FetchAlertsHubSchedules") + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: ONCALL_NOW + 8 * hour })).commands), + ).toContain("FetchAlertsHubSchedules") + }) + + test("an org with schedules but no rules sees who is on call above the empty inbox", () => { + const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.CompletedFetchSchedules({ + key: entered.model.schedules.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(SCHEDULES), + }), + ], + entered.model, + (current, message) => pageUpdate(current, message).model, + ) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("region", { name: "On call now" })).toExist(), + ) + }) + + test("a failed members read is asked again on the next route change", () => { + const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubMembers") + const failed = pageUpdate( + entered.model, + Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), + ).model + expect( + names(routeChanged(failed, { _tag: "Alerts", search: { tab: "oncall" } }, context).commands), + ).toContain("FetchAlertsHubMembers") + }) + + test("a chain's summary names the people and schedules it pages", () => { + scene( + { update: pageUpdate, view }, + given(onTab("routing", ENGINEER, SCHEDULES)), + expectView( + text( + "Slack #alerts-api, then whoever is on call on Checkout primary and Dana Kim after 5m, then Ben Brook after 15m; repeats once", + ), + ).toExist(), + ) + }) + + test("a refused delete names the chains that still page the schedule", () => { + const model = onTab("oncall", ENGINEER, SCHEDULES) + const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) + const asked = pageUpdate( + pageUpdate( + model, + Message.GotRowMenuMessage({ + menuId: scheduleMenuId(scheduleId), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "delete" } }), + }), + ).model, + Message.ClickedConfirmDeleteSchedule(), + ) + expect(names(asked.commands)).toEqual(["DeleteOnCallSchedule"]) + const refused = pageUpdate( + asked.model, + Message.CompletedDeleteSchedule({ + scheduleId, + result: Result.fail({ + _tag: "@maple/http/errors/OnCallScheduleInUseError", + type: "conflict_error", + code: SCHEDULE_IN_USE_CODE, + title: "Schedule is in use", + message: "An escalation chain pages this schedule.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteScheduleError).toBe( + "Still paged by Checkout on-call. Remove it from those chains first.", + ) + }) + + test("before the schedules load, a chain says it pages the on-call rather than a deleted schedule", () => { + scene( + { update: pageUpdate, view }, + given(onTab("routing", ENGINEER, null)), + expectView(text(/then the on-call and Dana Kim after 5m/u)).toExist(), + expectView(text(/a deleted schedule/u)).not.toExist(), + ) + }) + + test("members are read only by the tabs that name people, once", () => { + const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") + const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") + const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") + }) + + test("a deleted chain reads the schedules again, whose chain lists named it", () => { + const model = onTab("routing", ENGINEER, SCHEDULES) + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const deleted = pageUpdate( + { ...model, confirmDeleteChainId: chainId, deletingChainId: chainId }, + Message.CompletedDeleteChain({ chainId, result: Result.void }), + ) + expect(names(deleted.commands)).toContain("FetchAlertsHubSchedules") + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 386d58250e..0a677a246b 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -7,8 +7,15 @@ import { AlertRuleId, AlertSilenceId, EscalationChainId, + OnCallScheduleId, } from "@maple/domain/http" -import { V2AlertIncident, V2AlertSilence, V2EscalationChain, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertIncident, + V2AlertSilence, + V2EscalationChain, + V2OnCallSchedule, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -22,6 +29,7 @@ import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" +import { OrgMember } from "../shared/org-members" import * as SilenceDialog from "../silence-dialog/dialog" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -104,6 +112,16 @@ export const Message = defineMessageUnion({ ClickedConfirmDeleteChain: {}, CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, GotDeleteChainDialogMessage: { message: Dialog.Message }, + CompletedFetchSchedules: { + key: Schema.String, + readAtMs: Schema.Number, + result: outcome(Schema.Array(V2OnCallSchedule)), + }, + ClickedRetrySchedules: {}, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + ClickedConfirmDeleteSchedule: {}, + CompletedDeleteSchedule: { scheduleId: OnCallScheduleId, result: outcome(Schema.Void) }, + GotDeleteScheduleDialogMessage: { message: Dialog.Message }, CompletedFetchSilences: { key: Schema.String, result: outcome(Schema.Array(V2AlertSilence)) }, ClickedRetrySilences: {}, ClickedNewSilence: {}, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 6372cb8ef7..ff0af0bc6e 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -7,8 +7,16 @@ import { AlertRuleId, AlertSilenceId, EscalationChainId, + OnCallScheduleId, } from "@maple/domain/http" -import { V2AlertSilence, V2EscalationChain, V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertSilence, + V2EscalationChain, + V2OnCallSchedule, + V2Slo, + V2SloStatus, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -23,6 +31,7 @@ import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" +import { OrgMember } from "../shared/org-members" import * as SilenceDialog from "../silence-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" @@ -101,6 +110,18 @@ export const Model = Schema.Struct({ /** The refusal was "in use": its message is rebuilt once the re-read chains land. */ isDeleteChainInUse: Schema.Boolean, deleteChainDialog: Dialog.Model, + /** `oncallSchedules.list`, read on every tab: the inbox strip, the On-call tab and the chain dialog. */ + schedules: KeyedSchema(Schema.Array(V2OnCallSchedule), LoadFailure), + /** When the clock last asked for the schedules again; a failing read waits out the max age. */ + schedulesAskedAtMs: Schema.NullOr(Schema.Number), + /** The org's members, to name who is on call; `null` until Clerk answers (or failed). */ + members: Schema.NullOr(Schema.Array(OrgMember)), + /** The members were asked for: only the tabs that name people (inbox, On-call, Routing) ask. */ + isMembersRequested: Schema.Boolean, + confirmDeleteScheduleId: Schema.NullOr(OnCallScheduleId), + deletingScheduleId: Schema.NullOr(OnCallScheduleId), + deleteScheduleError: Schema.NullOr(Schema.String), + deleteScheduleDialog: Dialog.Model, /** Silences tab: `alertSilences.list`, read while the tab is open. */ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), endingSilenceIds: Schema.Array(AlertSilenceId), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 7febe0962a..4bc9ba85eb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,6 +1,12 @@ import type { UptimeMonitorId } from "@maple/domain" -import type { AlertDestinationId, AlertRuleId, AlertSilenceId, EscalationChainId } from "@maple/domain/http" -import type { V2EscalationChain } from "@maple/domain/http/v2" +import type { + AlertDestinationId, + AlertRuleId, + AlertSilenceId, + EscalationChainId, + OnCallScheduleId, +} from "@maple/domain/http" +import type { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" import { isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule" import { buildDestinationUpdateParamsV2, destinationToFormState } from "@maple/web-data/alerts/form-utils" import { rowToAlertDestinationDocument, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" @@ -22,9 +28,18 @@ import { endsAtMs, startsAtMs } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" +import { + editScheduleHref, + handoffMs, + SCHEDULE_IN_USE_CODE, + SCHEDULE_LIST_LIMIT, +} from "../shared/oncall-schedules" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + DeleteSchedule, + FetchHubMembers, + FetchHubSchedules, DeleteChain, AcknowledgeIncident, DeleteDestination, @@ -57,6 +72,7 @@ type HubRoute = Extract const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" +export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" @@ -165,9 +181,10 @@ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): const chainsKey = (context: PageContext) => requestKey(context.orgId, "escalationChains.list", { query: { limit: CHAIN_LIST_LIMIT } }) -/** The Routing tab reads the escalation chains only while it is open. */ +/** The Routing tab reads the escalation chains, and the On-call tab to name the ones paging a schedule. */ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "routing") return { model, commands: [] } + const tab = resolveTab(model.search.tab) + if (tab !== "routing" && tab !== "oncall") return { model, commands: [] } return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (chains) => ({ @@ -177,6 +194,46 @@ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode) }) } +const schedulesKey = (context: PageContext) => + requestKey(context.orgId, "oncallSchedules.list", { query: { limit: SCHEDULE_LIST_LIMIT } }) + +/** Every tab reads the schedules: the inbox strip, the On-call tab and the chain dialog's targets. */ +const requestSchedules = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => + Option.match(Keyed.requestFor(model.schedules, schedulesKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (schedules) => ({ + model: { ...model, schedules }, + commands: [FetchHubSchedules({ key: schedules.key })], + }), + }) + +/** Re-read at most every 5 minutes, and at once when a shift the list shows has handed off. */ +const SCHEDULES_MAX_AGE_MS = 5 * 60_000 + +const schedulesOf = (model: Model): ReadonlyArray => + Option.getOrElse(AsyncData.getData(model.schedules.data), () => []) + +const isSchedulesStale = (model: Model, nowMs: number): boolean => { + const readAt = model.schedules.updatedAtMs + // A failed refresh keeps the last list (`Stale`), which still ages and hands off. + if (readAt === undefined || Option.isNone(AsyncData.getData(model.schedules.data))) return false + // A re-read asked since the last answer (failing, or in flight) waits the max age, not every tick. + const askedAt = model.schedulesAskedAtMs + if (askedAt !== null && askedAt > readAt && nowMs - askedAt < SCHEDULES_MAX_AGE_MS) return false + return ( + nowMs - readAt >= SCHEDULES_MAX_AGE_MS || + Array.some(schedulesOf(model), (schedule) => Option.exists(handoffMs(schedule), (at) => at <= nowMs)) + ) +} + +/** The clock ticked: the overview's derivation moves, and stale schedules are read again. */ +const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { + const next = { ...model, nowMs } + if (!isSchedulesStale(next, nowMs)) return { model: next } + const step = requestSchedules({ ...next, schedulesAskedAtMs: nowMs }, context, "Refresh") + return { model: step.model, commands: step.commands } +} + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -184,6 +241,14 @@ const refreshDeliveries = (model: Model, context: PageContext) => { return { model: routing.model, commands: [...overview.commands, ...routing.commands] } } +/** Members are read once, on the first tab that names people: the inbox strip, On-call, Routing. */ +const requestMembers = (model: Model): Requested => { + const tab = resolveTab(model.search.tab) + return model.isMembersRequested || (tab !== "inbox" && tab !== "oncall" && tab !== "routing") + ? { model, commands: [] } + : { model: { ...model, isMembersRequested: true }, commands: [FetchHubMembers()] } +} + export const init = (route: HubRoute, context: PageContext): Return => { const model: Model = { search: route.search, @@ -226,6 +291,14 @@ export const init = (route: HubRoute, context: PageContext): Return => { deleteChainError: null, isDeleteChainInUse: false, deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), + schedules: Keyed.idle(), + schedulesAskedAtMs: null, + members: null, + isMembersRequested: false, + confirmDeleteScheduleId: null, + deletingScheduleId: null, + deleteScheduleError: null, + deleteScheduleDialog: Dialog.init(DELETE_SCHEDULE_DIALOG_ID, "alert-dialog"), silences: Keyed.idle(), endingSilenceIds: [], silenceDialog: SilenceDialog.init("alerts-silence"), @@ -236,11 +309,15 @@ export const init = (route: HubRoute, context: PageContext): Return => { const silences = requestSilences(monitors.model, context, "Load") const slos = requestSlos(silences.model, context, "Load") const chains = requestChains(slos.model, context, "Load") + const schedules = requestSchedules(chains.model, context, "Load") + const members = requestMembers(schedules.model) return { - model: chains.model, + model: members.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), + ...members.commands, + ...schedules.commands, ...deliveries.commands, ...routing.commands, ...monitors.commands, @@ -257,14 +334,18 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const silences = requestSilences(monitors.model, context, "Load") const slos = requestSlos(silences.model, context, "Load") const chains = requestChains(slos.model, context, "Load") + const schedules = requestSchedules(chains.model, context, "Load") + const members = requestMembers(schedules.model) return { - model: chains.model, + model: members.model, commands: [ ...routing.commands, ...monitors.commands, ...silences.commands, ...slos.commands, ...chains.commands, + ...schedules.commands, + ...members.commands, ReadHubClock(), ], } @@ -351,10 +432,11 @@ const liftDestinationDialog = (model: Model, step: ReturnType => Option.getOrElse(AsyncData.getData(model.chains.data), () => []) -/** A write changed the chains: read them again under a new generation. */ +/** A write changed the chains: read them again, and the schedules whose `chain_ids` name them. */ const refreshChains = (model: Model, context: PageContext): Return => { - const step = requestChains(model, context, "Supersede") - return { model: step.model, commands: step.commands } + const chains = requestChains(model, context, "Supersede") + const schedules = requestSchedules(chains.model, context, "Supersede") + return { model: schedules.model, commands: [...chains.commands, ...schedules.commands] } } /** The chain dialog's outcome: a toast, and the list read again. */ @@ -492,6 +574,111 @@ const deletedChain = ( }) } +// ON-CALL SCHEDULES + +/** A write changed the schedules: read them again under a new generation. */ +const refreshSchedules = (model: Model, context: PageContext): Return => { + const step = requestSchedules(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + +/** The chains that page a schedule, by name, for the in-use refusal. */ +const scheduleChainNames = (model: Model, schedule: V2OnCallSchedule): ReadonlyArray => + Array.flatMap(schedule.chain_ids, (id) => + Option.toArray( + Option.map( + Array.findFirst(chainsOf(model), (chain) => chain.id === id), + (chain) => chain.name, + ), + ), + ) + +const scheduleInUseMessage = (model: Model, scheduleId: OnCallScheduleId): string => { + const names = Option.match( + Array.findFirst(schedulesOf(model), (schedule) => schedule.id === scheduleId), + { onNone: () => [], onSome: (schedule) => scheduleChainNames(model, schedule) }, + ) + return names.length === 0 + ? "An escalation chain still pages this schedule. Remove it from the chain first." + : `Still paged by ${Array.join(names, ", ")}. Remove it from those chains first.` +} + +/** Runs the schedule delete dialog's update; `Closed` forgets the target and its error. */ +const updateDeleteScheduleDialog = (model: Model, step: ReturnType): Return => { + const isClosed = step.outMessage?._tag === "Closed" + return { + model: { + ...model, + deleteScheduleDialog: step.model, + confirmDeleteScheduleId: isClosed ? null : model.confirmDeleteScheduleId, + deleteScheduleError: isClosed ? null : model.deleteScheduleError, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteScheduleDialogMessage({ message }), + ), + } +} + +const pickedScheduleAction = (model: Model, rawId: string, value: string): Return => + Option.match( + Array.findFirst(schedulesOf(model), (schedule) => schedule.id === rawId), + { + onNone: () => ({ model }), + onSome: (schedule): Return => + value === "delete" + ? updateDeleteScheduleDialog( + { ...model, confirmDeleteScheduleId: schedule.id, deleteScheduleError: null }, + Dialog.open(model.deleteScheduleDialog), + ) + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: editScheduleHref(schedule.id), + isReplace: false, + }), + }, + }, + ) + +const confirmDeleteSchedule = (model: Model): Return => { + const id = model.confirmDeleteScheduleId + return id === null || model.deletingScheduleId !== null + ? { model } + : { + model: { ...model, deletingScheduleId: id, deleteScheduleError: null }, + commands: [DeleteSchedule({ scheduleId: id })], + } +} + +const deletedSchedule = ( + model: Model, + context: PageContext, + scheduleId: OnCallScheduleId, + result: Result.Result, +): Return => { + const settled = { ...model, deletingScheduleId: null } + return Result.match(result, { + onSuccess: (): Return => { + const closed = updateDeleteScheduleDialog(settled, Dialog.close(settled.deleteScheduleDialog)) + const refreshed = refreshSchedules(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Schedule deleted"), + } + }, + onFailure: (failure): Return => ({ + model: { + ...settled, + deleteScheduleError: + failure.code === SCHEDULE_IN_USE_CODE + ? scheduleInUseMessage(settled, scheduleId) + : failure.message, + }, + }), + }) +} + // SILENCES const liftSilenceDialog = ( @@ -539,6 +726,7 @@ export const ruleMenuId = (ruleId: AlertRuleId, groupKey: string) => `rule:${rul export const destinationMenuId = (destinationId: AlertDestinationId) => `destination:${destinationId}` export const monitorMenuId = (monitorId: UptimeMonitorId) => `monitor:${monitorId}` export const chainMenuId = (chainId: EscalationChainId) => `chain:${chainId}` +export const scheduleMenuId = (scheduleId: OnCallScheduleId) => `schedule:${scheduleId}` const monitorsOf = (model: Model) => Option.getOrElse(AsyncData.getData(model.monitors.data), () => []) @@ -631,6 +819,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) if (kind === "chain") return pickedChainAction(model, context, target, value) + if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] const rule = Option.map( @@ -700,7 +889,7 @@ const statusToggled = (model: Model, bucket: RuleBucket): Return => ({ export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { - ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + ReadClock: ({ nowMs }) => clockRead(model, context, nowMs), UpdatedRules: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, UpdatedRuleStates: ({ orgId, rows }) => @@ -885,6 +1074,24 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.deletingChainId !== null ? { model } : updateDeleteChainDialog(model, Dialog.update(model.deleteChainDialog, child)), + CompletedFetchSchedules: ({ key, readAtMs, result }) => ({ + model: { ...model, schedules: Keyed.accept(model.schedules, key, result, readAtMs) }, + }), + ClickedRetrySchedules: () => refreshSchedules(model, context), + // Without members the names read "Member"; the schedules themselves still show. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members } }), + // A failed read is asked again on the next tab or route change. + onFailure: () => ({ model: { ...model, isMembersRequested: false } }), + }), + ClickedConfirmDeleteSchedule: () => confirmDeleteSchedule(model), + CompletedDeleteSchedule: ({ scheduleId, result }) => + deletedSchedule(model, context, scheduleId, result), + GotDeleteScheduleDialogMessage: ({ message: child }) => + model.deletingScheduleId !== null + ? { model } + : updateDeleteScheduleDialog(model, Dialog.update(model.deleteScheduleDialog, child)), ClickedRetrySlos: () => { const step = requestSlos(model, context, "Supersede") return { model: step.model, commands: step.commands } diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index 781a28bbda..1d161f42f6 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -22,6 +22,7 @@ import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" import { chainSummary } from "../shared/escalation-chains" +import { memberName } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" import { chainMenuId, chainRuleNames } from "./update" @@ -48,7 +49,22 @@ export const chainRow = ( chain: V2EscalationChain, destinations: ReadonlyArray, ): Html => { - const summary = chainSummary(chain, nameOfDestination(destinations)) + const summary = chainSummary(chain, nameOfDestination(destinations), { + userName: (id) => memberName(model.members, id), + // Before the schedules load the summary says "the on-call"; once loaded, a missing one is deleted. + scheduleName: (id) => + Option.match(AsyncData.getData(model.schedules.data), { + onNone: () => undefined, + onSome: (schedules) => + Option.getOrElse( + Option.map( + Arr.findFirst(schedules, (schedule) => schedule.id === id), + (schedule) => schedule.name, + ), + () => "a deleted schedule", + ), + }), + }) const ruleCount = chain.rule_ids.length const ruleNames = chainRuleNames(model, chain) const menuId = chainMenuId(chain.id) diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts new file mode 100644 index 0000000000..a27509f16b --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -0,0 +1,291 @@ +import type { V2OnCallSchedule } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { icon } from "@maple/views/ui/icon" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { personLabel } from "@maple/views/ui/person-avatar" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { sectionHeading } from "../../kit/section-heading" +import { formatShiftEdge, formatUntil, newScheduleHref, scheduleHref } from "../shared/oncall-schedules" +import { memberName, memberOf } from "../shared/org-members" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { DELETE_SCHEDULE_DIALOG_ID, scheduleMenuId } from "./update" +import { isoMs } from "../shared/time" + +// The hub's On-call tab (each schedule with who is on call and the next handoff), the inbox's +// "On call now" strip, and the schedule delete confirmation. + +type H = HtmlBuilder + +/** + * Who holds the schedule now, with when they hand off; "Nobody on call" in a gap. The handoff + * reads on the schedule's clock, so `withZone` names it where the zone is not shown beside it. + */ +const onCallPerson = (h: H, model: Model, schedule: V2OnCallSchedule, withZone: boolean): Html => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => + hstack(h, { gap: "2" }, [ + icon(h, "UserIcon", { className: "text-foreground-subtle" }), + typography(h, { size: "sm", tone: "muted", className: "whitespace-nowrap" }, [ + "Nobody on call", + ]), + ]), + onSome: (now) => + personLabel(h, { + name: memberName(model.members, now.user_id), + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, now.user_id), (member) => member.imageUrl), + ), + detail: `until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}${withZone ? ` ${schedule.time_zone}` : ""}`, + }), + }) + +const layerCount = (schedule: V2OnCallSchedule) => + `${schedule.layers.length} ${pluralize(schedule.layers.length, "layer")}` + +/** `ScheduleRow`: name and zone, who is on call until when, the next handoff, and its actions. */ +const scheduleRow = (h: H, model: Model, schedule: V2OnCallSchedule, nowMs: number): Html => { + const menuId = scheduleMenuId(schedule.id) + const entries: ReadonlyArray = [ + { _tag: "Item", value: "edit", label: "Edit", icon: "PencilIcon" }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "delete", + label: "Delete", + icon: "TrashIcon", + variant: "destructive", + isDisabled: model.deletingScheduleId === schedule.id, + }, + ] + const handoff = Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => "No one scheduled", + onSome: (now) => `Hands off ${formatUntil(nowMs, isoMs(now.ends_at))}`, + }) + return h.li( + [h.Key(schedule.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", wrap: true }, [ + hstack(h, { gap: "3", align: "start", grow: true, shrink: true, className: "basis-60" }, [ + icon(h, "CalendarIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + vstack(h, { gap: "0.5", shrink: true }, [ + textLink( + h, + { + href: scheduleHref(schedule.id), + tone: "muted", + className: "min-w-0 text-foreground", + }, + [ + typography(h, { size: "sm", weight: "medium", truncate: true }, [ + schedule.name, + ]), + ], + ), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + `${schedule.time_zone} · ${layerCount(schedule)}`, + ]), + ]), + ]), + hstack(h, { gap: "4", className: "w-full pl-7 sm:w-auto sm:pl-0" }, [ + h.div( + [h.Class("min-w-0 flex-1 sm:w-56 sm:flex-none")], + [onCallPerson(h, model, schedule, false)], + ), + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + className: "hidden shrink-0 whitespace-nowrap sm:inline sm:w-36", + }, + [handoff], + ), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${schedule.name}`, + toParentMessage: (message) => + Message.GotRowMenuMessage({ menuId, message }), + }), + ] + : []), + ]), + ]), + ], + ) +} + +const schedulesEmpty = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CalendarIcon", { size: 18 })]), + emptyTitle(h, ["No on-call schedules"]), + emptyDescription(h, [ + "A schedule says who is on call when: people take turns in layers that hand off daily, weekly or every few hours, and overrides cover swaps. Escalation chains page whoever is on call.", + ...(canManage(model) ? [] : [" Ask an org admin to create one."]), + ]), + ]), + ...(canManage(model) + ? [ + emptyActions(h, [ + linkButton(h, { size: "sm", href: newScheduleHref() }, [ + icon(h, "PlusIcon"), + "Create schedule", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +const schedulesList = (h: H, model: Model, schedules: ReadonlyArray, nowMs: number): Html => + schedules.length === 0 + ? schedulesEmpty(h, model) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(schedules, (schedule) => scheduleRow(h, model, schedule, nowMs)), + ), + ]) + +const LOADING_ROWS = ["h-16 w-full", "h-16 w-full"] + +/** The On-call tab: what schedules are, then the list, or its empty, loading or failed state. */ +export const oncallTab = (h: H, model: Model, nowMs: number): Html => { + const loading = vstack( + h, + { gap: "3" }, + Arr.map(LOADING_ROWS, (row) => skeleton(h, row)), + ) + return h.section( + [h.Class("space-y-4")], + [ + h.div( + [], + [ + sectionHeading(h, { title: "On-call schedules" }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "Who is on call when. Later layers sit on top of earlier ones, and an override beats every layer.", + ]), + ], + ), + AsyncData.match(model.schedules.data, { + onIdle: () => loading, + onLoading: () => loading, + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load on-call schedules", + onRetry: Message.ClickedRetrySchedules(), + }), + onRefreshing: (schedules) => schedulesList(h, model, schedules, nowMs), + onStale: ({ data }) => schedulesList(h, model, data, nowMs), + onSuccess: (schedules) => schedulesList(h, model, schedules, nowMs), + }), + ], + ) +} + +/** The org has a schedule: the header offers another. */ +export const hasSchedules = (model: Model): boolean => + Option.exists(AsyncData.getData(model.schedules.data), (schedules) => schedules.length > 0) + +const stripItem = (h: H, model: Model, schedule: V2OnCallSchedule): Html => + vstack( + h, + { gap: "1", shrink: true, className: "min-w-44 flex-1 basis-56", attributes: [h.Key(schedule.id)] }, + [ + textLink(h, { href: scheduleHref(schedule.id), tone: "muted", className: "min-w-0" }, [ + typography(h, { size: "xs", truncate: true }, [schedule.name]), + ]), + onCallPerson(h, model, schedule, true), + ], + ) + +/** "On call now": each schedule's current person, atop the inbox; nothing without schedules. */ +export const whoIsOnCallStrip = (h: H, model: Model): ReadonlyArray => { + const schedules = Option.getOrElse(AsyncData.getData(model.schedules.data), () => []) + return schedules.length === 0 + ? [] + : [ + borderedBox( + h, + { overflow: "hidden", as: "section", attributes: [h.AriaLabel("On call now")] }, + [ + vstack(h, { gap: "3", className: "p-4" }, [ + eyebrow(h, { as: "h2" }, ["On call now"]), + hstack( + h, + { gap: "6", wrap: true, align: "start", className: "gap-y-3" }, + Arr.map(schedules, (schedule) => stripItem(h, model, schedule)), + ), + ]), + ], + ), + ] +} + +/** "Delete {schedule}?": the 409 that names the chains paging it stays in the dialog. */ +export const deleteScheduleDialog = (h: H, model: Model): Html => { + const name = Option.getOrElse( + Option.map( + Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.schedules.data), () => []), + (schedule) => schedule.id === model.confirmDeleteScheduleId, + ), + (schedule) => schedule.name, + ), + () => "schedule", + ) + return confirmDialog(h, { + slotId: DELETE_SCHEDULE_DIALOG_ID, + model: model.deleteScheduleDialog, + toParentMessage: (message) => Message.GotDeleteScheduleDialogMessage({ message }), + title: `Delete ${name}?`, + description: "Its overrides go with it. This cannot be undone.", + children: + model.deleteScheduleError === null + ? undefined + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete schedule"]), + alertDescription(h, [model.deleteScheduleError]), + ]), + ], + confirmLabel: "Delete schedule", + isPending: model.deletingScheduleId !== null, + onConfirm: Message.ClickedConfirmDeleteSchedule(), + }) +} + +/** The tab header's "New schedule", once one exists. */ +export const newScheduleButton = (h: H): Html => + linkButton(h, { size: "sm", variant: "outline", href: newScheduleHref() }, [ + icon(h, "PlusIcon"), + "New schedule", + ]) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index bfcd3d34d0..784c835acf 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -42,6 +42,13 @@ import { silencesTab } from "./view-silences" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" +import { + deleteScheduleDialog, + hasSchedules, + newScheduleButton, + oncallTab, + whoIsOnCallStrip, +} from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" // `AlertsPage` and `AlertsHub`: the `/alerts` page and its tabs. @@ -54,6 +61,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "uptime", label: "Uptime" }, { value: "slos", label: "SLOs" }, { value: "silences", label: "Silences" }, + { value: "oncall", label: "On-call" }, { value: "routing", label: "Routing" }, ] @@ -67,6 +75,7 @@ const tabBar = ( ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) const sloCount = Option.map(AsyncData.getData(model.slos.data), (slos) => slos.length) + const scheduleCount = Option.map(AsyncData.getData(model.schedules.data), (rows) => rows.length) return linkTabStrip( h, "Alerts sections", @@ -76,15 +85,17 @@ const tabBar = ( ? Option.getOrUndefined(monitorCount) : tab.value === "slos" ? Option.getOrUndefined(sloCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? unacknowledgedCount(data.openIncidents) - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : tab.value === "oncall" + ? Option.getOrUndefined(scheduleCount) + : Option.match(overview, { + onNone: () => undefined, + onSome: (data) => + tab.value === "inbox" + ? unacknowledgedCount(data.openIncidents) + : tab.value === "rules" + ? data.rules.length + : undefined, + }) return { key: tab.value, // The Anomalies view belongs to the Inbox: other tabs drop its filters. @@ -234,6 +245,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), + ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -303,8 +315,9 @@ const hubBody = ( if (tab === "inbox") { const signal = resolveSignal(model.search.signal) const hasAnomalies = Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + // A team can set up on-call before its first rule, so the strip shows on the empty inbox too. if (data.rules.length === 0 && !hasManagedIncidents && !hasAnomalies && signal === null) - return emptyState + return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), emptyState]) const filters = inboxFilters(h, { signal, status: resolveIncidentStatus(model.search.anomalyStatus), @@ -330,7 +343,7 @@ const hubBody = ( }), }) : inboxTab(h, data, acknowledge) - return h.div([h.Class("space-y-6")], [filters, body]) + return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), filters, body]) } return rulesTab(h, model, { data, @@ -388,17 +401,26 @@ export const view = Submodel.defineView((model, ? slosTab(h, model) : tab === "silences" ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : hubBody(h, model, overview, tab), + : tab === "oncall" + ? oncallTab(h, model, model.nowMs ?? 0) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), deleteMonitorDialog(h, model), deleteChainDialog(h, model), + deleteScheduleDialog(h, model), h.submodel({ slotId: CHAIN_DIALOG_ID, model: model.chainDialog, view: ChainDialogView.view, - viewInputs: { id: CHAIN_DIALOG_ID, destinations: model.destinations }, + viewInputs: { + id: CHAIN_DIALOG_ID, + destinations: model.destinations, + members: model.members, + // `null` until the schedules load (or after a failed read): names read neutrally. + schedules: Option.getOrNull(AsyncData.getData(model.schedules.data)), + }, toParentMessage: (message) => Message.GotChainDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts index bd7393322c..467d947a11 100644 --- a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -7,6 +7,7 @@ import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colo import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../contract" import { runOf } from "../shared/escalation-test-fixtures" +import { pagedEvents } from "../shared/oncall-test-fixtures" import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" @@ -72,6 +73,33 @@ describe("alert incident, escalation", () => { ).not.toContain("FetchAlertIncidentEscalation") }) + test("the activity is read again when a step pages people, and while a page is on its way", () => { + const loaded = synced() + const read = pageUpdate( + loaded, + Message.CompletedFetchActivity({ key: loaded.activity.key ?? "", result: Result.succeed([]) }), + ).model + const running = withRun(read, runOf("active", { stepsSent: 1 })) + const stepped = pageUpdate( + running, + Message.CompletedFetchEscalation({ + key: running.escalation.key ?? "", + result: Result.succeed(runOf("active", { stepsSent: 2 })), + }), + ) + expect(names(stepped.commands)).toContain("FetchAlertIncidentActivity") + const sending = pageUpdate( + stepped.model, + Message.CompletedFetchActivity({ + key: stepped.model.activity.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, NOW)), + }), + ).model + expect(names(pageUpdate(sending, Message.TickedClock({ nowMs: NOW + 60_000 })).commands)).toContain( + "FetchAlertIncidentActivity", + ) + }) + test("a running chain shows its step and when it pages next", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts b/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts new file mode 100644 index 0000000000..33ae2ca40f --- /dev/null +++ b/apps/web/src/page/alerts/incident/incident-paged.scene.test.ts @@ -0,0 +1,69 @@ +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { pagedEvents } from "../shared/oncall-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const route = { _tag: "AlertIncident" as const, incidentId: INCIDENT_ID, search: {} } +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +describe("alert incident, pages to people", () => { + test("the incident reads its events with the escalation, and again when it is acknowledged", () => { + const entered = run(init(route, context).model, [Message.ReadClock({ nowMs: NOW })]) + const first = pageUpdate(entered, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(first.commands)).toContain("FetchAlertIncidentActivity") + const same = pageUpdate(first.model, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(same.commands)).not.toContain("FetchAlertIncidentActivity") + const acked = pageUpdate( + first.model, + Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })], + }), + ) + expect(names(acked.commands)).toContain("FetchAlertIncidentActivity") + }) + + test("the timeline says who was paged on which channel, and why a page failed", () => { + const row = incidentRow() + const synced = run(init(route, context).model, [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ]) + const model = run(synced, [ + Message.CompletedFetchActivity({ + key: synced.activity.key ?? "", + result: Result.succeed(pagedEvents(INCIDENT_ID, Date.parse(row.first_triggered_at))), + }), + Message.CompletedFetchDeliveries({ + key: synced.deliveries.key ?? "", + result: Result.succeed([]), + }), + ]) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), + expectView(text("Paged Ben Brook by Slack DM")).toExist(), + expectView(text("Paged Ben Brook by email")).toExist(), + expectView(text("Couldn't page Dana Kim by Discord DM")).toExist(), + expectView(text("Dana has not linked a Discord account")).toExist(), + expectView(text("Paging Dana Kim by email")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 1f7f8aa9b4..6a2f97599d 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,3 +1,4 @@ +import { rowToAlertIncidentDocument } from "@maple/web-data/collections/alerts" import { OrgId } from "@maple/domain" import * as Policy from "@maple/domain/policy" import { AlertRuleId, AlertRulePreviewResponse, AlertSilenceId } from "@maple/domain/http" @@ -517,9 +518,13 @@ describe("alert incident", () => { created_at: at, }) + const synced = rowToAlertIncidentDocument(incidentRow()) const activityKey = (acknowledgedAt: string | null) => requestKey(ORG, "alertIncidents.listEvents", { id: INCIDENT_ID, + status: synced.status, + severity: synced.severity, + lastNotifiedAt: synced.lastNotifiedAt, acknowledgedAt: acknowledgedAt === null ? null : isoMs(acknowledgedAt), }) diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index cf44fcbdb9..eb1c7208f4 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -216,9 +216,13 @@ const followData = (model: Model, context: PageContext): Return => lastNotifiedAt: view.incident.lastNotifiedAt, acknowledgedAt: view.incident.acknowledgedAt, }) - // Keyed on the acknowledgement, so an ack from anywhere (Slack, the API) refetches the history. + // Keyed on the acknowledgement, so an ack from anywhere (Slack, the API) refetches the history, + // and on what moves an escalation, so its pages to people land as they are sent. const activityKey = requestKey(context.orgId, "alertIncidents.listEvents", { id: incidentId, + status: view.incident.status, + severity: view.incident.severity, + lastNotifiedAt: view.incident.lastNotifiedAt, // Epoch ms: the synced row and the API spell the same instant differently. acknowledgedAt: view.incident.acknowledgedAt === null ? null : isoMs(view.incident.acknowledgedAt), @@ -505,10 +509,44 @@ const refreshDueEscalation = (model: Model, nowMs: number): Return => { }) } +/** Re-reads the activity on its own key: pages are written without moving anything that keys it. */ +const refreshActivity = (model: Model, mode: "Refresh" | "Supersede"): Return => { + const key = model.activity.key + const view = incidentViewOf(model) + if (key === null || Option.isNone(view)) return { model } + const incidentId = view.value.incident.id + // A new step supersedes a read in flight, which may predate its page; the poll only refreshes, + // so a slow read is not discarded by the next tick. + return Option.match(Keyed.requestFor(model.activity, key, mode), { + onNone: () => ({ model }), + onSome: (activity): Return => ({ + model: { ...model, activity }, + commands: [FetchIncidentActivity({ key: activity.key, incidentId })], + }), + }) +} + +const isPageInFlight = (model: Model): boolean => + Arr.some( + Option.getOrElse(AsyncData.getData(model.activity.data), () => []), + (event) => event.page?.status === "queued" || event.page?.status === "sending", + ) + +const stepsSentOf = (model: Model): number | null => + Option.getOrNull(AsyncData.getData(model.escalation.data))?.steps_sent ?? null + +/** The clock re-reads a due step, and the activity while a page is still on its way. */ +const tick = (model: Model, nowMs: number): Return => { + const escalation = refreshDueEscalation(model, nowMs) + if (!isPageInFlight(escalation.model)) return escalation + const activity = refreshActivity(escalation.model, "Refresh") + return { model: activity.model, commands: [...(escalation.commands ?? []), ...(activity.commands ?? [])] } +} + export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs), clockMs: nowMs }, context), - TickedClock: ({ nowMs }) => refreshDueEscalation({ ...model, clockMs: nowMs }, nowMs), + TickedClock: ({ nowMs }) => tick({ ...model, clockMs: nowMs }, nowMs), UpdatedIncidents: ({ orgId, rows }) => withIncidents(model, context, orgId, rows), UpdatedRules: ({ orgId, rows }) => withRuleRows(model, context, orgId, { rules: rows }), UpdatedRuleStates: ({ orgId, rows }) => withRuleRows(model, context, orgId, { states: rows }), @@ -562,9 +600,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), - CompletedFetchEscalation: ({ key, result }) => ({ - model: { ...model, escalation: Keyed.accept(model.escalation, key, result) }, - }), + CompletedFetchEscalation: ({ key, result }) => { + const next = { ...model, escalation: Keyed.accept(model.escalation, key, result) } + // A step that paged people shows in the activity, which no synced row re-keys. + const before = stepsSentOf(model) + const after = stepsSentOf(next) + return before !== null && after !== null && after !== before + ? refreshActivity(next, "Supersede") + : { model: next } + }, CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts new file mode 100644 index 0000000000..ffa4c6ff33 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -0,0 +1,53 @@ +import { OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" +import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +/** The browser's zone (a new schedule's default), the clock and the zones to pick from. */ +export const ReadScheduleFormDefaults = Command.define("ReadScheduleFormDefaults", { + messages: [Message.ReadDefaults], + execute: Effect.map( + Effect.all([browserZone, Clock.currentTimeMillis, readSupportedTimeZones]), + ([timeZone, nowMs, zones]) => Message.ReadDefaults({ timeZone, nowMs, zones }), + ), +}) + +export const FetchExistingSchedule = Command.define("FetchScheduleFormSchedule", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, scheduleId }) => + retrieveOnCallSchedule(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +export const FetchScheduleFormMembers = Command.define("FetchScheduleFormMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveSchedule = Command.define("SaveOnCallSchedule", { + args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveOnCallSchedule(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts new file mode 100644 index 0000000000..dbb603b9ee --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -0,0 +1,313 @@ +import { + MAX_ONCALL_LAYERS, + MAX_ONCALL_PARTICIPANTS, + MAX_ONCALL_RESTRICTIONS, + OnCallLocalTime, + OnCallScheduleDescription, + OnCallScheduleName, + OnCallTimeZone, + OnCallWeekday, + UserId, +} from "@maple/domain/http" +import type { + V2OnCallLayer, + V2OnCallRestriction, + V2OnCallRotation, + V2OnCallSchedule, + V2OnCallScheduleCreateParams, +} from "@maple/domain/http/v2" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import { isoMs, msIso } from "../shared/time" + +// The schedule form's draft: name, zone and up to five layers, each with its rotation and +// restrictions, held as typed text and checked against the API's own schemas. + +export const RotationKind = Schema.Literals(["daily", "weekly", "custom"]) +export type RotationKind = typeof RotationKind.Type + +export const RestrictionDraft = Schema.Struct({ + key: Schema.String, + weekdays: Schema.Array(OnCallWeekday), + startTime: Schema.String, + endTime: Schema.String, +}) +export type RestrictionDraft = typeof RestrictionDraft.Type + +export const LayerDraft = Schema.Struct({ + key: Schema.String, + name: Schema.String, + /** In rotation order. */ + participants: Schema.Array(UserId), + rotation: RotationKind, + handoffWeekday: OnCallWeekday, + handoffTime: Schema.String, + lengthHours: Schema.String, + /** `YYYY-MM-DDTHH:MM` on the schedule zone's wall clock (a `datetime-local` value). */ + startsAt: Schema.String, + /** The saved start, sent back exactly (seconds, a repeated DST hour) until its text is edited. */ + startsAtMs: Schema.NullOr(Schema.Number), + restrictions: Schema.Array(RestrictionDraft), +}) +export type LayerDraft = typeof LayerDraft.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + timeZone: Schema.String, + layers: Schema.Array(LayerDraft), + /** The next key a layer or restriction takes, so rows keep identity across reorders. */ + nextKey: Schema.Number, +}) +export type Form = typeof Form.Type + +export const WEEKDAYS: ReadonlyArray> = [ + { value: "mon", label: "Mon" }, + { value: "tue", label: "Tue" }, + { value: "wed", label: "Wed" }, + { value: "thu", label: "Thu" }, + { value: "fri", label: "Fri" }, + { value: "sat", label: "Sat" }, + { value: "sun", label: "Sun" }, +] + +const pad = (n: number) => Str.padStart(2, "0")(String(n)) + +/** `ms` as a `datetime-local` value on `timeZone`'s wall clock. */ +export const localInputOf = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${Str.padStart(4, "0")(String(parts.year))}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` +} + +const LOCAL_INPUT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/u + +/** A `datetime-local` value read on `timeZone`'s wall clock, as epoch ms. */ +export const localInputMs = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_INPUT.exec(value) + if (match === null || !isZone(timeZone)) return Option.none() + const [, year, month, day, hour, minute] = Arr.map(match, Number) + const ms = zonedPartsToEpochMs( + { + year: year ?? 0, + month: month ?? 1, + day: day ?? 1, + hour: hour ?? 0, + minute: minute ?? 0, + second: 0, + }, + timeZone, + ) + // The API takes no instant before 1970, so the form refuses one too. + return Number.isFinite(ms) && ms >= 0 ? Option.some(ms) : Option.none() +} + +/** Today at 09:00 in the zone: where a new layer's rotation starts. */ +const defaultStart = (nowMs: number, timeZone: string): string => + `${Str.slice(0, 10)(localInputOf(nowMs, isZone(timeZone) ? timeZone : "UTC"))}T09:00` + +export const emptyLayer = (key: number, index: number, nowMs: number, timeZone: string): LayerDraft => ({ + key: `l${key}`, + name: index === 0 ? "Primary" : `Layer ${index + 1}`, + participants: [], + rotation: "weekly", + handoffWeekday: "mon", + handoffTime: "09:00", + lengthHours: "12", + startsAt: defaultStart(nowMs, timeZone), + startsAtMs: null, + restrictions: [], +}) + +export const emptyRestriction = (key: number): RestrictionDraft => ({ + key: `r${key}`, + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "18:00", + endTime: "08:00", +}) + +export const emptyForm = (timeZone: string, nowMs: number): Form => ({ + name: "", + description: "", + timeZone, + layers: [emptyLayer(0, 0, nowMs, timeZone)], + nextKey: 1, +}) + +/** The form filled from a saved schedule; its starts read on the schedule's own zone. */ +export const formFromSchedule = (schedule: V2OnCallSchedule): Form => { + const layers = Arr.map(schedule.layers, (layer, layerIndex): LayerDraft => { + const rotation = layer.rotation + return { + key: `l${layerIndex}`, + name: layer.name, + participants: layer.participants, + rotation: rotation.kind, + handoffWeekday: rotation.kind === "weekly" ? rotation.handoff_weekday : "mon", + handoffTime: rotation.kind === "custom" ? "09:00" : rotation.handoff_time, + lengthHours: rotation.kind === "custom" ? String(rotation.length_hours) : "12", + startsAt: localInputOf(isoMs(layer.starts_at), schedule.time_zone), + startsAtMs: isoMs(layer.starts_at), + restrictions: Arr.map(layer.restrictions ?? [], (restriction, index) => ({ + key: `r${layerIndex}x${index}`, + weekdays: restriction.weekdays, + startTime: restriction.start_time, + endTime: restriction.end_time, + })), + } + }) + return { + name: schedule.name, + description: schedule.description ?? "", + timeZone: schedule.time_zone, + layers, + nextKey: layers.length, + } +} + +// VALIDATION: the limits the API's schemas hold, as one message per field. + +const isName = Schema.is(OnCallScheduleName) +const isDescription = Schema.is(OnCallScheduleDescription) +const isZone = Schema.is(OnCallTimeZone) +const isLocalTime = Schema.is(OnCallLocalTime) +const isShiftLength = (raw: string) => { + const hours = Number(Str.trim(raw)) + return Str.trim(raw) !== "" && Number.isInteger(hours) && hours >= 1 && hours <= 336 +} + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the schedule") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const zoneError = (zone: string): Option.Option => + isZone(zone) ? Option.none() : Option.some("Pick a time zone from the list") + +/** One layer's field errors, by field. */ +export type LayerErrors = Readonly<{ + name: Option.Option + participants: Option.Option + handoff: Option.Option + startsAt: Option.Option + restrictions: ReadonlyArray> +}> + +const restrictionError = (restriction: RestrictionDraft): Option.Option => + restriction.weekdays.length === 0 + ? Option.some("Pick at least one day") + : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) + ? Option.some("Use HH:MM times") + : Option.none() + +/** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ +const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => + layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) + +export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => { + const name = Str.trim(layer.name) + return { + name: + name === "" + ? Option.some("Name the layer") + : name.length > 100 + ? Option.some("Keep the layer name to 100 characters") + : Option.none(), + participants: + layer.participants.length === 0 + ? Option.some("Add at least one person") + : layer.participants.length > MAX_ONCALL_PARTICIPANTS + ? Option.some(`A layer rotates at most ${MAX_ONCALL_PARTICIPANTS} people`) + : Option.none(), + handoff: + layer.rotation === "custom" + ? isShiftLength(layer.lengthHours) + ? Option.none() + : Option.some("Shifts last 1 to 336 whole hours") + : isLocalTime(layer.handoffTime) + ? Option.none() + : Option.some("Use an HH:MM handoff time"), + startsAt: Option.isSome(layerStartMs(layer, timeZone)) + ? Option.none() + : Option.some("Pick when the rotation starts"), + restrictions: Arr.map(layer.restrictions, restrictionError), + } +} + +const hasLayerError = (errors: LayerErrors) => + Option.isSome(errors.name) || + Option.isSome(errors.participants) || + Option.isSome(errors.handoff) || + Option.isSome(errors.startsAt) || + Arr.some(errors.restrictions, Option.isSome) + +/** What still blocks saving, one short phrase each, for the action bar. */ +export const formIssues = (form: Form): ReadonlyArray => [ + ...(Option.isSome(nameError(form.name)) ? ["Name"] : []), + ...(Option.isSome(descriptionError(form.description)) ? ["Description"] : []), + ...(Option.isSome(zoneError(form.timeZone)) ? ["Time zone"] : []), + ...(form.layers.length === 0 ? ["At least one layer"] : []), + ...Arr.flatMap(form.layers, (layer, index) => + hasLayerError(layerErrors(layer, form.timeZone)) ? [`Layer ${index + 1}`] : [], + ), +] + +const rotationOf = (layer: LayerDraft): V2OnCallRotation => + layer.rotation === "daily" + ? { kind: "daily", handoff_time: layer.handoffTime } + : layer.rotation === "weekly" + ? { kind: "weekly", handoff_weekday: layer.handoffWeekday, handoff_time: layer.handoffTime } + : { kind: "custom", length_hours: Number(Str.trim(layer.lengthHours)) } + +const restrictionOf = (restriction: RestrictionDraft): Option.Option => + Arr.isReadonlyArrayNonEmpty(restriction.weekdays) + ? Option.some({ + weekdays: restriction.weekdays, + start_time: restriction.startTime, + end_time: restriction.endTime, + }) + : Option.none() + +const layerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Option.flatMap(layerStartMs(layer, timeZone), (startMs) => { + const participants = layer.participants + return Arr.isReadonlyArrayNonEmpty(participants) + ? Option.map( + Option.all(Arr.map(layer.restrictions, restrictionOf)), + (restrictions): V2OnCallLayer => ({ + name: Str.trim(layer.name), + participants, + rotation: rotationOf(layer), + starts_at: msIso(startMs), + ...(restrictions.length === 0 ? undefined : { restrictions }), + }), + ) + : Option.none() + }) + +/** The create payload the form describes, or none while anything blocks saving. */ +export const payloadOf = (form: Form): Option.Option => + formIssues(form).length > 0 || form.layers.length > MAX_ONCALL_LAYERS + ? Option.none() + : Option.flatMap( + Option.all(Arr.map(form.layers, (layer) => layerOf(layer, form.timeZone))), + (layers) => + Arr.isReadonlyArrayNonEmpty(layers) + ? Option.some({ + name: Str.trim(form.name), + description: Str.isEmpty(Str.trim(form.description)) + ? null + : Str.trim(form.description), + time_zone: form.timeZone, + layers, + }) + : Option.none(), + ) + +export { MAX_ONCALL_LAYERS, MAX_ONCALL_RESTRICTIONS } diff --git a/apps/web/src/page/alerts/schedule-form/index.ts b/apps/web/src/page/alerts/schedule-form/index.ts new file mode 100644 index 0000000000..6dcd7b46cc --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/index.ts @@ -0,0 +1,23 @@ +import { definePage } from "../../contract" +import { oncallTabHref } from "../shared/oncall-schedules" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +/** `/alerts/schedules/new`: one instance per schedule being edited, or the new one. */ +export const page = definePage( + "AlertsScheduleNew", + { Model, Message }, + { + routes: ["AlertsScheduleNew"], + key: (route) => route.search.scheduleId ?? "new", + init, + update, + view, + breadcrumbs: () => [ + { label: "Alerts", href: "/alerts" }, + { label: "On-call", href: oncallTabHref() }, + ], + }, +) diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts new file mode 100644 index 0000000000..922636d3dc --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -0,0 +1,52 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallWeekday, UserId } from "@maple/domain/http" +import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" +import { RotationKind } from "./form" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Direction = Schema.Literals(["up", "down"]) +export type Direction = typeof Direction.Type + +/** A layer's text fields. */ +export const LayerField = Schema.Literals(["name", "handoffTime", "lengthHours", "startsAt"]) +export type LayerField = typeof LayerField.Type + +export const Message = defineMessageUnion({ + ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, + CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, + ClickedRetryExisting: {}, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + GotZoneMessage: { message: Combobox.Message }, + ClickedAddLayer: {}, + ClickedRemoveLayer: { key: Schema.String }, + /** `up` raises the layer over the one above it, as the form draws them top first. */ + ClickedMoveLayer: { key: Schema.String, direction: Direction }, + ChangedLayerText: { key: Schema.String, field: LayerField, value: Schema.String }, + PickedRotation: { key: Schema.String, rotation: RotationKind }, + PickedHandoffWeekday: { key: Schema.String, weekday: OnCallWeekday }, + GotParticipantMenuMessage: { key: Schema.String, message: Menu.Message }, + ClickedRemoveParticipant: { key: Schema.String, userId: UserId }, + ClickedMoveParticipant: { key: Schema.String, userId: UserId, direction: Direction }, + ClickedAddRestriction: { key: Schema.String }, + ClickedRemoveRestriction: { key: Schema.String, restrictionKey: Schema.String }, + ToggledRestrictionDay: { key: Schema.String, restrictionKey: Schema.String, weekday: OnCallWeekday }, + ChangedRestrictionTime: { + key: Schema.String, + restrictionKey: Schema.String, + edge: Schema.Literals(["start", "end"]), + value: Schema.String, + }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2OnCallSchedule) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts new file mode 100644 index 0000000000..da4206ac16 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -0,0 +1,49 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { Array as Arr, Order, Schema, String as Str } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import { AlertsScheduleNewSearch } from "../../../route/search" +import { OrgMember } from "../shared/org-members" +import { Form } from "./form" + +// `/alerts/schedules/new`: create an on-call schedule, or edit one (`?scheduleId=`). + +export const Model = Schema.Struct({ + search: AlertsScheduleNewSearch, + /** The schedule being edited; `null` creates. */ + editingId: Schema.NullOr(OnCallScheduleId), + /** The edited schedule's read; the form fills from it once. */ + existing: KeyedSchema(V2OnCallSchedule, LoadFailure), + form: Form, + /** The browser's zone and clock arrived: a new form starts from them. */ + isReady: Schema.Boolean, + nowMs: Schema.Number, + /** Every IANA zone the browser knows, for the zone picker. */ + zones: Schema.Array(Schema.String), + zonePicker: Combobox.Model, + members: Schema.NullOr(Schema.Array(OrgMember)), + membersError: Schema.NullOr(Schema.String), + /** Each layer's "Add person" menu, by layer key. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: each field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveFailure: Schema.NullOr(LoadFailure), + grants: Schema.Array(Policy.PermissionName), + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The picker's zones: the browser's list, plus `UTC` (which it leaves out) and the form's own zone. */ +export const zoneItems = (model: Pick): ReadonlyArray => + Arr.sort( + Arr.dedupe(Arr.filter([...model.zones, "UTC", model.form.timeZone], Str.isNonEmpty)), + Order.String, + ) + +/** The viewer may change schedules (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts new file mode 100644 index 0000000000..f290843e39 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -0,0 +1,309 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallScheduleId, UserId } from "@maple/domain/http" +import { Option, Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { + checkoutSchedule, + memberId, + ONCALL_NOW, + oncallMembers, + schedulePublicId, + scheduleUuid, +} from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { formFromSchedule, localInputMs, localInputOf, payloadOf } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const ZONES = ["UTC", "Europe/Berlin", "America/New_York"] + +const ready = (search: { scheduleId?: string } = {}) => { + const entered = init({ _tag: "AlertsScheduleNew", search }, context) + const base = run(entered.model, [ + Message.ReadDefaults({ timeZone: "Europe/Berlin", nowMs: ONCALL_NOW, zones: ZONES }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ]) + return search.scheduleId === undefined + ? base + : run(base, [ + Message.CompletedFetchExisting({ + key: base.existing.key ?? "", + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + ]) +} + +const addPerson = (key: string, index: number) => + Message.GotParticipantMenuMessage({ + key, + message: { _tag: "ClickedItem", index, item: { kind: "Item", value: memberId(index) } }, + }) + +describe("schedule form", () => { + test("a new schedule starts on the viewer's zone, today at 09:00, with one weekly layer", () => { + const model = ready() + expect(model.form.timeZone).toBe("Europe/Berlin") + expect(model.form.layers).toHaveLength(1) + expect(model.form.layers[0]?.startsAt).toBe("2026-11-10T09:00") + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("New schedule")).toExist(), + expectView(role("combobox", { name: "Time zone" })).toExist(), + expectView(text("Nobody yet. Add who takes turns.")).toExist(), + ) + }) + + test("an edited schedule fills the form and saves back the layers it was read with", () => { + const schedule = checkoutSchedule(ONCALL_NOW) + const model = ready({ scheduleId: schedulePublicId(1) }) + expect(model.form).toEqual(formFromSchedule(schedule)) + const payload = payloadOf(model.form) + expect(Option.map(payload, (p) => p.layers)).toEqual(Option.some(schedule.layers)) + expect(Option.map(payload, (p) => p.time_zone)).toEqual(Option.some("Europe/Berlin")) + }) + + test("a start reads on the schedule's wall clock, across a DST change", () => { + expect(localInputMs("2026-10-25T09:00", "Europe/Berlin")).toEqual( + Option.some(Date.parse("2026-10-25T08:00:00.000Z")), + ) + expect(localInputMs("2026-10-24T09:00", "Europe/Berlin")).toEqual( + Option.some(Date.parse("2026-10-24T07:00:00.000Z")), + ) + expect(localInputMs("tomorrow", "Europe/Berlin")).toEqual(Option.none()) + }) + + test("saving with what the API refuses shows each problem and sends nothing", () => { + const model = run(ready(), [ + Message.ChangedLayerText({ key: "l0", field: "handoffTime", value: "25:00" }), + Message.ClickedAddRestriction({ key: "l0" }), + ...Arr.map(["mon", "tue", "wed", "thu", "fri"] as const, (weekday) => + Message.ToggledRestrictionDay({ key: "l0", restrictionKey: "r1", weekday }), + ), + ]) + const step = pageUpdate(model, Message.ClickedSave()) + expect(names(step.commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(step.model), + expectView(text("Name the schedule")).toExist(), + expectView(text("Add at least one person")).toExist(), + expectView(text("Use an HH:MM handoff time")).toExist(), + expectView(text("Pick at least one day")).toExist(), + ) + }) + + test("people rotate in the order picked, and move up and down", () => { + const model = run(ready(), [ + Message.ChangedName({ value: "Checkout primary" }), + addPerson("l0", 0), + addPerson("l0", 1), + addPerson("l0", 2), + Message.ClickedMoveParticipant({ + key: "l0", + userId: Schema.decodeSync(UserId)(memberId(2)), + direction: "up", + }), + ]) + expect(model.form.layers[0]?.participants).toEqual([memberId(0), memberId(2), memberId(1)]) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: `Move ${oncallMembers[2]?.name} up in layer 1` })).toExist(), + expectView(role("button", { name: `Remove ${oncallMembers[2]?.name} in layer 1` })).toExist(), + ) + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveOnCallSchedule"]) + }) + + test("a refused save keeps the form and says why", () => { + const saving = pageUpdate(run(ready({ scheduleId: schedulePublicId(1) }), []), Message.ClickedSave()) + expect(names(saving.commands)).toEqual(["SaveOnCallSchedule"]) + const failed = pageUpdate( + saving.model, + Message.CompletedSave({ + result: Result.fail({ + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message: "Omar Haddad is no longer a member of this organization.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + scene( + { update: pageUpdate, view }, + given(failed.model), + expectView(text("Couldn't save the schedule")).toExist(), + expectView(text("Omar Haddad is no longer a member of this organization.")).toExist(), + ) + }) + + test("layers draw top first, and Move up on the bottom layer makes it the top one in the saved payload", () => { + const model = run(ready(), [ + Message.ChangedName({ value: "Checkout" }), + addPerson("l0", 0), + Message.ClickedAddLayer(), + addPerson("l1", 1), + Message.ChangedLayerText({ key: "l1", field: "name", value: "Nights" }), + ]) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Move layer 1 up" })).toExist(), + expectView(text("Layer 2, on top")).toExist(), + expectView(text("Layer 1, at the bottom")).toExist(), + ) + const raised = run(model, [Message.ClickedMoveLayer({ key: "l0", direction: "up" })]) + expect( + Option.map(payloadOf(raised.form), (payload) => Arr.map(payload.layers, (layer) => layer.name)), + ).toEqual(Option.some(["Nights", "Primary"])) + }) + + test("a window's midnight note waits until both times are complete", () => { + const model = run(ready(), [Message.ClickedAddRestriction({ key: "l0" })]) + scene({ update: pageUpdate, view }, given(model), expectView(text("Ends the next morning")).toExist()) + scene( + { update: pageUpdate, view }, + given( + run(model, [ + Message.ChangedRestrictionTime({ + key: "l0", + restrictionKey: "r1", + edge: "end", + value: "", + }), + ]), + ), + expectView(text("Ends the next morning")).not.toExist(), + expectView(text("Same day")).not.toExist(), + ) + }) + + test("an edit sends a layer's saved start back exactly, seconds and a repeated DST hour included, until its text changes", () => { + const schedule = checkoutSchedule(ONCALL_NOW) + const repeatedHour = "2026-10-25T01:30:15.000Z" // 02:30:15 CET, after Berlin fell back + const edited = { + ...schedule, + layers: Arr.map(schedule.layers, (layer, index) => + index === 0 ? { ...layer, starts_at: repeatedHour } : layer, + ), + } + const base = ready() + const loaded = run({ ...base, editingId: Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) }, []) + const filled = { ...loaded, form: formFromSchedule(edited) } + expect(Option.map(payloadOf(filled.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.some(repeatedHour), + ) + const retyped = run(filled, [ + Message.ChangedLayerText({ key: "l0", field: "startsAt", value: "2026-10-26T09:00" }), + ]) + expect(Option.map(payloadOf(retyped.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.some("2026-10-26T08:00:00.000Z"), + ) + }) + + test("UTC can be picked although the browser's zone list leaves it out", () => { + const model = run(ready(), [ + Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: ["Europe/Berlin", "America/New_York"], + }), + Message.GotZoneMessage({ message: { _tag: "ClickedItem", value: "UTC" } }), + ]) + expect(model.form.timeZone).toBe("UTC") + }) + + test("a new zone keeps each layer's start on the wall clock shown, and saves it in that zone", () => { + const base = ready() + const loaded = run({ ...base, editingId: Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) }, []) + const filled = { ...loaded, form: formFromSchedule(checkoutSchedule(ONCALL_NOW)) } + const shown = filled.form.layers[0]?.startsAt ?? "" + const moved = run(filled, [ + Message.GotZoneMessage({ message: { _tag: "ClickedItem", value: "America/New_York" } }), + ]) + expect(moved.form.layers[0]?.startsAt).toBe(shown) + expect(Option.map(payloadOf(moved.form), (payload) => payload.layers[0].starts_at)).toEqual( + Option.map(localInputMs(shown, "America/New_York"), (ms) => new Date(ms).toISOString()), + ) + }) + + test("a window whose From and To are the same time saves as a full day, and says so", () => { + const model = run(ready(), [ + Message.ChangedName({ value: "Checkout primary" }), + addPerson("l0", 0), + Message.ClickedAddRestriction({ key: "l0" }), + Message.ChangedRestrictionTime({ key: "l0", restrictionKey: "r1", edge: "end", value: "18:00" }), + ]) + expect( + Option.map(payloadOf(model.form), (payload) => payload.layers[0].restrictions?.[0]?.end_time), + ).toEqual(Option.some("18:00")) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Runs 24 hours, to the same time the next day")).toExist(), + expectView(role("button", { name: "Remove this window from layer 1" })).toExist(), + expectView(role("group", { name: "Days of window 1 in layer 1" })).toExist(), + expectView(role("group", { name: "Rotation in layer 1" })).toExist(), + expectView(text("Ends the next morning")).not.toExist(), + ) + }) + + test("a start before 1970 is refused, as the API refuses it", () => { + expect(localInputMs("1969-12-31T23:00", "UTC")).toEqual(Option.none()) + expect(localInputMs("1970-01-01T00:00", "UTC")).toEqual(Option.some(0)) + }) + + test("a start before year 1000 keeps four year digits", () => { + expect(localInputOf(Date.UTC(999, 0, 2, 9, 0), "UTC")).toBe("0999-01-02T09:00") + }) + + test("a failed load of the edited schedule offers Try again, which reads it again", () => { + const entered = init( + { _tag: "AlertsScheduleNew", search: { scheduleId: schedulePublicId(1) } }, + context, + ) + const failed = run(entered.model, [ + Message.ReadDefaults({ timeZone: "Europe/Berlin", nowMs: ONCALL_NOW, zones: ZONES }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchExisting({ + key: entered.model.existing.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the schedule", + retryable: false, + recovery: "retry", + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(role("button", { name: /Try again/u })).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetryExisting()).commands)).toContain( + "FetchScheduleFormSchedule", + ) + }) +}) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts new file mode 100644 index 0000000000..f8ee3957de --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -0,0 +1,323 @@ +import { UserId } from "@maple/domain/http" +import { Option, Result, Schema, Array as Arr } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Combobox from "@maple/views/ui/combobox" +import * as Menu from "@maple/views/ui/menu" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" +import { + FetchExistingSchedule, + FetchScheduleFormMembers, + FetchScheduleFormSession, + ReadScheduleFormDefaults, + SaveSchedule, +} from "./command" +import { + emptyForm, + emptyLayer, + emptyRestriction, + type Form, + formFromSchedule, + type LayerDraft, + MAX_ONCALL_LAYERS, + MAX_ONCALL_RESTRICTIONS, + payloadOf, + type RestrictionDraft, +} from "./form" +import { type Direction, Message } from "./message" +import { canManage, type Model, zoneItems } from "./model" + +type Return = PageReturn +type FormRoute = Extract + +export const ZONE_PICKER_ID = "schedule-form-zone" + +export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` + +export const init = (route: FormRoute, context: PageContext): Return => { + const editingId = Option.getOrNull( + Option.flatMap(Option.fromNullishOr(route.search.scheduleId), scheduleIdFromUrl), + ) + const model: Model = { + search: route.search, + editingId, + existing: Keyed.idle(), + form: emptyForm("UTC", 0), + isReady: false, + nowMs: 0, + zones: [], + zonePicker: Combobox.init(ZONE_PICKER_ID, "single", { selected: ["UTC"] }), + members: null, + membersError: null, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + grants: [], + isSessionLoaded: false, + } + const common = [ + ReadScheduleFormDefaults(), + FetchScheduleFormMembers(), + FetchScheduleFormSession({ orgId: context.orgId }), + ] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingSchedule({ key, scheduleId: editingId })], + }), + }) +} + +const withForm = (model: Model, form: Form): Return => ({ model: { ...model, form } }) + +/** The zone picker reads the form's zone again (a load, a new default). */ +const syncZone = (model: Model): Model => ({ + ...model, + zonePicker: Combobox.init(ZONE_PICKER_ID, "single", { selected: [model.form.timeZone] }), +}) + +const editLayer = (model: Model, key: string, edit: (layer: LayerDraft) => LayerDraft): Return => + withForm(model, { + ...model.form, + layers: Arr.map(model.form.layers, (layer) => (layer.key === key ? edit(layer) : layer)), + }) + +const editRestriction = ( + model: Model, + key: string, + restrictionKey: string, + edit: (restriction: RestrictionDraft) => RestrictionDraft, +): Return => + editLayer(model, key, (layer) => ({ + ...layer, + restrictions: Arr.map(layer.restrictions, (restriction) => + restriction.key === restrictionKey ? edit(restriction) : restriction, + ), + })) + +/** Swaps an item with its neighbour; at either end the list stays as it is. */ +export const moved = (items: ReadonlyArray, index: number, direction: Direction): ReadonlyArray => { + const to = direction === "up" ? index - 1 : index + 1 + const moving = items[index] + const other = items[to] + if (moving === undefined || other === undefined) return items + return Arr.map(items, (item, at) => (at === index ? other : at === to ? moving : item)) +} + +const swapIndex = (items: ReadonlyArray, find: (item: A) => boolean, direction: Direction) => + Option.match(Arr.findFirstIndex(items, find), { + onNone: () => items, + onSome: (index) => moved(items, index, direction), + }) + +const decodeUserId = Schema.decodeUnknownOption(UserId) + +/** A layer's "Add person" menu: a pick appends the member to the rotation. */ +const participantMenu = (model: Model, key: string, message: Menu.Message): Return => { + const result = Menu.update(model.menus[key] ?? Menu.init(participantMenuId(key)), message) + const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotParticipantMenuMessage({ key, message: child }), + ) + const picked = Option.flatMap( + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), + decodeUserId, + ) + return Option.match(picked, { + onNone: () => ({ model: next, commands }), + onSome: (userId) => ({ + ...editLayer(next, key, (layer) => + layer.participants.includes(userId) + ? layer + : { ...layer, participants: [...layer.participants, userId] }, + ), + commands, + }), + }) +} + +const zonePicked = (model: Model, message: Combobox.Message): Return => { + const step = Combobox.update(model.zonePicker, message) + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (child) => + Message.GotZoneMessage({ message: child }), + ) + const zone = + step.outMessage?._tag === "ChangedValue" && zoneItems(model).includes(step.outMessage.value) + ? step.outMessage.value + : null + return { + model: { + ...model, + zonePicker: step.model, + // Each layer keeps its wall-clock start, now read in the new zone, so what is shown is what saves. + form: + zone === null || zone === model.form.timeZone + ? model.form + : { + ...model.form, + timeZone: zone, + layers: Arr.map(model.form.layers, (layer) => ({ ...layer, startsAtMs: null })), + }, + }, + commands, + } +} + +const save = (model: Model): Return => + model.isSaving || !canManage(model) + ? { model } + : Option.match(payloadOf(model.form), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveFailure: null }, + commands: [SaveSchedule({ editingId: model.editingId, payload })], + }), + }) + +const addRestriction = (model: Model, key: string): Return => { + const added = editLayer(model, key, (layer) => + layer.restrictions.length >= MAX_ONCALL_RESTRICTIONS + ? layer + : { ...layer, restrictions: [...layer.restrictions, emptyRestriction(model.form.nextKey)] }, + ) + return withForm(added.model, { ...added.model.form, nextKey: added.model.form.nextKey + 1 }) +} + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadDefaults: ({ timeZone, nowMs, zones }) => { + const ready: Model = { ...model, nowMs, zones, isReady: true } + // An edit fills from the schedule; a new one starts on the viewer's zone and today. + return model.editingId !== null + ? { model: ready } + : { model: syncZone({ ...ready, form: emptyForm(timeZone, nowMs) }) } + }, + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + const loaded = Option.getOrNull(AsyncData.getData(existing.data)) + return { + model: + loaded === null + ? { ...model, existing } + : syncZone({ ...model, existing, form: formFromSchedule(loaded) }), + } + }, + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members, membersError: null } }), + // Members stay unknown (names read "Member"), and the picker says why it is empty. + onFailure: (error) => ({ model: { ...model, members: null, membersError: error } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedName: ({ value }) => withForm(model, { ...model.form, name: value }), + ChangedDescription: ({ value }) => withForm(model, { ...model.form, description: value }), + GotZoneMessage: ({ message: child }) => zonePicked(model, child), + ClickedAddLayer: () => + model.form.layers.length >= MAX_ONCALL_LAYERS + ? { model } + : withForm(model, { + ...model.form, + layers: [ + ...model.form.layers, + emptyLayer( + model.form.nextKey, + model.form.layers.length, + model.nowMs, + model.form.timeZone, + ), + ], + nextKey: model.form.nextKey + 1, + }), + ClickedRemoveLayer: ({ key }) => + withForm(model, { + ...model.form, + layers: Arr.filter(model.form.layers, (layer) => layer.key !== key), + }), + // Layers are stored bottom first and drawn top first: "up" raises a layer over the next one. + ClickedMoveLayer: ({ key, direction }) => + withForm(model, { + ...model.form, + layers: swapIndex( + model.form.layers, + (layer) => layer.key === key, + direction === "up" ? "down" : "up", + ), + }), + ClickedRetryExisting: () => { + const editingId = model.editingId + if (editingId === null) return { model } + const existing = Keyed.supersede( + model.existing, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }), + ) + return { + model: { ...model, existing }, + commands: [FetchExistingSchedule({ key: existing.key, scheduleId: editingId })], + } + }, + ChangedLayerText: ({ key, field, value }) => + field === "startsAt" + ? editLayer(model, key, (layer) => ({ ...layer, startsAt: value, startsAtMs: null })) + : editLayer(model, key, (layer) => ({ ...layer, [field]: value })), + PickedRotation: ({ key, rotation }) => editLayer(model, key, (layer) => ({ ...layer, rotation })), + PickedHandoffWeekday: ({ key, weekday }) => + editLayer(model, key, (layer) => ({ ...layer, handoffWeekday: weekday })), + GotParticipantMenuMessage: ({ key, message: child }) => participantMenu(model, key, child), + ClickedRemoveParticipant: ({ key, userId }) => + editLayer(model, key, (layer) => ({ + ...layer, + participants: Arr.filter(layer.participants, (id) => id !== userId), + })), + ClickedMoveParticipant: ({ key, userId, direction }) => + editLayer(model, key, (layer) => ({ + ...layer, + participants: swapIndex(layer.participants, (id) => id === userId, direction), + })), + ClickedAddRestriction: ({ key }) => addRestriction(model, key), + ClickedRemoveRestriction: ({ key, restrictionKey }) => + editLayer(model, key, (layer) => ({ + ...layer, + restrictions: Arr.filter( + layer.restrictions, + (restriction) => restriction.key !== restrictionKey, + ), + })), + ToggledRestrictionDay: ({ key, restrictionKey, weekday }) => + editRestriction(model, key, restrictionKey, (restriction) => ({ + ...restriction, + weekdays: restriction.weekdays.includes(weekday) + ? Arr.filter(restriction.weekdays, (day) => day !== weekday) + : [...restriction.weekdays, weekday], + })), + ChangedRestrictionTime: ({ key, restrictionKey, edge, value }) => + editRestriction(model, key, restrictionKey, (restriction) => + edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, + ), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + onSuccess: (schedule): Return => ({ + model: { ...model, isSaving: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: scheduleHref(schedule.id), + isReplace: true, + toast: { + type: "success", + title: model.editingId === null ? "Schedule created" : "Changes saved", + description: "", + }, + }), + }), + onFailure: (failure): Return => ({ + model: { ...model, isSaving: false, saveFailure: failure }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts new file mode 100644 index 0000000000..3aca283c63 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -0,0 +1,564 @@ +import { OnCallLocalTime } from "@maple/domain/http" +import { Option, Schema, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import * as ComboboxView from "@maple/views/ui/combobox-view" +import { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" +import { multiSegmentedSelect, segmentedSelect } from "@maple/views/ui/segmented-select" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" +import { pickList } from "../../kit/pick-list" +import { oncallTabHref, scheduleHref } from "../shared/oncall-schedules" +import { memberName, memberOf } from "../shared/org-members" +import { + descriptionError, + formIssues, + type LayerDraft, + layerErrors, + MAX_ONCALL_LAYERS, + MAX_ONCALL_RESTRICTIONS, + nameError, + type RestrictionDraft, + RotationKind, + WEEKDAYS, + zoneError, +} from "./form" +import { type LayerField, Message } from "./message" +import { canManage, type Model, zoneItems } from "./model" +import { participantMenuId } from "./update" + +// `/alerts/schedules/new`: the schedule's name and zone, then its layers: who rotates, how they +// hand off, from when, and the weekly windows a layer is limited to. + +type H = HtmlBuilder + +const isLocalTime = Schema.is(OnCallLocalTime) + +const FORM_WIDTH = "max-w-3xl" + +/** A field's error line once a save was tried. */ +const shown = (h: H, model: Model, error: Option.Option): ReadonlyArray => + model.isShowingErrors + ? Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, text)], + }) + : [] + +const fieldLabel = (h: H, id: string, text: string): Html => + label(h, { className: typographyClass({ size: "xs", tone: "muted" }), attributes: [h.For(id)] }, [text]) + +/** A labelled input bound to one value. */ +const textInput = ( + h: H, + options: Readonly<{ + id: string + label: string + value: string + type?: string + placeholder?: string + className?: string + /** The section's title already names it: the label is for screen readers. */ + isLabelHidden?: boolean + onInput: (value: string) => Message + }>, +): Html => + vstack(h, { gap: "1.5", className: options.className }, [ + options.isLabelHidden === true + ? label(h, { className: "sr-only", attributes: [h.For(options.id)] }, [options.label]) + : fieldLabel(h, options.id, options.label), + input(h, { + id: options.id, + type: options.type, + attributes: [ + h.Value(options.value), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ], + }), + ]) + +const nameSection = (h: H, model: Model): Html => + formSection(h, { title: "Name" }, [ + textInput(h, { + id: "schedule-name", + label: "Name", + isLabelHidden: true, + value: model.form.name, + placeholder: "Checkout primary", + onInput: (value) => Message.ChangedName({ value }), + }), + ...shown(h, model, nameError(model.form.name)), + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, "schedule-description", "Description (optional)"), + textarea(h, { + id: "schedule-description", + defaultValue: model.form.description, + attributes: [ + h.Rows(2), + h.Placeholder("Who is on this rotation and how to swap a shift"), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ]), + ...shown(h, model, descriptionError(model.form.description)), + ]) + +/** The schedule's zone: a searchable list of every IANA zone, the viewer's own first by default. */ +const zoneSection = (h: H, model: Model): Html => + formSection(h, { title: "Time zone", hint: "Handoffs and restrictions read in it" }, [ + vstack(h, { gap: "1.5", className: "max-w-sm" }, [ + label(h, { className: "sr-only", attributes: [h.For(`${model.zonePicker.id}-input`)] }, [ + "Time zone", + ]), + h.submodel({ + slotId: model.zonePicker.id, + model: model.zonePicker, + view: ComboboxView.view, + toParentMessage: (message) => Message.GotZoneMessage({ message }), + viewInputs: { + items: zoneItems(model), + ariaLabel: "Time zone", + placeholder: "Search time zones…", + emptyText: "No time zone matches that", + startAddon: icon(h, "GlobeIcon"), + className: "w-full", + highlightsSelected: true, + toView: ({ field, rest }) => h.div([h.Class("contents")], [field, ...rest]), + }, + }), + ]), + ...shown(h, model, zoneError(model.form.timeZone)), + ]) + +/** The rotation's people, in order, with an "Add person" menu of the rest. */ +const participantsField = (h: H, model: Model, layer: LayerDraft, index: number): Html => { + const key = layer.key + const count = layer.participants.length + const addEntries: ReadonlyArray = Arr.flatMap(model.members ?? [], (member) => + layer.participants.includes(member.userId) + ? [] + : [ + { + _tag: "Item", + value: member.userId, + label: member.name, + content: [ + hstack(h, { gap: "2", shrink: true, as: "span" }, [ + personAvatar(h, { name: member.name, imageUrl: member.imageUrl, size: "xs" }), + typography(h, { truncate: true, className: "min-w-0 flex-1" }, [member.name]), + ]), + ], + }, + ], + ) + return vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, ["People, in rotation order"]), + pickList(h, { + entries: Arr.map(layer.participants, (userId, at) => { + const name = memberName(model.members, userId) + return { + key: userId, + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, userId), (member) => member.imageUrl), + ), + }), + removeLabel: `Remove ${name}`, + onRemove: Message.ClickedRemoveParticipant({ key, userId }), + move: { + context: `layer ${index + 1}`, + up: + at === 0 + ? null + : Message.ClickedMoveParticipant({ key, userId, direction: "up" }), + down: + at === count - 1 + ? null + : Message.ClickedMoveParticipant({ key, userId, direction: "down" }), + }, + } + }), + emptyText: + model.membersError === null + ? "Nobody yet. Add who takes turns." + : `Couldn't load members: ${model.membersError}`, + menu: model.menus[key] ?? Menu.init(participantMenuId(key)), + toMenuMessage: (message) => Message.GotParticipantMenuMessage({ key, message }), + addEntries, + addLabel: "Add person", + // Names the layer in its buttons: "Remove Ben in layer 2", "Add person to layer 2". + context: `layer ${index + 1}`, + }), + ]) +} + +const ROTATIONS: ReadonlyArray> = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "custom", label: "Custom" }, +] + +const layerText = (key: string, field: LayerField) => (value: string) => + Message.ChangedLayerText({ key, field, value }) + +/** How the layer hands off: the kind, then its day and time or its shift length. */ +const rotationField = (h: H, layer: LayerDraft, index: number): Html => { + const key = layer.key + return vstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, ["Hand off"]), + segmentedSelect(h, { + ariaLabel: `Rotation in layer ${index + 1}`, + size: "sm", + className: "w-fit", + value: layer.rotation, + onChange: (rotation) => Message.PickedRotation({ key, rotation }), + options: ROTATIONS, + }), + hstack(h, { gap: "3", wrap: true, align: "end" }, [ + ...(layer.rotation === "weekly" + ? [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, `${key}-weekday`, "Day"), + nativeSelect( + h, + { + defaultValue: layer.handoffWeekday, + className: "w-32", + attributes: [ + h.Key(`${key}-weekday`), + h.Id(`${key}-weekday`), + h.OnChange((value) => + Option.match( + Arr.findFirst(WEEKDAYS, (day) => day.value === value), + { + onNone: () => + Message.PickedHandoffWeekday({ + key, + weekday: layer.handoffWeekday, + }), + onSome: (day) => + Message.PickedHandoffWeekday({ + key, + weekday: day.value, + }), + }, + ), + ), + ], + }, + Arr.map(WEEKDAYS, (day) => nativeOption(day.value, day.label)), + ), + ]), + ] + : []), + ...(layer.rotation === "custom" + ? [ + textInput(h, { + id: `${key}-length`, + label: "Every, in hours", + type: "number", + value: layer.lengthHours, + className: "w-32", + onInput: layerText(key, "lengthHours"), + }), + ] + : [ + textInput(h, { + id: `${key}-handoff`, + label: "At", + type: "time", + value: layer.handoffTime, + className: "w-32", + onInput: layerText(key, "handoffTime"), + }), + ]), + ]), + ]) +} + +/** One weekly window: its days, from and to (an end at or before the start crosses midnight). */ +const restrictionRow = ( + h: H, + layer: LayerDraft, + layerIndex: number, + windowIndex: number, + restriction: RestrictionDraft, +): Html => { + const key = layer.key + const restrictionKey = restriction.key + const isComplete = isLocalTime(restriction.startTime) && isLocalTime(restriction.endTime) + // Equal times are a full day, as the API reads them: to the same time the next day. + const midnightNote = + restriction.endTime === restriction.startTime + ? "Runs 24 hours, to the same time the next day" + : restriction.endTime < restriction.startTime + ? "Ends the next morning" + : "Same day" + return vstack(h, { gap: "2", className: "rounded-md border p-3", attributes: [h.Key(restrictionKey)] }, [ + hstack(h, { gap: "2", justify: "between", wrap: true }, [ + multiSegmentedSelect(h, { + ariaLabel: `Days of window ${windowIndex + 1} in layer ${layerIndex + 1}`, + size: "sm", + className: "flex-wrap", + value: restriction.weekdays, + onToggle: (weekday) => Message.ToggledRestrictionDay({ key, restrictionKey, weekday }), + options: Arr.map(WEEKDAYS, (day) => ({ + value: day.value, + label: day.label, + ariaLabel: day.label, + })), + }), + iconButton( + h, + { + label: `Remove this window from layer ${layerIndex + 1}`, + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedRemoveRestriction({ key, restrictionKey }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + hstack(h, { gap: "3", wrap: true, align: "end" }, [ + textInput(h, { + id: `${restrictionKey}-start`, + label: "From", + type: "time", + value: restriction.startTime, + className: "w-32", + onInput: (value) => + Message.ChangedRestrictionTime({ key, restrictionKey, edge: "start", value }), + }), + textInput(h, { + id: `${restrictionKey}-end`, + label: "To", + type: "time", + value: restriction.endTime, + className: "w-32", + onInput: (value) => + Message.ChangedRestrictionTime({ key, restrictionKey, edge: "end", value }), + }), + // A time still being typed says nothing about midnight yet. + ...(isComplete + ? [typography(h, { size: "xs", tone: "muted", className: "pb-2" }, [midnightNote])] + : []), + ]), + ]) +} + +const restrictionsField = (h: H, model: Model, layer: LayerDraft, layerIndex: number): Html => { + const errors = layerErrors(layer, model.form.timeZone).restrictions + return vstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + layer.restrictions.length === 0 + ? "Always active. Limit it to weekly windows, like nights or weekends." + : "Only active in these windows", + ]), + ...Arr.flatMap(layer.restrictions, (restriction, index) => [ + restrictionRow(h, layer, layerIndex, index, restriction), + ...shown(h, model, errors[index] ?? Option.none()), + ]), + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: layer.restrictions.length >= MAX_ONCALL_RESTRICTIONS, + attributes: [h.OnClick(Message.ClickedAddRestriction({ key: layer.key }))], + }, + [icon(h, "PlusIcon"), "Add window"], + ), + ]) +} + +/** One layer: its name and order, its people, how it hands off, from when, and its windows. */ +/** "Layer 2, on top": layers are numbered bottom first, and drawn top first. */ +export const layerLabel = (index: number, count: number): string => + count === 1 + ? "Layer 1" + : index === count - 1 + ? `Layer ${index + 1}, on top` + : index === 0 + ? "Layer 1, at the bottom" + : `Layer ${index + 1}` + +const layerBlock = (h: H, model: Model, layer: LayerDraft, index: number): Html => { + const key = layer.key + const count = model.form.layers.length + const errors = layerErrors(layer, model.form.timeZone) + return panel(h, { padded: "sm", className: "gap-4", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between", align: "end" }, [ + textInput(h, { + id: `${key}-name`, + label: layerLabel(index, count), + value: layer.name, + className: "min-w-0 max-w-xs flex-1", + onInput: layerText(key, "name"), + }), + hstack(h, { gap: "0.5" }, [ + iconButton( + h, + { + label: `Move layer ${index + 1} up`, + isDisabled: index === count - 1, + attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "up" }))], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move layer ${index + 1} down`, + isDisabled: index === 0, + attributes: [h.OnClick(Message.ClickedMoveLayer({ key, direction: "down" }))], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: `Remove layer ${index + 1}`, + isDisabled: count === 1, + attributes: [h.OnClick(Message.ClickedRemoveLayer({ key }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + ]), + ...shown(h, model, errors.name), + participantsField(h, model, layer, index), + ...shown(h, model, errors.participants), + rotationField(h, layer, index), + ...shown(h, model, errors.handoff), + textInput(h, { + id: `${key}-starts`, + label: `Starts, in ${model.form.timeZone}: the first person holds this shift`, + type: "datetime-local", + value: layer.startsAt, + className: "max-w-sm", + onInput: layerText(key, "startsAt"), + }), + ...shown(h, model, errors.startsAt), + restrictionsField(h, model, layer, index), + ]) +} + +const layersSection = (h: H, model: Model): Html => + formSection(h, { title: "Layers", hint: "Later layers sit on top where they have someone on call" }, [ + // Top layer first, so "up" reads as "wins over the layer below". + ...Arr.reverse(Arr.map(model.form.layers, (layer, index) => layerBlock(h, model, layer, index))), + ...shown( + h, + model, + model.form.layers.length === 0 ? Option.some("Add at least one layer") : Option.none(), + ), + hstack(h, { gap: "3" }, [ + button( + h, + { + variant: "outline", + size: "sm", + isDisabled: model.form.layers.length >= MAX_ONCALL_LAYERS, + attributes: [h.OnClick(Message.ClickedAddLayer())], + }, + [icon(h, "PlusIcon"), "Add layer"], + ), + typography(h, { size: "xs", tone: "muted", numeric: true }, [ + `${model.form.layers.length} of ${MAX_ONCALL_LAYERS}`, + ]), + ]), + ]) + +const backHref = (model: Model) => + model.editingId === null ? oncallTabHref() : scheduleHref(model.editingId) + +const actionBar = (h: H, model: Model): Html => { + const issues = formIssues(model.form) + return formActionBar(h, { + issues, + widthClass: FORM_WIDTH, + notice: + model.saveFailure === null + ? null + : alert(h, { variant: "crit", size: "sm", className: `mb-3 ${FORM_WIDTH}` }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null + ? "Couldn't create the schedule" + : "Couldn't save the schedule", + ]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + actions: [ + linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), + button( + h, + { + isDisabled: !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create schedule" : "Save changes"], + ), + ], + }) +} + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: isEdit ? "Edit schedule" : "New schedule", + description: "Who is on call when: people take turns in layers, in one time zone.", + } + if (model.isSessionLoaded && !canManage(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change on-call schedules", + back: { href: backHref(model), label: isEdit ? "Back to the schedule" : "Back to on-call" }, + }) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the schedule", + onRetry: Message.ClickedRetryExisting(), + }), + ]) + if (!model.isReady || (isEdit && !AsyncData.isSuccess(model.existing.data))) + return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + return dashboardPage(h, { ...header, fill: true }, [ + formScrollArea(h, [ + formCard(h, [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], FORM_WIDTH), + ]), + actionBar(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts new file mode 100644 index 0000000000..9cceccf980 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -0,0 +1,85 @@ +import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallOverrideCreateParams } from "@maple/domain/http/v2" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { + createOverride, + deleteOverride, + listOverrides, + listShifts, + retrieveOnCallSchedule, +} from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +export const ReadScheduleClock = Command.define("ReadScheduleClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchSchedule = Command.define("FetchOnCallSchedule", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchSchedule], + execute: ({ key, scheduleId }) => + Effect.gen(function* () { + const result = yield* Effect.result(retrieveOnCallSchedule(scheduleId)) + const readAtMs = yield* Clock.currentTimeMillis + return Message.CompletedFetchSchedule({ key, readAtMs, result }) + }), +}) + +export const FetchShifts = Command.define("FetchOnCallShifts", { + args: { key: Schema.String, scheduleId: OnCallScheduleId, fromMs: Schema.Number, toMs: Schema.Number }, + messages: [Message.CompletedFetchShifts], + execute: ({ key, scheduleId, fromMs, toMs }) => + listShifts(scheduleId, fromMs, toMs).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchShifts({ key, result })), + ), +}) + +export const FetchOverrides = Command.define("FetchOnCallOverrides", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchOverrides], + execute: ({ key, scheduleId }) => + listOverrides(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchOverrides({ key, result })), + ), +}) + +export const FetchScheduleMembers = Command.define("FetchScheduleMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchScheduleSession = Command.define("FetchScheduleSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const CreateOverride = Command.define("CreateOnCallOverride", { + args: { openCount: Schema.Number, scheduleId: OnCallScheduleId, payload: V2OnCallOverrideCreateParams }, + messages: [Message.CompletedCreateOverride], + execute: ({ openCount, scheduleId, payload }) => + createOverride(scheduleId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedCreateOverride({ openCount, result })), + ), +}) + +export const DeleteOverride = Command.define("DeleteOnCallOverride", { + args: { scheduleId: OnCallScheduleId, overrideId: OnCallOverrideId }, + messages: [Message.CompletedDeleteOverride], + execute: ({ scheduleId, overrideId }) => + deleteOverride(scheduleId, overrideId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/schedule/index.ts b/apps/web/src/page/alerts/schedule/index.ts new file mode 100644 index 0000000000..eb7cc1d1eb --- /dev/null +++ b/apps/web/src/page/alerts/schedule/index.ts @@ -0,0 +1,39 @@ +import { Clock, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +/** The now line and the range follow the clock, on the hub's 30-second cadence. */ +const CLOCK_TICK = "30 seconds" + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + clock: entry(Schema.Struct({}).fields, { + modelToDependencies: () => ({}), + dependenciesToStream: () => + Stream.tick(CLOCK_TICK).pipe( + Stream.drop(1), + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), + ), + ), + }), +})) + +/** `/alerts/schedules/$scheduleId`: one instance per schedule. */ +export const page = definePage( + "AlertsSchedule", + { Model, Message }, + { + routes: ["AlertsSchedule"], + key: (route) => route.scheduleId, + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, + }, +) diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts new file mode 100644 index 0000000000..cf1187acc6 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -0,0 +1,43 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallOverrideId } from "@maple/domain/http" +import { V2OnCallOverride, V2OnCallSchedule, V2OnCallShifts } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { OrgMember } from "../shared/org-members" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchSchedule: { + key: Schema.String, + readAtMs: Schema.Number, + result: outcome(V2OnCallSchedule), + }, + CompletedFetchShifts: { key: Schema.String, result: outcome(V2OnCallShifts) }, + CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ClickedRetry: {}, + ClickedRetryShifts: {}, + ClickedRetryOverrides: {}, + ClickedPreviousWeek: {}, + ClickedNextWeek: {}, + ClickedThisWeek: {}, + /** A final shift's bar: an override over its whole range, for someone else to cover. */ + ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, + ClickedNewOverride: {}, + GotOverrideDialogMessage: { message: Dialog.Message }, + ChangedOverrideUser: { value: Schema.String }, + ChangedOverrideStart: { value: Schema.String }, + ChangedOverrideEnd: { value: Schema.String }, + ClickedCreateOverride: {}, + CompletedCreateOverride: { openCount: Schema.Number, result: outcome(V2OnCallOverride) }, + ClickedDeleteOverride: { overrideId: OnCallOverrideId }, + ClickedConfirmDeleteOverride: {}, + GotDeleteOverrideDialogMessage: { message: Dialog.Message }, + CompletedDeleteOverride: { overrideId: OnCallOverrideId, result: outcome(Schema.Void) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts new file mode 100644 index 0000000000..545d5434ef --- /dev/null +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -0,0 +1,55 @@ +import * as Policy from "@maple/domain/policy" +import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" +import { V2OnCallOverride, V2OnCallSchedule, V2OnCallShifts } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import { OrgMember } from "../shared/org-members" + +// `/alerts/schedules/$scheduleId`: who is on call, the calendar of shifts, and the overrides. + +/** The override form as typed: who, and from when to when on the schedule zone's wall clock. */ +export const OverrideDraft = Schema.Struct({ + userId: Schema.String, + startsAt: Schema.String, + endsAt: Schema.String, + /** The exact instants the dialog opened on, sent back until their text is edited. */ + startMs: Schema.NullOr(Schema.Number), + endMs: Schema.NullOr(Schema.Number), +}) +export type OverrideDraft = typeof OverrideDraft.Type + +export const Model = Schema.Struct({ + rawScheduleId: Schema.String, + scheduleId: Schema.NullOr(OnCallScheduleId), + /** The clock, read on entry and by the hub cadence's tick. */ + nowMs: Schema.NullOr(Schema.Number), + schedule: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** When the clock last asked for the schedule again; a failing read waits out the max age. */ + scheduleAskedAtMs: Schema.NullOr(Schema.Number), + /** Weeks from this one the calendar shows: 0 is the week starting today. */ + weekOffset: Schema.Number, + shifts: KeyedSchema(V2OnCallShifts, LoadFailure), + overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), + /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ + members: Schema.NullOr(Schema.Array(OrgMember)), + /** The members read failed: the override form says so instead of offering nobody. */ + isMembersFailed: Schema.Boolean, + grants: Schema.Array(Policy.PermissionName), + overrideDialog: Dialog.Model, + overrideDraft: OverrideDraft, + isShowingOverrideErrors: Schema.Boolean, + isCreatingOverride: Schema.Boolean, + overrideError: Schema.NullOr(Schema.String), + /** Bumped on every open: a create's result only settles the dialog it was sent from. */ + overrideOpenCount: Schema.Number, + deletingOverrideId: Schema.NullOr(OnCallOverrideId), + /** The override awaiting delete confirmation. */ + confirmDeleteOverrideId: Schema.NullOr(OnCallOverrideId), + deleteOverrideDialog: Dialog.Model, +}) +export type Model = typeof Model.Type + +/** The viewer may change schedules and overrides (`alerts:manage`). */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts new file mode 100644 index 0000000000..4866bbee22 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -0,0 +1,459 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallOverrideId } from "@maple/domain/http" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Option, Result, Schema, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import { isoMs, msIso } from "../shared/time" +import { memberName } from "../shared/org-members" +import { formatShiftEdge } from "../shared/oncall-schedules" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { + checkoutOverrides, + checkoutSchedule, + checkoutShifts, + DST_NOW, + memberId, + ONCALL_NOW, + oncallMembers, + overrideUuid, + schedulePublicId, +} from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, overrideIssue, rangeOf, readRangeOf, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const BERLIN = "Europe/Berlin" + +const loaded = (nowMs: number, grants = ENGINEER) => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + expect(names(entered.commands)).toEqual( + expect.arrayContaining(["FetchOnCallSchedule", "FetchOnCallOverrides", "FetchScheduleMembers"]), + ) + const base = run(entered.model, [ + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.ReadClock({ nowMs }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + readAtMs: nowMs, + result: Result.succeed(checkoutSchedule(nowMs)), + }), + Message.CompletedFetchOverrides({ + key: entered.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(nowMs)), + }), + ]) + return withShifts(base, nowMs) +} + +/** The shifts the page asked for, over the range it asked for (wider than the week drawn). */ +const withShifts = (model: Model, nowMs: number) => + Option.match(readRangeOf(model), { + onNone: () => model, + onSome: ({ fromMs, toMs }) => + run(model, [ + Message.CompletedFetchShifts({ + key: model.shifts.key ?? "", + result: Result.succeed(checkoutShifts(nowMs, fromMs, toMs)), + }), + ]), + }) + +const commandArgs = ( + commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined, + name: string, +) => + Arr.findFirst(commands ?? [], (command) => command.name === name).pipe( + Option.map((command) => command.args), + ) + +describe("on-call schedule page", () => { + test("the week reads each layer and the final row in the schedule's zone, noting the viewer's", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW)), + expectView(text("Checkout primary")).toExist(), + expectView( + text( + "Times in Europe/Berlin. Yours is UTC. Pick a shift in the final row to cover it with an override.", + ), + ).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + expectView(role("rowheader", { name: /Weeknights/u })).toExist(), + expectView(role("button", { name: /^Override: Ana Souza, Wed,? 11 Nov/u })).toExist(), + expectView(text("Upcoming overrides")).toExist(), + expectView(role("button", { name: /^Delete Dana Kim's override, /u })).toExist(), + ) + }) + + test("the DST week runs Thursday to Wednesday on the Berlin clock, through the fall-back Sunday", () => { + const model = loaded(DST_NOW) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Sun 25")).toExist(), + expectView(text("22 Oct to 28 Oct")).toExist(), + ) + }) + + test("moving a week reads that week's shifts and shows a skeleton, not last week's bars, until they land", () => { + const model = loaded(ONCALL_NOW) + const next = pageUpdate(model, Message.ClickedNextWeek()) + expect(names(next.commands)).toEqual(["FetchOnCallShifts"]) + expect(next.model.weekOffset).toBe(1) + scene( + { update: pageUpdate, view }, + given(next.model), + expectView(text("17 Nov to 23 Nov")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(next.model, ONCALL_NOW)), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + ) + expect(pageUpdate(next.model, Message.ClickedThisWeek()).model.weekOffset).toBe(0) + }) + + test("an override over a shift crossing the week's edge covers the whole shift, not the drawn part", () => { + const model = loaded(ONCALL_NOW) + const weekEnd = Option.getOrElse( + Option.map(rangeOf(model), (range) => range.toMs), + () => 0, + ) + const shifts = Option.getOrElse(AsyncData.getData(model.shifts.data), () => checkoutShifts(0, 0, 1)) + const crossing = Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) < weekEnd && isoMs(shift.ends_at) > weekEnd, + ) + expect(Option.isSome(crossing)).toBe(true) + const shift = Option.getOrElse(crossing, () => shifts.final[0]) + const created = pageUpdate( + run(model, [ + Message.ClickedShift({ + startMs: isoMs(shift?.starts_at ?? ""), + endMs: isoMs(shift?.ends_at ?? ""), + }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]), + Message.ClickedCreateOverride(), + ) + expect(commandArgs(created.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: { user_id: memberId(7), starts_at: shift?.starts_at, ends_at: shift?.ends_at }, + }), + ), + ) + }) + + test("an override opened on an instant with seconds sends that instant back while its text is untouched", () => { + const startMs = Date.UTC(2026, 10, 12, 8, 0, 30) + const opened = run(loaded(ONCALL_NOW), [ + Message.ClickedShift({ startMs, endMs: startMs + 3_600_000 }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + const created = pageUpdate(opened, Message.ClickedCreateOverride()) + expect(commandArgs(created.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: expect.objectContaining({ starts_at: "2026-11-12T08:00:30.000Z" }), + }), + ), + ) + const edited = pageUpdate( + run(opened, [Message.ChangedOverrideStart({ value: "2026-11-12T10:00" })]), + Message.ClickedCreateOverride(), + ) + expect(commandArgs(edited.commands, "CreateOnCallOverride")).toEqual( + Option.some( + expect.objectContaining({ + payload: expect.objectContaining({ starts_at: "2026-11-12T09:00:00.000Z" }), + }), + ), + ) + }) + + test("the header's on-call is read again after its handoff passes, and after an override is written", () => { + const model = loaded(ONCALL_NOW) + const handoff = Option.getOrElse( + Option.flatMap(AsyncData.getData(model.schedule.data), (schedule) => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)), + ), + () => 0, + ) + expect( + names(pageUpdate(model, Message.ReadClock({ nowMs: handoff - 60_000 })).commands), + ).not.toContain("FetchOnCallSchedule") + expect(names(pageUpdate(model, Message.ReadClock({ nowMs: handoff })).commands)).toContain( + "FetchOnCallSchedule", + ) + const created = pageUpdate( + run(model, [Message.ClickedNewOverride(), Message.ChangedOverrideUser({ value: memberId(7) })]), + Message.ClickedCreateOverride(), + ) + const done = pageUpdate( + created.model, + Message.CompletedCreateOverride({ + openCount: created.model.overrideOpenCount, + result: Result.succeed(Option.getOrThrow(Arr.head(checkoutOverrides(ONCALL_NOW)))), + }), + ) + expect(names(done.commands)).toContain("FetchOnCallSchedule") + }) + + test("a failed header re-read keeps the last schedule and tries again on the five-minute clock", () => { + const model = loaded(ONCALL_NOW) + const handoff = Option.getOrElse( + Option.flatMap(AsyncData.getData(model.schedule.data), (schedule) => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)), + ), + () => 0, + ) + const asked = pageUpdate(model, Message.ReadClock({ nowMs: handoff })) + expect(names(asked.commands)).toContain("FetchOnCallSchedule") + const failed = pageUpdate( + asked.model, + Message.CompletedFetchSchedule({ + key: asked.model.schedule.key ?? "", + readAtMs: handoff, + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: true, + recovery: "retry", + }), + }), + ).model + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: handoff + 30_000 })).commands), + ).not.toContain("FetchOnCallSchedule") + expect( + names(pageUpdate(failed, Message.ReadClock({ nowMs: handoff + 6 * 60_000 })).commands), + ).toContain("FetchOnCallSchedule") + }) + + test("a failed shifts read waits for Try again instead of retrying every clock tick", () => { + const model = loaded(ONCALL_NOW) + const failed = run(model, [Message.ClickedNextWeek()]) + const settled = run(failed, [ + Message.CompletedFetchShifts({ + key: failed.shifts.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the schedule", + retryable: true, + recovery: "retry", + }), + }), + ]) + expect( + names(pageUpdate(settled, Message.ReadClock({ nowMs: ONCALL_NOW + 30_000 })).commands), + ).not.toContain("FetchOnCallShifts") + expect(names(pageUpdate(settled, Message.ClickedRetryShifts()).commands)).toContain( + "FetchOnCallShifts", + ) + }) + + test("a final shift opens an override for its range; creating one sends the times in UTC", () => { + const fromMs = zonedDayStart(ONCALL_NOW, BERLIN, 2) + const opened = run(loaded(ONCALL_NOW), [ + Message.ClickedShift({ startMs: fromMs, endMs: fromMs + 8 * 3_600_000 }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + expect(opened.overrideDraft.startsAt).toBe("2026-11-12T00:00") + const created = pageUpdate(opened, Message.ClickedCreateOverride()) + expect(names(created.commands)).toEqual(["CreateOnCallOverride"]) + }) + + test("an override has to end after it starts and in the future", () => { + const draft = { + userId: memberId(1), + startsAt: "2026-11-12T10:00", + endsAt: "2026-11-12T09:00", + startMs: null, + endMs: null, + } + expect(overrideIssue(draft, BERLIN, ONCALL_NOW)).toEqual( + Option.some("End the override after it starts"), + ) + expect( + overrideIssue( + { ...draft, startsAt: "2026-11-01T08:00", endsAt: "2026-11-02T08:00" }, + BERLIN, + ONCALL_NOW, + ), + ).toEqual(Option.some("End the override in the future")) + expect(overrideIssue({ ...draft, userId: "" }, BERLIN, ONCALL_NOW)).toEqual( + Option.some("Pick who covers the shift"), + ) + expect(overrideIssue({ ...draft, endsAt: "2026-11-12T18:00" }, BERLIN, ONCALL_NOW)).toEqual( + Option.none(), + ) + }) + + test("a failed overrides read offers Try again, which reads them again", () => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + const failed = run(entered.model, [ + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed(checkoutSchedule(ONCALL_NOW)), + }), + Message.CompletedFetchOverrides({ + key: entered.model.overrides.key ?? "", + result: Result.fail({ + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read", + retryable: false, + recovery: "retry", + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(withShifts(failed, ONCALL_NOW)), + expectView(text("Couldn't load the overrides")).toExist(), + expectView(role("button", { name: /Try again/u })).toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetryOverrides()).commands)).toContain( + "FetchOnCallOverrides", + ) + }) + + test("each override's delete button names the window it removes", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW)), + expectView(role("button", { name: /^Delete .+'s override, .+/u })).toExist(), + ) + }) + + test("deleting an override asks first, sends one delete, and reads the list again even when it fails", () => { + const overrideId = Schema.decodeSync(OnCallOverrideId)(overrideUuid(1)) + const asked = pageUpdate(loaded(ONCALL_NOW), Message.ClickedDeleteOverride({ overrideId })) + expect(names(asked.commands)).not.toContain("DeleteOnCallOverride") + expect(asked.model.confirmDeleteOverrideId).toBe(overrideId) + const confirmed = pageUpdate(asked.model, Message.ClickedConfirmDeleteOverride()) + expect(names(confirmed.commands)).toContain("DeleteOnCallOverride") + expect( + names(pageUpdate(confirmed.model, Message.ClickedConfirmDeleteOverride()).commands), + ).not.toContain("DeleteOnCallOverride") + const gone = pageUpdate( + confirmed.model, + Message.CompletedDeleteOverride({ + overrideId, + result: Result.fail({ + _tag: "@maple/http/errors/OnCallOverrideNotFoundError", + type: "not_found_error", + code: "oncall_override_not_found", + title: "Override not found", + message: "The override no longer exists.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(names(gone.commands)).toContain("FetchOnCallOverrides") + expect(names(gone.commands)).toContain("FetchOnCallShifts") + }) + + test("a member reads the calendar but cannot add or delete an override", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ONCALL_NOW, MEMBER)), + expectView(role("button", { name: "Add override" })).not.toExist(), + expectView(role("button", { name: /^Delete Dana Kim's override, /u })).not.toExist(), + expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), + ) + }) + + test("an ended shift is a read-only bar; a running one opens an override from now to its real end", () => { + const model = loaded(ONCALL_NOW) + const shifts = Option.getOrThrow(AsyncData.getData(model.shifts.data)) + const weekStart = Option.getOrElse( + Option.map(rangeOf(model), (range) => range.fromMs), + () => 0, + ) + const ended = Option.getOrThrow( + Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) >= weekStart && isoMs(shift.ends_at) <= ONCALL_NOW, + ), + ) + const running = Option.getOrThrow( + Arr.findFirst( + shifts.final, + (shift) => isoMs(shift.starts_at) <= ONCALL_NOW && ONCALL_NOW < isoMs(shift.ends_at), + ), + ) + const endedName = `${memberName(oncallMembers, ended.user_id)}, ${formatShiftEdge(isoMs(ended.starts_at), BERLIN)} to ${formatShiftEdge(isoMs(ended.ends_at), BERLIN)}` + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("img", { name: endedName })).toExist(), + expectView(role("button", { name: endedName })).not.toExist(), + ) + const ignored = pageUpdate( + model, + Message.ClickedShift({ startMs: isoMs(ended.starts_at), endMs: isoMs(ended.ends_at) }), + ) + expect(ignored.model.overrideOpenCount).toBe(model.overrideOpenCount) + const opened = run(model, [ + Message.ClickedShift({ startMs: isoMs(running.starts_at), endMs: isoMs(running.ends_at) }), + Message.ChangedOverrideUser({ value: memberId(7) }), + ]) + expect(overrideIssue(opened.overrideDraft, BERLIN, ONCALL_NOW)).toEqual(Option.none()) + expect( + commandArgs(pageUpdate(opened, Message.ClickedCreateOverride()).commands, "CreateOnCallOverride"), + ).toEqual( + Option.some( + expect.objectContaining({ + payload: { user_id: memberId(7), starts_at: msIso(ONCALL_NOW), ends_at: running.ends_at }, + }), + ), + ) + }) + test("the override form says the members did not load, rather than asking to pick nobody", () => { + const model = run(loaded(ONCALL_NOW), [ + Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), + Message.ClickedNewOverride(), + ]) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Couldn't load members, try reloading the page")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts new file mode 100644 index 0000000000..47b0e8f77d --- /dev/null +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -0,0 +1,427 @@ +import { MAX_ONCALL_OVERRIDE_MS, UserId } from "@maple/domain/http" +import type { V2OnCallOverrideCreateParams, V2OnCallSchedule } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { scheduleIdFromUrl } from "../shared/oncall-schedules" +import { localInputMs, localInputOf } from "../schedule-form/form" +import { + CreateOverride, + DeleteOverride, + FetchOverrides, + FetchSchedule, + FetchScheduleMembers, + FetchScheduleSession, + FetchShifts, + ReadScheduleClock, +} from "./command" +import { Message } from "./message" +import { canManage, type Model, type OverrideDraft } from "./model" +import { isoMs, msIso } from "../shared/time" + +type Return = PageReturn +type ScheduleRoute = Extract + +export const OVERRIDE_DIALOG_ID = "schedule-override" +export const DELETE_OVERRIDE_DIALOG_ID = "schedule-delete-override" + +/** The calendar shows one week of the schedule zone's days, from today. */ +export const CALENDAR_DAYS = 7 + +export const scheduleOf = (model: Model): Option.Option => + AsyncData.getData(model.schedule.data) + +/** The calendar's range: whole days on the schedule's own clock. */ +export const rangeOf = (model: Model): Option.Option> => + Option.flatMap(scheduleOf(model), (schedule) => + model.nowMs === null + ? Option.none() + : Option.some({ + fromMs: zonedDayStart(model.nowMs, schedule.time_zone, model.weekOffset * CALENDAR_DAYS), + toMs: zonedDayStart( + model.nowMs, + schedule.time_zone, + (model.weekOffset + 1) * CALENDAR_DAYS, + ), + }), + ) + +/** + * The shifts are read this far past either edge: the API clips shifts to the range it is asked, + * so a shift crossing the week's edge keeps its real bounds (a custom layer's longest is 14 days). + */ +export const SHIFT_READ_MARGIN_MS = 14 * 86_400_000 + +/** The range the shifts are read over: the calendar's, widened by the margin. */ +export const readRangeOf = (model: Model): Option.Option> => + Option.map(rangeOf(model), ({ fromMs, toMs }) => ({ + fromMs: fromMs - SHIFT_READ_MARGIN_MS, + toMs: toMs + SHIFT_READ_MARGIN_MS, + })) + +export const init = (route: ScheduleRoute, context: PageContext): Return => { + const scheduleId = Option.getOrNull(scheduleIdFromUrl(route.scheduleId)) + const model: Model = { + rawScheduleId: route.scheduleId, + scheduleId, + nowMs: null, + schedule: Keyed.idle(), + scheduleAskedAtMs: null, + weekOffset: 0, + shifts: Keyed.idle(), + overrides: Keyed.idle(), + members: null, + isMembersFailed: false, + grants: [], + overrideDialog: Dialog.init(OVERRIDE_DIALOG_ID, "dialog"), + overrideDraft: { userId: "", startsAt: "", endsAt: "", startMs: null, endMs: null }, + isShowingOverrideErrors: false, + isCreatingOverride: false, + overrideError: null, + overrideOpenCount: 0, + deletingOverrideId: null, + confirmDeleteOverrideId: null, + deleteOverrideDialog: Dialog.init(DELETE_OVERRIDE_DIALOG_ID, "alert-dialog"), + } + const common = [ + ReadScheduleClock(), + FetchScheduleMembers(), + FetchScheduleSession({ orgId: context.orgId }), + ] + if (scheduleId === null) return { model, commands: common } + const schedule = Keyed.supersede( + model.schedule, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), + ) + const overrides = Keyed.supersede( + model.overrides, + requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }), + ) + return { + model: { ...model, schedule, overrides }, + commands: [ + ...common, + FetchSchedule({ key: schedule.key, scheduleId }), + FetchOverrides({ key: overrides.key, scheduleId }), + ], + } +} + +/** + * The shifts of the range on screen; a new range or a write reads them again. A tick's `Load` + * leaves a failure on screen: only Try again asks again. + */ +const requestShifts = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { + const scheduleId = model.scheduleId + return Option.match(readRangeOf(model), { + onNone: () => ({ model }), + onSome: ({ fromMs, toMs }) => { + if (scheduleId === null) return { model } + const key = requestKey(context.orgId, "oncallSchedules.shifts", { id: scheduleId, fromMs, toMs }) + const requested = + mode === "Supersede" + ? Keyed.requestFor(model.shifts, key, mode) + : Keyed.requestKeepingFailure(model.shifts, key, mode) + return Option.match(requested, { + onNone: () => ({ model }), + onSome: (shifts) => ({ + model: { ...model, shifts }, + commands: [FetchShifts({ key: shifts.key, scheduleId, fromMs, toMs })], + }), + }) + }, + }) +} + +/** The schedule read again: its header's "on call now" moves with a handoff or an override. */ +const refreshSchedule = (model: Model, context: PageContext): Return => { + const scheduleId = model.scheduleId + if (scheduleId === null) return { model } + const schedule = Keyed.supersede( + model.schedule, + requestKey(context.orgId, "oncallSchedules.retrieve", { id: scheduleId }), + ) + return { model: { ...model, schedule }, commands: [FetchSchedule({ key: schedule.key, scheduleId })] } +} + +/** An override write: the overrides, the shifts and the schedule's "on call now" are read again. */ +const refreshOverrides = (model: Model, context: PageContext): Return => { + const scheduleId = model.scheduleId + if (scheduleId === null) return { model } + const key = requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }) + const overrides = Keyed.supersede(model.overrides, key) + const shifts = requestShifts({ ...model, overrides }, context, "Supersede") + const schedule = refreshSchedule(shifts.model, context) + return { + model: schedule.model, + commands: [ + FetchOverrides({ key: overrides.key, scheduleId }), + ...(shifts.commands ?? []), + ...(schedule.commands ?? []), + ], + } +} + +/** With nobody on call there is no handoff to wait for, so the header is read again every 5 minutes. */ +const SCHEDULE_MAX_AGE_MS = 5 * 60_000 + +/** The header's shift has handed off, or a gap has gone unchecked too long. */ +export const isScheduleStale = (model: Model, nowMs: number): boolean => + // A failed re-read keeps the last schedule (`Stale`), which still ages and hands off. + Option.isSome(AsyncData.getData(model.schedule.data)) && + !isScheduleRetryWaiting(model, nowMs) && + Option.exists(scheduleOf(model), (schedule) => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onSome: (now) => isoMs(now.ends_at) <= nowMs, + onNone: () => nowMs - (model.schedule.updatedAtMs ?? nowMs) >= SCHEDULE_MAX_AGE_MS, + }), + ) + +/** A re-read asked since the last answer (failing, or in flight) waits the max age, not every tick. */ +const isScheduleRetryWaiting = (model: Model, nowMs: number): boolean => { + const askedAt = model.scheduleAskedAtMs + const readAt = model.schedule.updatedAtMs + return ( + askedAt !== null && readAt !== undefined && askedAt > readAt && nowMs - askedAt < SCHEDULE_MAX_AGE_MS + ) +} + +const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { + const shifts = requestShifts({ ...model, nowMs }, context, "Load") + if (!isScheduleStale(shifts.model, nowMs)) return shifts + const schedule = refreshSchedule({ ...shifts.model, scheduleAskedAtMs: nowMs }, context) + return { model: schedule.model, commands: [...(shifts.commands ?? []), ...(schedule.commands ?? [])] } +} + +const decodeUserId = Schema.decodeUnknownOption(UserId) + +/** What is wrong with the override form, or none. */ +export const overrideIssue = ( + draft: OverrideDraft, + timeZone: string, + nowMs: number, +): Option.Option => { + const start = draft.startMs === null ? localInputMs(draft.startsAt, timeZone) : Option.some(draft.startMs) + const end = draft.endMs === null ? localInputMs(draft.endsAt, timeZone) : Option.some(draft.endMs) + if (Option.isNone(decodeUserId(draft.userId))) return Option.some("Pick who covers the shift") + if (Option.isNone(start) || Option.isNone(end)) + return Option.some("Pick when the override starts and ends") + if (end.value <= start.value) return Option.some("End the override after it starts") + if (end.value <= nowMs) return Option.some("End the override in the future") + if (end.value - start.value > MAX_ONCALL_OVERRIDE_MS) return Option.some("Keep an override to 90 days") + return Option.none() +} + +const payloadOf = ( + draft: OverrideDraft, + timeZone: string, + nowMs: number, +): Option.Option => + Option.isSome(overrideIssue(draft, timeZone, nowMs)) + ? Option.none() + : Option.flatMap(decodeUserId(draft.userId), (user_id) => + Option.flatMap( + Option.all([ + draft.startMs === null + ? localInputMs(draft.startsAt, timeZone) + : Option.some(draft.startMs), + draft.endMs === null + ? localInputMs(draft.endsAt, timeZone) + : Option.some(draft.endMs), + ]), + ([start, end]) => + Option.some({ + user_id, + starts_at: msIso(start), + ends_at: msIso(end), + }), + ), + ) + +const liftDialog = (model: Model, step: ReturnType): Return => ({ + model: { ...model, overrideDialog: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotOverrideDialogMessage({ message }), + ), +}) + +/** Opens the override dialog over a range: a clicked shift's, or the next hour's. */ +const openOverride = (model: Model, startMs: number, endMs: number): Return => + Option.match(scheduleOf(model), { + onNone: () => ({ model }), + onSome: (schedule) => { + // An override cannot lie wholly in the past: one over a running shift starts now. + const fromMs = Math.max(startMs, model.nowMs ?? startMs) + return liftDialog( + { + ...model, + overrideDraft: { + userId: "", + startsAt: localInputOf(fromMs, schedule.time_zone), + endsAt: localInputOf(endMs, schedule.time_zone), + startMs: fromMs, + endMs, + }, + isShowingOverrideErrors: false, + overrideError: null, + overrideOpenCount: model.overrideOpenCount + 1, + }, + Dialog.open(model.overrideDialog), + ) + }, + }) + +const HOUR_MS = 3_600_000 + +const createOverride = (model: Model): Return => { + const scheduleId = model.scheduleId + return Option.match(scheduleOf(model), { + onNone: () => ({ model }), + onSome: (schedule) => + model.isCreatingOverride || scheduleId === null + ? { model } + : Option.match(payloadOf(model.overrideDraft, schedule.time_zone, model.nowMs ?? 0), { + onNone: () => ({ model: { ...model, isShowingOverrideErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isCreatingOverride: true, overrideError: null }, + commands: [ + CreateOverride({ openCount: model.overrideOpenCount, scheduleId, payload }), + ], + }), + }), + }) +} + +/** Runs the delete confirmation's update; `Closed` forgets the target. */ +const updateDeleteDialog = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteOverrideDialog: step.model, + confirmDeleteOverrideId: step.outMessage?._tag === "Closed" ? null : model.confirmDeleteOverrideId, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteOverrideDialogMessage({ message }), + ), +}) + +const toast = (type: "success" | "error", title: string, description = "") => + PageOutMessage.RequestedToast({ type, title, description }) + +const moveWeek = (model: Model, context: PageContext, weekOffset: number): Return => + requestShifts({ ...model, weekOffset }, context, "Load") + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => clockRead(model, context, nowMs), + CompletedFetchSchedule: ({ key, readAtMs, result }) => + requestShifts( + { ...model, schedule: Keyed.accept(model.schedule, key, result, readAtMs) }, + context, + "Load", + ), + ClickedRetryShifts: () => requestShifts(model, context, "Supersede"), + ClickedRetryOverrides: () => refreshOverrides(model, context), + CompletedFetchShifts: ({ key, result }) => ({ + model: { ...model, shifts: Keyed.accept(model.shifts, key, result) }, + }), + CompletedFetchOverrides: ({ key, result }) => ({ + model: { ...model, overrides: Keyed.accept(model.overrides, key, result) }, + }), + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), + // A failed read keeps names neutral ("Member") rather than calling everyone a former member. + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants } } : { model }, + ClickedRetry: () => refreshSchedule(model, context), + ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), + ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), + ClickedThisWeek: () => moveWeek(model, context, 0), + // An ended shift has nothing left to cover; a running one is covered from now to its end. + ClickedShift: ({ startMs, endMs }) => + canManage(model) && endMs > (model.nowMs ?? 0) ? openOverride(model, startMs, endMs) : { model }, + ClickedNewOverride: () => { + const now = model.nowMs ?? 0 + const start = Math.ceil(now / HOUR_MS) * HOUR_MS + return openOverride(model, start, start + 8 * HOUR_MS) + }, + GotOverrideDialogMessage: ({ message: child }) => + model.isCreatingOverride + ? { model } + : liftDialog(model, Dialog.update(model.overrideDialog, child)), + ChangedOverrideUser: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, userId: value } }, + }), + ChangedOverrideStart: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, startsAt: value, startMs: null } }, + }), + ChangedOverrideEnd: ({ value }) => ({ + model: { ...model, overrideDraft: { ...model.overrideDraft, endsAt: value, endMs: null } }, + }), + ClickedCreateOverride: () => createOverride(model), + CompletedCreateOverride: ({ openCount, result }) => + openCount !== model.overrideOpenCount + ? { model } + : Result.match(result, { + onSuccess: (): Return => { + const closed = liftDialog( + { ...model, isCreatingOverride: false }, + Dialog.close(model.overrideDialog), + ) + const refreshed = refreshOverrides(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Override created"), + } + }, + onFailure: (failure): Return => ({ + model: { ...model, isCreatingOverride: false, overrideError: failure.message }, + }), + }), + ClickedDeleteOverride: ({ overrideId }) => + model.deletingOverrideId !== null || !canManage(model) + ? { model } + : updateDeleteDialog( + { ...model, confirmDeleteOverrideId: overrideId }, + Dialog.open(model.deleteOverrideDialog), + ), + ClickedConfirmDeleteOverride: () => { + const overrideId = model.confirmDeleteOverrideId + return overrideId === null || model.deletingOverrideId !== null || model.scheduleId === null + ? { model } + : { + model: { ...model, deletingOverrideId: overrideId }, + commands: [DeleteOverride({ scheduleId: model.scheduleId, overrideId })], + } + }, + GotDeleteOverrideDialogMessage: ({ message: child }) => + // The dialog ignores dismissal while the delete is in flight. + model.deletingOverrideId !== null + ? { model } + : updateDeleteDialog(model, Dialog.update(model.deleteOverrideDialog, child)), + CompletedDeleteOverride: ({ result }) => { + const settled = { ...model, deletingOverrideId: null } + const closed = updateDeleteDialog(settled, Dialog.close(settled.deleteOverrideDialog)) + // Either way the list is read again: a 404 means someone else already removed it. + const refreshed = refreshOverrides(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: Result.match(result, { + onSuccess: () => toast("success", "Override deleted"), + onFailure: (failure) => toast("error", "Couldn't delete the override", failure.message), + }), + } + }, + }) + +export const overridesOf = (model: Model) => + Option.getOrElse(AsyncData.getData(model.overrides.data), () => []) diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts new file mode 100644 index 0000000000..65e21a14f8 --- /dev/null +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -0,0 +1,478 @@ +import type { V2OnCallOverride, V2OnCallSchedule, V2OnCallShift, V2OnCallShifts } from "@maple/domain/http/v2" +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText } from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { personLabel } from "@maple/views/ui/person-avatar" +import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" +import { resourceNotFound } from "../../kit/resource-not-found" +import { sectionHeading } from "../../kit/section-heading" +import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/oncall-schedules" +import { memberName, memberOf, type OrgMember } from "../shared/org-members" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { + DELETE_OVERRIDE_DIALOG_ID, + OVERRIDE_DIALOG_ID, + overrideIssue, + overridesOf, + rangeOf, + readRangeOf, + scheduleOf, +} from "./update" +import { isoMs } from "../shared/time" + +// `/alerts/schedules/$scheduleId`: who is on call now, the week's calendar (each layer and the +// final row with its overrides), the upcoming overrides, and the dialog that adds one. + +type H = HtmlBuilder + +const imageOf = (members: ReadonlyArray | null, userId: string) => + Option.getOrUndefined(Option.map(memberOf(members, userId), (member) => member.imageUrl)) + +const rangeText = (startMs: number, endMs: number, timeZone: string) => + `${formatShiftEdge(startMs, timeZone)} to ${formatShiftEdge(endMs, timeZone)}` + +const onCallLine = (model: Model, schedule: V2OnCallSchedule): string => + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => "Nobody is on call right now", + onSome: (now) => + `${memberName(model.members, now.user_id)} is on call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + }) + +/** A shift as a bar: the person's name, and who and when in its tooltip. */ +const barOf = ( + model: Model, + schedule: V2OnCallSchedule, + shift: V2OnCallShift, + key: string, + isFinal: boolean, +): TimelineBar => { + const startMs = isoMs(shift.starts_at) + const endMs = isoMs(shift.ends_at) + const name = memberName(model.members, shift.user_id) + const isOverride = shift.source.kind === "override" + const bar: TimelineBar = { + key, + startMs, + endMs, + label: name, + title: `${isOverride ? "Override: " : ""}${name}, ${rangeText(startMs, endMs, schedule.time_zone)}`, + tone: isOverride ? "info" : isFinal ? "primary" : "neutral", + } + // Only a final shift that has not ended opens an override, and only for someone who may add one. + return isFinal && canManage(model) && endMs > (model.nowMs ?? 0) + ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } + : bar +} + +/** The final row on top, then the layers from the top one down, as they stack. */ +const timelineRows = ( + model: Model, + schedule: V2OnCallSchedule, + shifts: V2OnCallShifts, +): ReadonlyArray> => [ + { + key: "final", + label: "Final schedule", + detail: "Who gets paged", + bars: Arr.map(shifts.final, (shift, index) => barOf(model, schedule, shift, `final-${index}`, true)), + emptyText: "Nobody on call in this range", + }, + ...Arr.reverse( + Arr.map(schedule.layers, (layer, index): TimelineRow => ({ + key: `layer-${index}`, + label: layer.name, + detail: `Layer ${index + 1}`, + bars: Arr.map(shifts.layers[index] ?? [], (shift, at) => + barOf(model, schedule, shift, `layer-${index}-${at}`, false), + ), + emptyText: "Not active in this range", + })), + ), +] + +/** "10 Nov to 16 Nov": the first and last day the calendar shows. */ +const weekLabel = (fromMs: number, toMs: number, timeZone: string): string => { + const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) + return `${day.format(fromMs)} to ${day.format(toMs - 1)}` +} + +/** The calendar's header: the week, prev/this week/next, and which zone the times read in. */ +const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { + const range = rangeOf(model) + const nowMs = model.nowMs ?? 0 + // Shifts kept from another week (a week's read still in flight) draw as the skeleton, not blank rows. + const timeline = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + !Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) + ? skeleton(h, "h-40 w-full") + : shiftTimeline(h, { + fromMs, + toMs, + timeZone: schedule.time_zone, + nowMs, + rows: timelineRows(model, schedule, shifts), + ariaLabel: `Shifts for ${schedule.name}`, + nowLabel: `Now, ${formatShiftEdge(nowMs, schedule.time_zone)}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + ], + }) + const body = Option.match(range, { + onNone: () => skeleton(h, "h-40 w-full"), + onSome: ({ fromMs, toMs }) => + AsyncData.match(model.shifts.data, { + onIdle: () => skeleton(h, "h-40 w-full"), + onLoading: () => skeleton(h, "h-40 w-full"), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load the shifts", + onRetry: Message.ClickedRetryShifts(), + }), + onRefreshing: (shifts) => timeline(fromMs, toMs, shifts), + onStale: ({ data }) => timeline(fromMs, toMs, data), + onSuccess: (shifts) => timeline(fromMs, toMs, shifts), + }), + }) + const zoneNote = + viewerZone === schedule.time_zone + ? `Times in ${schedule.time_zone}.` + : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` + return h.section( + [h.Class("space-y-3")], + [ + sectionHeading(h, { + title: Option.match(range, { + onNone: () => "This week", + onSome: ({ fromMs, toMs }) => weekLabel(fromMs, toMs, schedule.time_zone), + }), + actions: hstack(h, { gap: "1" }, [ + iconButton( + h, + { label: "Previous week", attributes: [h.OnClick(Message.ClickedPreviousWeek())] }, + [icon(h, "ChevronLeftIcon")], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: model.weekOffset === 0, + attributes: [h.OnClick(Message.ClickedThisWeek())], + }, + ["Today"], + ), + iconButton( + h, + { label: "Next week", attributes: [h.OnClick(Message.ClickedNextWeek())] }, + [icon(h, "ChevronRightIcon")], + ), + ]), + }), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + zoneNote, + ...(canManage(model) ? [" Pick a shift in the final row to cover it with an override."] : []), + ]), + body, + ], + ) +} + +const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V2OnCallOverride): Html => { + const name = memberName(model.members, override.user_id) + return h.li( + [h.Key(override.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", justify: "between" }, [ + personLabel(h, { + name, + imageUrl: imageOf(model.members, override.user_id), + detail: rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone), + }), + ...(canManage(model) + ? [ + iconButton( + h, + { + label: `Delete ${name}'s override, ${rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone)}`, + isDisabled: model.deletingOverrideId === override.id, + className: "text-muted-foreground", + attributes: [ + h.OnClick(Message.ClickedDeleteOverride({ overrideId: override.id })), + ], + }, + [icon(h, "TrashIcon")], + ), + ] + : []), + ]), + ], + ) +} + +const overridesSection = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const overrides = overridesOf(model) + return h.section( + [h.Class("space-y-3")], + [ + sectionHeading(h, { title: "Upcoming overrides" }), + AsyncData.isFailure(model.overrides.data) + ? errorState(h, { + failure: model.overrides.data.error, + title: "Couldn't load the overrides", + onRetry: Message.ClickedRetryOverrides(), + }) + : overrides.length === 0 + ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ + AsyncData.isLoading(model.overrides.data) + ? "Loading overrides…" + : "No overrides. One puts a person on call for a time range, over every layer.", + ]) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(overrides, (override) => overrideRow(h, model, schedule, override)), + ), + ]), + ], + ) +} + +const dialogField = (h: H, id: string, text: string, control: Html): Html => + vstack(h, { gap: "1.5" }, [ + label(h, { className: typographyClass({ size: "xs", tone: "muted" }), attributes: [h.For(id)] }, [ + text, + ]), + control, + ]) + +/** "Add override": who covers, from when to when on the schedule's clock. */ +const overrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const issue = overrideIssue(model.overrideDraft, schedule.time_zone, model.nowMs ?? 0) + const id = OVERRIDE_DIALOG_ID + return formDialog(h, { + slotId: `${id}-dialog`, + model: model.overrideDialog, + toParentMessage: (message) => Message.GotOverrideDialogMessage({ message }), + title: "Add override", + description: `Put one person on call for a time range, over every layer. Times in ${schedule.time_zone}.`, + panelClassName: "space-y-4", + fields: [ + dialogField( + h, + `${id}-person`, + "Who covers", + nativeSelect( + h, + { + defaultValue: model.overrideDraft.userId, + className: "w-full", + isDisabled: model.isMembersFailed, + attributes: [ + h.Key(`${id}-person-${model.overrideOpenCount}-${(model.members ?? []).length}`), + h.Id(`${id}-person`), + h.OnChange((value) => Message.ChangedOverrideUser({ value })), + ], + }, + [ + nativeOption( + "", + model.isMembersFailed + ? "Couldn't load members, try reloading the page" + : "Pick a person", + ), + ...Arr.map(model.members ?? [], (member) => nativeOption(member.userId, member.name)), + ], + ), + ), + hstack(h, { gap: "3", wrap: true }, [ + dialogField( + h, + `${id}-start`, + "From", + input(h, { + id: `${id}-start`, + type: "datetime-local", + attributes: [ + h.Value(model.overrideDraft.startsAt), + h.OnInput((value) => Message.ChangedOverrideStart({ value })), + ], + }), + ), + dialogField( + h, + `${id}-end`, + "To", + input(h, { + id: `${id}-end`, + type: "datetime-local", + attributes: [ + h.Value(model.overrideDraft.endsAt), + h.OnInput((value) => Message.ChangedOverrideEnd({ value })), + ], + }), + ), + ]), + ...(model.isShowingOverrideErrors + ? Option.match(issue, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, text)], + }) + : []), + ...(model.overrideError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't create the override"]), + alertDescription(h, [model.overrideError]), + ]), + ]), + ], + onSubmit: Message.ClickedCreateOverride(), + submitLabel: "Add override", + isPending: model.isCreatingOverride, + }) +} + +/** "Delete {person}'s override?": the layers decide that window again. */ +const deleteOverrideDialog = (h: H, model: Model, schedule: V2OnCallSchedule): Html => { + const target = Arr.findFirst( + overridesOf(model), + (override) => override.id === model.confirmDeleteOverrideId, + ) + const name = Option.getOrElse( + Option.map(target, (override) => `${memberName(model.members, override.user_id)}'s override`), + () => "this override", + ) + return confirmDialog(h, { + slotId: DELETE_OVERRIDE_DIALOG_ID, + model: model.deleteOverrideDialog, + toParentMessage: (message) => Message.GotDeleteOverrideDialogMessage({ message }), + title: `Delete ${name}?`, + description: Option.match(target, { + onNone: () => "The layers decide who is on call again.", + onSome: (override) => + `The layers decide who is on call again from ${rangeText(isoMs(override.starts_at), isoMs(override.ends_at), schedule.time_zone)}.`, + }), + confirmLabel: "Delete override", + isPending: model.deletingOverrideId !== null, + onConfirm: Message.ClickedConfirmDeleteOverride(), + }) +} + +const page = (h: H, model: Model, viewerZone: string): Html => { + const notFound = dashboardPage(h, {}, [ + resourceNotFound(h, { + title: "Schedule not found", + description: "It may have been deleted, or the link is stale.", + back: { href: oncallTabHref(), label: "Back to on-call" }, + }), + ]) + if (model.scheduleId === null) return notFound + const data = model.schedule.data + if (AsyncData.isFailure(data)) + return data.error.code === "oncall_schedule_not_found" + ? notFound + : dashboardPage(h, {}, [ + errorState(h, { + failure: data.error, + title: "Couldn't load the schedule", + onRetry: Message.ClickedRetry(), + }), + ]) + return Option.match(scheduleOf(model), { + onNone: () => + dashboardPage(h, { titleContent: detailTitleSkeleton(h) }, [ + skeleton(h, "h-48 w-full"), + skeleton(h, "h-24 w-full"), + ]), + onSome: (schedule) => + h.div( + [h.Class("contents")], + [ + dashboardPage( + h, + { + titleContent: detailTitle(h, { + kind: ["On-call schedule", schedule.time_zone], + title: [schedule.name], + meta: [ + Option.match(Option.fromNullishOr(schedule.on_call_now), { + onNone: () => + typography(h, { size: "sm", tone: "muted" }, [ + onCallLine(model, schedule), + ]), + onSome: (now) => + personLabel(h, { + name: memberName(model.members, now.user_id), + imageUrl: imageOf(model.members, now.user_id), + detail: `On call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, + }), + }), + ], + }), + actions: canManage(model) + ? detailActions(h, [ + linkButton( + h, + { + size: "sm", + variant: "outline", + href: editScheduleHref(schedule.id), + }, + [icon(h, "PencilIcon"), "Edit schedule"], + ), + button( + h, + { + size: "sm", + attributes: [h.OnClick(Message.ClickedNewOverride())], + }, + [icon(h, "PlusIcon"), "Add override"], + ), + ]) + : undefined, + gap: "lg", + }, + [ + calendarSection(h, model, schedule, viewerZone), + overridesSection(h, model, schedule), + ], + ), + overrideDialog(h, model, schedule), + deleteOverrideDialog(h, model, schedule), + ], + ), + }) +} + +export const view = Submodel.defineView((model, inputs, h) => + page(h, model, inputs.context.timeZone), +) + +/** The H1 names the schedule, so the trail stops at the tab. */ +export const breadcrumbsOf = () => [ + { label: "Alerts", href: "/alerts" }, + { label: "On-call", href: oncallTabHref() }, +] diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts index b3e408cbf9..68462e2627 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.test.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -2,6 +2,7 @@ import { Array as Arr, Option } from "effect" import { describe, expect, it } from "vitest" import { chainSummary, formatWait, runView } from "./escalation-chains" import { chainDestinations, chainOf, runOf } from "./escalation-test-fixtures" +import { memberId, mixedTargetChain } from "./oncall-test-fixtures" import { NOW } from "./test-rows" const nameOf = (id: string) => @@ -45,6 +46,23 @@ describe("chainSummary", () => { }) }) +describe("chainSummary with people and schedules", () => { + it("names the person and whoever is on call on the schedule, and reads generic words without names", () => { + const chain = mixedTargetChain(chainDestinations[0]?.id ?? "") + expect( + chainSummary(chain, nameOf, { + userName: (id) => (id === memberId(1) ? "Dana Kim" : "Ben Brook"), + scheduleName: () => "Checkout primary", + }), + ).toBe( + "Slack #alerts-api, then whoever is on call on Checkout primary and Dana Kim after 5m, then Ben Brook after 15m; repeats once", + ) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api, then the on-call and a team member after 5m, then a team member after 15m; repeats once", + ) + }) +}) + describe("formatWait", () => { it("reads minutes, hours and a day", () => { expect([formatWait(1), formatWait(60), formatWait(75), formatWait(1440)]).toEqual([ diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 82a3d643c6..4b8cbb53d7 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -1,11 +1,17 @@ -import type { AlertDestinationId, AlertIncidentId, EscalationChainId } from "@maple/domain/http" +import type { + AlertDestinationId, + AlertIncidentId, + EscalationChainId, + OnCallScheduleId, + UserId, +} from "@maple/domain/http" import type { V2AlertIncidentEscalation, V2EscalationChain, V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect } from "effect" +import { Array as Arr, Effect, Option } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -80,6 +86,13 @@ const repeatPhrase = (count: number): string => ? "; repeats twice" : `; repeats ${count} times` +/** How a summary names the people and schedules a step pages; without them, generic words. */ +export type TargetNames = Readonly<{ + userName?: (id: UserId) => string + /** `undefined` while the name is not known yet: the summary then says "the on-call". */ + scheduleName?: (id: OnCallScheduleId) => string | undefined +}> + /** * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. @@ -87,6 +100,7 @@ const repeatPhrase = (count: number): string => export const chainSummary = ( chain: Pick, nameOf: (id: AlertDestinationId) => string, + people: TargetNames = {}, ): string => { const parts = Arr.map(chain.steps, (step, index) => { const names = namesOf( @@ -94,8 +108,11 @@ export const chainSummary = ( target.type === "destination" ? nameOf(target.destination_id) : target.type === "user" - ? "a team member" - : "the on-call", + ? (people.userName?.(target.user_id) ?? "a team member") + : Option.match(Option.fromNullishOr(people.scheduleName?.(target.schedule_id)), { + onNone: () => "the on-call", + onSome: (name) => `whoever is on call on ${name}`, + }), ), ) const previous = index === 0 ? undefined : chain.steps[index - 1] diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts new file mode 100644 index 0000000000..0d2b0dd2fd --- /dev/null +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -0,0 +1,163 @@ +import { MAX_ONCALL_FUTURE_OVERRIDES, type OnCallOverrideId, type OnCallScheduleId } from "@maple/domain/http" +import { + OnCallSchedulePublicId, + type V2OnCallOverride, + type V2OnCallOverrideCreateParams, + type V2OnCallSchedule, + type V2OnCallScheduleCreateParams, +} from "@maple/domain/http/v2" +import { Effect, Option, Schema } from "effect" +import { type LoadFailure, toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" +import type { AlertsScheduleNewSearch } from "../../../route/search" +import { isoMs, msIso } from "./time" + +// On-call schedule reads and writes as Effects the pages' Commands wrap, their links, and how a +// shift's edges read. + +/** An org holds at most 50 schedules, one v2 page. */ +export const SCHEDULE_LIST_LIMIT = 50 + +/** 409 on delete while an escalation chain still pages the schedule. */ +export const SCHEDULE_IN_USE_CODE = "oncall_schedule_in_use" + +export const listOnCallSchedules = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.oncallSchedules.list({ query: { limit: SCHEDULE_LIST_LIMIT } }) + return page.data +}).pipe(Effect.mapError(toLoadFailure)) + +export const retrieveOnCallSchedule = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Creates a schedule, or replaces `editingId`'s fields (overrides are kept). */ +export const saveOnCallSchedule = ( + editingId: OnCallScheduleId | null, + payload: V2OnCallScheduleCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return editingId === null + ? yield* client.oncallSchedules.create({ payload }) + : yield* client.oncallSchedules.update({ + params: { id: editingId }, + payload: { ...payload, description: payload.description ?? null }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteOnCallSchedule = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.oncallSchedules.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Each layer's shifts and the final ones over `[fromMs, toMs)`. */ +export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.shifts({ + params: { id }, + query: { from: msIso(fromMs), to: msIso(toMs) }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +const OVERRIDES_PAGE_SIZE = 100 + +/** The overrides that have not ended yet, by start: every page (a schedule holds up to 500). */ +export const listOverrides = (id: OnCallScheduleId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const pages = ( + cursor: string | undefined, + read: ReadonlyArray, + ): Effect.Effect, LoadFailure> => + client.oncallSchedules + .listOverrides({ + params: { id }, + query: { limit: OVERRIDES_PAGE_SIZE, ...(cursor === undefined ? undefined : { cursor }) }, + }) + .pipe( + Effect.mapError(toLoadFailure), + Effect.flatMap((page) => { + const all = [...read, ...page.data] + return page.has_more && + page.next_cursor !== null && + all.length < MAX_ONCALL_FUTURE_OVERRIDES + ? pages(page.next_cursor, all) + : Effect.succeed(all) + }), + ) + return yield* pages(undefined, []) + }) + +export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.oncallSchedules.createOverride({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteOverride = (id: OnCallScheduleId, overrideId: OnCallOverrideId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.oncallSchedules.deleteOverride({ params: { id, override_id: overrideId } }) + }).pipe(Effect.mapError(toLoadFailure)) + +// LINKS + +const encodePublicId = Schema.encodeSync(OnCallSchedulePublicId) +const decodePublicId = Schema.decodeUnknownOption(OnCallSchedulePublicId) + +/** The `onc_…` id in a URL as the internal id. */ +export const scheduleIdFromUrl = (raw: string): Option.Option => decodePublicId(raw) + +export const scheduleHref = (id: OnCallScheduleId): string => + hrefOf(AppRoute.AlertsSchedule({ scheduleId: encodePublicId(id), search: {} })) + +export const newScheduleHref = (search: typeof AlertsScheduleNewSearch.Type = {}): string => + hrefOf(AppRoute.AlertsScheduleNew({ search })) + +export const editScheduleHref = (id: OnCallScheduleId): string => + newScheduleHref({ scheduleId: encodePublicId(id) }) + +export const oncallTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "oncall" } })) + +// FORMAT + +const edgeFormatters = new Map() + +/** "Mon 16 Nov, 09:00" on the wall clock of `timeZone`. */ +export const formatShiftEdge = (ms: number, timeZone: string): string => { + const cached = edgeFormatters.get(timeZone) + const formatter = + cached ?? + new Intl.DateTimeFormat("en-GB", { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + if (cached === undefined) edgeFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + +/** "in 45m", "in 5h 10m", "in 3d 4h"; "now" once it has passed. */ +export const formatUntil = (nowMs: number, atMs: number): string => { + const minutes = Math.round((atMs - nowMs) / 60_000) + if (minutes <= 0) return "now" + if (minutes < 60) return `in ${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return minutes % 60 === 0 ? `in ${hours}h` : `in ${hours}h ${minutes % 60}m` + const days = Math.floor(hours / 24) + return hours % 24 === 0 ? `in ${days}d` : `in ${days}d ${hours % 24}h` +} + +/** When the current shift hands off, as epoch ms; none while nobody is on call. */ +export const handoffMs = (schedule: V2OnCallSchedule): Option.Option => + Option.map(Option.fromNullishOr(schedule.on_call_now), (now) => isoMs(now.ends_at)) diff --git a/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts new file mode 100644 index 0000000000..349bd45537 --- /dev/null +++ b/apps/web/src/page/alerts/shared/oncall-test-fixtures.ts @@ -0,0 +1,458 @@ +import { + encodePublicId, + V2AlertIncidentEvent, + V2EscalationChain, + V2OnCallOverride, + V2OnCallSchedule, + V2OnCallShifts, +} from "@maple/domain/http/v2" +import { UserId } from "@maple/domain/http" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Array as Arr, Option, Order, Schema, String as Str } from "effect" +import type { OrgMember } from "./org-members" +import { isoMs, msIso } from "./time" + +// On-call schedules, members, shifts and overrides for the scene tests and `/lab/oncall`. The +// shifts come from a small wall-clock generator, so a DST week reads the way the API renders it. + +const pad = (n: number) => Str.padStart(12, "0")(String(n)) +export const scheduleUuid = (n: number) => `0199a00f-0000-7000-8000-${pad(n)}` +export const overrideUuid = (n: number) => `0199a010-0000-7000-8000-${pad(n)}` +export const schedulePublicId = (n: number) => encodePublicId("onc", scheduleUuid(n)) + +/** Tue 10 Nov 2026, 10:30 UTC: the lab's and the tests' clock. */ +export const ONCALL_NOW = isoMs("2026-11-10T10:30:00.000Z") +/** Thu 22 Oct 2026, 10:00 UTC: a week whose Sunday (25 Oct) Berlin falls back an hour. */ +export const DST_NOW = isoMs("2026-10-22T10:00:00.000Z") + +const NAMES = [ + "Ben Brook", + "Dana Kim", + "Priya Raman", + "Lukas Weber", + "Sofia Alvarez", + "Mei Chen", + "Omar Haddad", + "Ana Souza", +] as const + +export const memberId = (index: number) => `user_2oncall${Str.padStart(4, "0")(String(index))}` + +export const oncallMembers: ReadonlyArray = Arr.map(NAMES, (name, index) => ({ + userId: Schema.decodeSync(UserId)(memberId(index)), + name, + email: `${Str.toLowerCase(Str.replaceAll(" ", ".")(name))}@acme.test`, + imageUrl: "", +})) + +// A tiny on-call engine over the zone's wall clock. + +type Segment = Readonly<{ userId: string; startMs: number; endMs: number }> +type Source = Readonly<{ kind: "layer"; layer_index: number } | { kind: "override"; override_id: string }> +type Shift = Segment & Readonly<{ source: Source }> + +const weekdayFormatter = (timeZone: string) => + new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }) + +/** `hour`:00 on the zone's day `dayOffset` days after the one containing `ms`. */ +const wallClock = (ms: number, timeZone: string, dayOffset: number, hour: number): number => { + const parts = zonedDateParts(ms, timeZone) + return zonedPartsToEpochMs({ ...parts, day: parts.day + dayOffset, hour, minute: 0, second: 0 }, timeZone) +} + +const DAYS = Arr.range(-8, 50) +const DAY_MS = 86_400_000 + +/** Whose turn a shift starting at `startMs` is: counted from the epoch, so any range agrees. */ +const turnOf = (startMs: number, periodMs: number) => Math.floor((startMs + 43_200_000) / periodMs) + +/** Hand-offs at `hour` on the days `isHandoffDay` keeps, people taking turns from `fromMs`'s week. */ +const rotation = ( + participants: ReadonlyArray, + timeZone: string, + fromMs: number, + hour: number, + isHandoffDay: (weekday: string) => boolean, + periodMs: number, +): ReadonlyArray => { + const weekday = weekdayFormatter(timeZone) + const edges = Arr.filter( + Arr.map(DAYS, (day) => wallClock(fromMs, timeZone, day, hour)), + (at) => isHandoffDay(weekday.format(at)), + ) + return Arr.flatMap(edges, (startMs, index) => { + const endMs = edges[index + 1] + const userId = participants[turnOf(startMs, periodMs) % participants.length] + return endMs === undefined || userId === undefined ? [] : [{ userId, startMs, endMs }] + }) +} + +/** A window from `startHour` to `endHour` the next morning on each kept day, people alternating. */ +const nights = ( + participants: ReadonlyArray, + timeZone: string, + fromMs: number, + startHour: number, + endHour: number, + isDay: (weekday: string) => boolean, +): ReadonlyArray => { + const weekday = weekdayFormatter(timeZone) + return Arr.flatMap(DAYS, (day) => { + const startMs = wallClock(fromMs, timeZone, day, startHour) + const userId = participants[turnOf(startMs, DAY_MS) % participants.length] + return isDay(weekday.format(startMs)) && userId !== undefined + ? [{ userId, startMs, endMs: wallClock(fromMs, timeZone, day + 1, endHour) }] + : [] + }) +} + +const clip = ( + segments: ReadonlyArray, + fromMs: number, + toMs: number, +): ReadonlyArray => + Arr.flatMap(segments, (segment) => { + const startMs = Math.max(segment.startMs, fromMs) + const endMs = Math.min(segment.endMs, toMs) + return endMs > startMs ? [{ ...segment, startMs, endMs }] : [] + }) + +const covering = (segments: ReadonlyArray, at: number) => + Arr.findLast(segments, (segment) => segment.startMs <= at && at < segment.endMs) + +const sameSource = (a: Source, b: Source): boolean => + a.kind === "layer" + ? b.kind === "layer" && a.layer_index === b.layer_index + : b.kind === "override" && a.override_id === b.override_id + +/** Layers stacked bottom first, the top one with someone on call winning, overrides over all. */ +const flatten = ( + layers: ReadonlyArray>, + overrides: ReadonlyArray>, + fromMs: number, + toMs: number, +): ReadonlyArray => { + const edges = Arr.sort( + Arr.dedupe([ + fromMs, + toMs, + ...Arr.flatMap([...layers, overrides], (segments) => + Arr.flatMap(segments, (segment) => [segment.startMs, segment.endMs]), + ), + ]), + Order.Number, + ) + const pieces = Arr.flatMap(edges, (startMs, index): ReadonlyArray => { + const endMs = edges[index + 1] + if (endMs === undefined || startMs < fromMs || endMs > toMs) return [] + const mid = (startMs + endMs) / 2 + const override = Arr.findLast(overrides, (segment) => segment.startMs <= mid && mid < segment.endMs) + if (Option.isSome(override)) + return [ + { + userId: override.value.userId, + startMs, + endMs, + source: { kind: "override", override_id: override.value.id }, + }, + ] + const top = Arr.findLast( + Arr.map(layers, (segments, layerIndex) => ({ layerIndex, hit: covering(segments, mid) })), + ({ hit }) => Option.isSome(hit), + ) + return Option.match(top, { + onNone: () => [], + onSome: ({ layerIndex, hit }) => + Option.match(hit, { + onNone: () => [], + onSome: (segment) => [ + { + userId: segment.userId, + startMs, + endMs, + source: { kind: "layer", layer_index: layerIndex }, + }, + ], + }), + }) + }) + const none: ReadonlyArray = [] + return Arr.reduce(pieces, none, (merged, piece) => { + const last = merged[merged.length - 1] + return last !== undefined && + last.endMs === piece.startMs && + last.userId === piece.userId && + sameSource(last.source, piece.source) + ? [...merged.slice(0, -1), { ...last, endMs: piece.endMs }] + : [...merged, piece] + }) +} + +// WIRE + +const iso = (ms: number) => msIso(ms) +const decodeSchedule = Schema.decodeUnknownSync(V2OnCallSchedule) +const decodeShifts = Schema.decodeUnknownSync(V2OnCallShifts) +const decodeOverride = Schema.decodeUnknownSync(V2OnCallOverride) +const decodeEvent = Schema.decodeUnknownSync(V2AlertIncidentEvent) + +const wireShift = (shift: Shift) => ({ + user_id: shift.userId, + starts_at: iso(shift.startMs), + ends_at: iso(shift.endMs), + source: + shift.source.kind === "layer" + ? shift.source + : { kind: "override", override_id: encodePublicId("ovr", shift.source.override_id) }, +}) + +/** One fixture schedule: its layers as the API holds them, and how to render its shifts. */ +type Spec = Readonly<{ + n: number + name: string + timeZone: string + layers: ReadonlyArray + segments: (fromMs: number) => ReadonlyArray> + overrides: (nowMs: number) => ReadonlyArray> +}> + +const m = (index: number) => memberId(index) +const isWeekday = (day: string) => day !== "Sat" && day !== "Sun" +const isWeekend = (day: string) => day === "Sat" || day === "Sun" + +const checkout: Spec = { + n: 1, + name: "Checkout primary", + timeZone: "Europe/Berlin", + layers: [ + { + name: "Primary", + participants: [m(0), m(1), m(2)], + rotation: { kind: "weekly", handoff_weekday: "mon", handoff_time: "09:00" }, + starts_at: "2026-10-05T07:00:00.000Z", + }, + { + name: "Daytime backup", + participants: [m(3), m(4)], + rotation: { kind: "daily", handoff_time: "09:00" }, + starts_at: "2026-10-05T07:00:00.000Z", + restrictions: [ + { weekdays: ["mon", "tue", "wed", "thu", "fri"], start_time: "09:00", end_time: "17:00" }, + ], + }, + { + name: "Weeknights", + participants: [m(5), m(6)], + rotation: { kind: "daily", handoff_time: "18:00" }, + starts_at: "2026-10-05T16:00:00.000Z", + restrictions: [ + { weekdays: ["mon", "tue", "wed", "thu", "fri"], start_time: "18:00", end_time: "08:00" }, + ], + }, + ], + segments: (fromMs) => [ + rotation([m(0), m(1), m(2)], "Europe/Berlin", fromMs, 9, (day) => day === "Mon", 7 * DAY_MS), + Arr.filter( + Arr.map( + rotation([m(3), m(4)], "Europe/Berlin", fromMs, 9, () => true, DAY_MS), + (segment) => ({ + ...segment, + endMs: segment.startMs + 8 * 3_600_000, + }), + ), + (segment) => isWeekday(weekdayFormatter("Europe/Berlin").format(segment.startMs)), + ), + nights([m(5), m(6)], "Europe/Berlin", fromMs, 18, 8, isWeekday), + ], + overrides: (nowMs) => [ + { + id: overrideUuid(1), + userId: m(7), + startMs: wallClock(nowMs, "Europe/Berlin", 1, 18), + endMs: wallClock(nowMs, "Europe/Berlin", 2, 8), + }, + { + id: overrideUuid(2), + userId: m(1), + startMs: wallClock(nowMs, "Europe/Berlin", 3, 12), + endMs: wallClock(nowMs, "Europe/Berlin", 4, 12), + }, + ], +} + +const longName: Spec = { + n: 2, + name: "Payments platform and ledger reconciliation, North America business hours rotation", + timeZone: "America/New_York", + layers: [ + { + name: "Business hours", + participants: [m(4), m(2), m(6)], + rotation: { kind: "custom", length_hours: 12 }, + starts_at: "2026-11-02T13:00:00.000Z", + }, + ], + segments: (fromMs) => [rotation([m(4), m(2), m(6)], "America/New_York", fromMs, 9, () => true, DAY_MS)], + overrides: () => [], +} + +/** Weekends only: on a weekday nobody is on call. */ +const weekends: Spec = { + n: 3, + name: "Weekend coverage", + timeZone: "UTC", + layers: [ + { + name: "Weekends", + participants: [m(7), m(3)], + rotation: { kind: "weekly", handoff_weekday: "sat", handoff_time: "00:00" }, + starts_at: "2026-10-03T00:00:00.000Z", + restrictions: [{ weekdays: ["sat", "sun"], start_time: "00:00", end_time: "00:00" }], + }, + ], + segments: (fromMs) => [ + Arr.filter( + rotation([m(7), m(3)], "UTC", fromMs, 0, () => true, DAY_MS), + (segment) => isWeekend(weekdayFormatter("UTC").format(segment.startMs)), + ), + ], + overrides: () => [], +} + +const SPECS: ReadonlyArray = [checkout, longName, weekends] + +const WEEK_MS = 7 * 86_400_000 + +/** The schedule as `GET /oncall_schedules` returns it, with who is on call at `nowMs`. */ +const scheduleOf = (spec: Spec, nowMs: number): V2OnCallSchedule => { + const final = flatten( + spec.segments(nowMs - WEEK_MS), + spec.overrides(nowMs), + nowMs - WEEK_MS, + nowMs + WEEK_MS, + ) + const current = Arr.findFirst(final, (shift) => shift.startMs <= nowMs && nowMs < shift.endMs) + return decodeSchedule({ + id: encodePublicId("onc", scheduleUuid(spec.n)), + object: "oncall_schedule", + name: spec.name, + description: null, + time_zone: spec.timeZone, + layers: spec.layers, + on_call_now: Option.getOrNull( + Option.map(current, (shift) => ({ + user_id: shift.userId, + starts_at: iso(shift.startMs), + ends_at: iso(shift.endMs), + })), + ), + chain_ids: spec.n === 1 ? [encodePublicId("esc", "0199a00e-0000-7000-8000-000000000001")] : [], + created_at: iso(nowMs - 30 * 86_400_000), + updated_at: iso(nowMs - 86_400_000), + }) +} + +/** Three schedules: one with three layers and overrides, one with a long name, one in a gap. */ +export const populatedSchedules = (nowMs: number = ONCALL_NOW): ReadonlyArray => + Arr.map(SPECS, (spec) => scheduleOf(spec, nowMs)) + +export const checkoutSchedule = (nowMs: number = ONCALL_NOW): V2OnCallSchedule => scheduleOf(checkout, nowMs) + +/** `GET /{id}/shifts` for the checkout schedule over `[fromMs, toMs)`. */ +export const checkoutShifts = (nowMs: number, fromMs: number, toMs: number): V2OnCallShifts => { + const layers = Arr.map(checkout.segments(fromMs), (segments) => clip(segments, fromMs, toMs)) + const overrides = checkout.overrides(nowMs) + return decodeShifts({ + object: "oncall_shifts", + schedule_id: encodePublicId("onc", scheduleUuid(1)), + from: iso(fromMs), + to: iso(toMs), + layers: Arr.map(layers, (segments, layerIndex) => + Arr.map(segments, (segment) => + wireShift({ ...segment, source: { kind: "layer", layer_index: layerIndex } }), + ), + ), + final: Arr.map(flatten(layers, clip(overrides, fromMs, toMs), fromMs, toMs), wireShift), + }) +} + +/** The checkout schedule's upcoming overrides. */ +export const checkoutOverrides = (nowMs: number = ONCALL_NOW): ReadonlyArray => + Arr.map(checkout.overrides(nowMs), (override) => + decodeOverride({ + id: encodePublicId("ovr", override.id), + object: "oncall_override", + schedule_id: encodePublicId("onc", scheduleUuid(1)), + user_id: override.userId, + starts_at: iso(override.startMs), + ends_at: iso(override.endMs), + created_by: m(0), + created_at: iso(nowMs - 86_400_000), + }), + ) + +const eventUuid = (n: number) => `0199a011-0000-7000-8000-${pad(n)}` + +/** `paged` incident events: one sent, one failed, one still on its way. */ +export const pagedEvents = (incidentId: string, atMs: number): ReadonlyArray => + Arr.map( + [ + { n: 1, user: 0, channel: "slack", status: "sent", error: null, minutes: 1 }, + { n: 2, user: 0, channel: "email", status: "sent", error: null, minutes: 1 }, + { + n: 3, + user: 1, + channel: "discord", + status: "failed", + error: "Dana has not linked a Discord account", + minutes: 11, + }, + { n: 4, user: 1, channel: "email", status: "sending", error: null, minutes: 11 }, + ], + (event) => + decodeEvent({ + id: encodePublicId("evt", eventUuid(event.n)), + object: "alert_incident_event", + incident_id: encodePublicId("inc", incidentId), + type: "paged", + actor: null, + source: null, + page: { + user_id: memberId(event.user), + name: NAMES[event.user] ?? null, + channel: event.channel, + status: event.status, + error_message: event.error, + }, + created_at: iso(atMs + event.minutes * 60_000), + }), + ) + +const decodeChain = Schema.decodeUnknownSync(V2EscalationChain) + +/** A chain built through the API: a destination, then a person and a schedule, then a person. */ +export const mixedTargetChain = (destinationId: string): V2EscalationChain => + decodeChain({ + id: encodePublicId("esc", "0199a00e-0000-7000-8000-000000000001"), + object: "escalation_chain", + name: "Checkout on-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destination_id: encodePublicId("dest", destinationId) }], + wait_minutes: 5, + }, + { + targets: [ + { type: "schedule", schedule_id: schedulePublicId(1) }, + { type: "user", user_id: memberId(1) }, + ], + wait_minutes: 15, + }, + { targets: [{ type: "user", user_id: memberId(0) }], wait_minutes: 30 }, + ], + repeat_count: 1, + rule_ids: [], + created_at: iso(ONCALL_NOW), + updated_at: iso(ONCALL_NOW), + }) diff --git a/apps/web/src/page/alerts/shared/org-members.test.ts b/apps/web/src/page/alerts/shared/org-members.test.ts new file mode 100644 index 0000000000..ad6ed22284 --- /dev/null +++ b/apps/web/src/page/alerts/shared/org-members.test.ts @@ -0,0 +1,57 @@ +import { Effect, Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import type { ClerkMembershipResource } from "../../../platform/clerk" +import { MAX_MEMBERS, memberName, membersOf } from "./org-members" + +const membership = (n: number): ClerkMembershipResource => ({ + id: `mem_${n}`, + role: "org:member", + createdAt: new Date(0), + publicUserData: { + userId: `user_${n}`, + firstName: `Person`, + lastName: String(n), + identifier: `person${n}@acme.test`, + imageUrl: "", + hasImage: false, + }, + update: () => Promise.resolve(), + destroy: () => Promise.resolve(), +}) + +/** An org of `total` members, served 100 a page, counting the pages asked for. */ +const organizationOf = (total: number) => { + const asked: Array = [] + return { + asked, + getMemberships: ({ initialPage = 1, pageSize = 10 }: { initialPage?: number; pageSize?: number }) => { + asked.push(initialPage) + const start = (initialPage - 1) * pageSize + return Promise.resolve({ + data: Arr.map(Arr.range(start, Math.min(start + pageSize, total) - 1), membership), + total_count: total, + }) + }, + } +} + +describe("org members", () => { + it("reads every page of a large org, not just the first 100", async () => { + const organization = organizationOf(250) + const members = await Effect.runPromise(membersOf(organization)) + expect(members).toHaveLength(250) + expect(memberName(members, "user_249")).toBe("Person 249") + expect([...organization.asked].sort()).toEqual([1, 2, 3]) + }) + + it("stops at the cap, and names a member past it neutrally rather than as gone", async () => { + const members = await Effect.runPromise(membersOf(organizationOf(MAX_MEMBERS + 150))) + expect(members).toHaveLength(MAX_MEMBERS) + expect(memberName(members, `user_${MAX_MEMBERS + 10}`)).toBe("Member") + }) + + it("reads unknown ids as former members only once the whole directory is known", () => { + expect(memberName(null, "user_1")).toBe("Member") + expect(memberName([], "user_1")).toBe("A former member") + }) +}) diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts new file mode 100644 index 0000000000..92651bae91 --- /dev/null +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -0,0 +1,95 @@ +import { UserId } from "@maple/domain/http" +import { Array as Arr, Effect, Option, Schema } from "effect" +import { + type ClerkMembershipResource, + type ClerkOrganizationResource, + clerkTry, + currentClerkOrganization, +} from "../../../platform/clerk" + +// The org's members as the on-call pickers and names read them: Clerk's memberships, every page +// up to `MAX_MEMBERS` (an org past that names the rest "Member" and cannot pick them here). + +const PAGE_SIZE = 100 +export const MAX_MEMBERS = 2000 + +export const OrgMember = Schema.Struct({ + userId: UserId, + name: Schema.String, + email: Schema.String, + /** `""` without an uploaded image: the warm initials tile stands in. */ + imageUrl: Schema.String, +}) +export type OrgMember = typeof OrgMember.Type + +const decodeUserIdOption = Schema.decodeUnknownOption(UserId) + +const memberOfMembership = (member: ClerkMembershipResource): ReadonlyArray => { + const user = member.publicUserData + const email = user?.identifier ?? "" + const name = Arr.join( + Arr.filter([user?.firstName, user?.lastName], (part): part is string => Boolean(part)), + " ", + ) + return Option.match(decodeUserIdOption(user?.userId), { + onNone: () => [], + onSome: (userId) => [ + { + userId, + name: name || email || "Unknown member", + email, + imageUrl: user?.hasImage === true ? user.imageUrl : "", + }, + ], + }) +} + +/** Every member of `organization` with a user id, named by first and last name, else by email. */ +export const membersOf = ( + organization: Pick, +): Effect.Effect, string> => + Effect.gen(function* () { + const page = (initialPage: number) => + clerkTry("Couldn't load members", () => + organization.getMemberships({ initialPage, pageSize: PAGE_SIZE }), + ) + const first = yield* page(1) + const pageCount = Math.ceil(Math.min(first.total_count, MAX_MEMBERS) / PAGE_SIZE) + // The rest of the pages, 4 at a time. + const rest = yield* Effect.forEach(pageCount > 1 ? Arr.range(2, pageCount) : [], page, { + concurrency: 4, + }) + return Arr.flatMap([first, ...rest], (result) => Arr.flatMap(result.data, memberOfMembership)) + }).pipe(Effect.mapError((error) => error.message)) + +/** The current org's members. */ +export const listOrgMembers: Effect.Effect, string> = currentClerkOrganization.pipe( + Effect.mapError((error) => error.message), + Effect.flatMap(membersOf), +) + +/** + * A member by id; someone who left the org reads as "A former member". Until the members load, after + * a failed read, or in an org past `MAX_MEMBERS`, an unknown id reads neutrally as "Member". + */ +export const memberName = (members: ReadonlyArray | null, userId: string): string => + members === null || members.length >= MAX_MEMBERS + ? Option.getOrElse( + Option.map( + Arr.findFirst(members ?? [], (member) => member.userId === userId), + (member) => member.name, + ), + () => "Member", + ) + : Option.getOrElse( + Option.map( + Arr.findFirst(members, (member) => member.userId === userId), + (member) => member.name, + ), + () => "A former member", + ) + +export const memberOf = ( + members: ReadonlyArray | null, + userId: string, +): Option.Option => Arr.findFirst(members ?? [], (member) => member.userId === userId) diff --git a/apps/web/src/page/alerts/slo-form/view.ts b/apps/web/src/page/alerts/slo-form/view.ts index 0c4972a774..08239544ff 100644 --- a/apps/web/src/page/alerts/slo-form/view.ts +++ b/apps/web/src/page/alerts/slo-form/view.ts @@ -20,7 +20,13 @@ import { typography, typographyClass } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { destinationPicker } from "../../kit/destination-picker" -import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" import { sloHref, slosTabHref } from "../shared/slo-commands" import { formatPercent, formatThreshold, formatWindow } from "../shared/slo-derive" import { budgetDowntime, formIssues, targetOf } from "./derive" @@ -343,23 +349,20 @@ export const view = Submodel.defineView((model, if (isEdit && !AsyncData.isSuccess(model.existing.data) && !AsyncData.isRefreshing(model.existing.data)) return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) return dashboardPage(h, { ...header, fill: true }, [ - h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], - [ - formCard( - h, - [ - nameSection(h, model.form), - serviceSection(h, model), - indicatorSection(h, model.form), - targetSection(h, model.form), - windowSection(h, model.form), - alertingSection(h, model), - ], - FORM_WIDTH, - ), - ], - ), + formScrollArea(h, [ + formCard( + h, + [ + nameSection(h, model.form), + serviceSection(h, model), + indicatorSection(h, model.form), + targetSection(h, model.form), + windowSection(h, model.form), + alertingSection(h, model), + ], + FORM_WIDTH, + ), + ]), actionBar(h, model), ]) }) diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 9b937391a3..2dd7128f3c 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -12,7 +12,13 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { skeleton } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" +import { + formActionBar, + formCard, + formScrollArea, + formSection, + managersOnlyPage, +} from "../../kit/form-section" import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { LIMIT_REACHED_CODE, monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { formatMs } from "../shared/uptime-derive" @@ -296,33 +302,27 @@ export const view = Submodel.defineView((model, notifySection(h, model), ]) return dashboardPage(h, { ...header, fill: true }, [ - h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], - [ - isHttp - ? h.div( - [h.Class(cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]", widthClass))], - [ - card, - h.div([h.Class("lg:sticky lg:top-0 lg:self-start")], [testPanel(h, model)]), - ], - ) - : h.div( - [h.Class(cn(vstackClass({ gap: "2" }), widthClass))], - [ - card, - ...(isEdit - ? [] - : [ - h.p( - [h.Class("px-1 text-xs text-muted-foreground")], - [afterSaveNote(model.form.kind)], - ), - ]), - ], - ), - ], - ), + formScrollArea(h, [ + isHttp + ? h.div( + [h.Class(cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]", widthClass))], + [card, h.div([h.Class("lg:sticky lg:top-0 lg:self-start")], [testPanel(h, model)])], + ) + : h.div( + [h.Class(cn(vstackClass({ gap: "2" }), widthClass))], + [ + card, + ...(isEdit + ? [] + : [ + h.p( + [h.Class("px-1 text-xs text-muted-foreground")], + [afterSaveNote(model.form.kind)], + ), + ]), + ], + ), + ]), actionBar(h, model, widthClass), ]) }) diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts index 80238cd3ee..1543910123 100644 --- a/apps/web/src/page/kit/destination-picker.ts +++ b/apps/web/src/page/kit/destination-picker.ts @@ -2,13 +2,9 @@ import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" import { Array as Arr, Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" import type * as Menu from "@maple/views/ui/menu" -import * as MenuView from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { pressable } from "@maple/views/ui/pressable" -import { hstack } from "@maple/views/ui/stack" -import { typography } from "@maple/views/ui/typography" +import type * as MenuView from "@maple/views/ui/menu-view" +import { pickList } from "./pick-list" /** * `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add @@ -33,7 +29,6 @@ export const destinationPicker = ( ): Html => { const rows = options.rows ?? [] const isDisabled = options.isDisabled ?? false - const inContext = options.context === undefined ? "" : ` in ${options.context}` // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. const fallback = options.rows === null ? "Destination" : "Unavailable destination" const selected = Arr.map(options.selectedIds, (id) => ({ @@ -47,76 +42,26 @@ export const destinationPicker = ( ), })) const available = Arr.filter(rows, (row) => !Arr.some(options.selectedIds, (id) => id === row.id)) - return panel(h, { className: "divide-y shadow-none" }, [ - ...Arr.map(selected, (destination) => - hstack(h, { gap: "2", className: "px-3 py-2", attributes: [h.Key(destination.id)] }, [ - icon(h, "BellIcon", { className: "text-muted-foreground" }), - typography(h, { size: "sm", truncate: true, className: "min-w-0 flex-1" }, [ - destination.name, - ]), - iconButton( - h, - { - label: `Stop notifying ${destination.name}${inContext}`, - className: "text-muted-foreground", - isDisabled, - attributes: [h.OnClick(options.onRemove(destination.id))], - }, - [icon(h, "XmarkIcon")], - ), - ]), - ), - ...(selected.length === 0 - ? [ - typography(h, { size: "sm", tone: "muted", as: "p", className: "px-3 py-2.5" }, [ - options.emptyText, - ]), - ] - : []), - ...(available.length === 0 - ? [] - : [ - h.submodel({ - slotId: options.menu.id, - model: options.menu, - view: MenuView.view, - viewInputs: { - entries: Arr.map(available, (row): MenuView.MenuEntry => ({ - _tag: "Item", - value: row.id, - label: row.name, - })), - align: "start", - className: "w-72", - toView: ({ trigger, withTrigger }) => - h.span( - [h.Class("contents")], - withTrigger( - pressable( - h, - { - className: - "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - isDisabled, - attributes: - options.context === undefined - ? trigger - : [ - ...trigger, - h.AriaLabel( - `Add destination to ${options.context}`, - ), - ], - }, - [icon(h, "PlusIcon", { size: 14 }), "Add destination"], - ), - ), - ), - }, - toParentMessage: options.toMenuMessage, - }), - ]), - ]) + return pickList(h, { + entries: Arr.map(selected, (destination) => ({ + key: destination.id, + label: destination.name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${destination.name}`, + onRemove: options.onRemove(destination.id), + })), + emptyText: options.emptyText, + menu: options.menu, + toMenuMessage: options.toMenuMessage, + addEntries: Arr.map(available, (row): MenuView.MenuEntry => ({ + _tag: "Item", + value: row.id, + label: row.name, + })), + addLabel: "Add destination", + isDisabled, + ...(options.context === undefined ? undefined : { context: options.context }), + }) } /** The destination a menu pick added, if it is a synced one not already chosen. */ diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 18e8771cb6..56b0edae15 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -45,6 +45,13 @@ export const formSection = ( ], ) +/** `FormScrollArea`: a form page's scroller above its action bar, the page's own scroll area. */ +export const formScrollArea = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.div( + [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + children, + ) + /** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => h.div([h.Class(cn("divide-y rounded-md border bg-card", className))], children) diff --git a/apps/web/src/page/kit/pick-list.ts b/apps/web/src/page/kit/pick-list.ts new file mode 100644 index 0000000000..fbbc9e10a2 --- /dev/null +++ b/apps/web/src/page/kit/pick-list.ts @@ -0,0 +1,148 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { pressable } from "@maple/views/ui/pressable" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an +// ordered list, movable, then an "Add" menu of the rest. + +const moveLabel = (label: string, direction: "up" | "down", context: string | undefined): string => + context === undefined ? `Move ${label} ${direction}` : `Move ${label} ${direction} in ${context}` + +/** One picked entry: its media (an icon or an avatar), its name and how to remove or move it. */ +export type PickedEntry = Readonly<{ + key: string + label: string + media: Html + /** A muted note after the name ("on call: Ben Brook"). */ + detail?: string + removeLabel: string + onRemove: M + /** Present on an ordered list: the up and down buttons, disabled at its ends. */ + /** `context` names the list in the move buttons ("layer 2"), when one page shows several lists. */ + move?: Readonly<{ up: M | null; down: M | null; context?: string }> +}> + +/** + * `PickList`: what a form has picked, each removable (and in order, movable), then an "Add" menu + * of the rest. The owner keeps the menu's Model and the picks; destinations, people and schedules. + */ +export const pickList = ( + h: HtmlBuilder, + options: Readonly<{ + entries: ReadonlyArray> + /** What an empty pick means, in one line. */ + emptyText: string + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + /** The menu's entries; no add row while there are none. */ + addEntries: ReadonlyArray + addLabel: string + /** A save in flight: the picks show, but nothing can be removed, moved or added. */ + isDisabled?: boolean + /** Names the list in its remove and add buttons ("step 2") when a form shows several. */ + context?: string + }>, +): Html => { + const isDisabled = options.isDisabled ?? false + const inContext = options.context === undefined ? "" : ` in ${options.context}` + return panel(h, { className: "divide-y shadow-none" }, [ + ...Arr.map(options.entries, (entry) => + hstack(h, { gap: "2", className: "px-3 py-2", attributes: [h.Key(entry.key)] }, [ + entry.media, + typography(h, { size: "sm", truncate: true, className: "min-w-0 flex-1" }, [ + entry.label, + ...(entry.detail === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted" }, [` · ${entry.detail}`])]), + ]), + ...(entry.move === undefined + ? [] + : [ + iconButton( + h, + { + label: moveLabel(entry.label, "up", entry.move.context), + isDisabled: isDisabled || entry.move.up === null, + className: "text-muted-foreground", + attributes: entry.move.up === null ? [] : [h.OnClick(entry.move.up)], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: moveLabel(entry.label, "down", entry.move.context), + isDisabled: isDisabled || entry.move.down === null, + className: "text-muted-foreground", + attributes: entry.move.down === null ? [] : [h.OnClick(entry.move.down)], + }, + [icon(h, "ArrowDownIcon")], + ), + ]), + iconButton( + h, + { + label: `${entry.removeLabel}${inContext}`, + className: "text-muted-foreground", + isDisabled, + attributes: [h.OnClick(entry.onRemove)], + }, + [icon(h, "XmarkIcon")], + ), + ]), + ), + ...(options.entries.length === 0 + ? [ + typography(h, { size: "sm", tone: "muted", as: "p", className: "px-3 py-2.5" }, [ + options.emptyText, + ]), + ] + : []), + ...(options.addEntries.length === 0 + ? [] + : [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: options.addEntries, + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + pressable( + h, + { + className: + "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", + isDisabled, + attributes: + options.context === undefined + ? trigger + : [ + ...trigger, + h.AriaLabel( + `${options.addLabel} to ${options.context}`, + ), + ], + }, + [icon(h, "PlusIcon", { size: 14 }), options.addLabel], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ]), + ]) +} diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 63b58aea1e..addae8db61 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -29,6 +29,8 @@ import * as AlertsUptimeMonitor from "./alerts/uptime-monitor" import * as AlertsUptimeNew from "./alerts/uptime-form" import * as AlertsSlo from "./alerts/slo" import * as AlertsSloNew from "./alerts/slo-form" +import * as AlertsSchedule from "./alerts/schedule" +import * as AlertsScheduleNew from "./alerts/schedule-form" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -122,6 +124,8 @@ export const pages = [ AlertsUptimeNew.page, AlertsSlo.page, AlertsSloNew.page, + AlertsSchedule.page, + AlertsScheduleNew.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/page/settings/members/view.ts b/apps/web/src/page/settings/members/view.ts index 64e6cbe796..4985b49602 100644 --- a/apps/web/src/page/settings/members/view.ts +++ b/apps/web/src/page/settings/members/view.ts @@ -1,5 +1,4 @@ import * as Policy from "@maple/domain/policy" -import { initialsFrom } from "@maple/ui/lib/initials" import { cn } from "@maple/ui/lib/utils" import { orgRoleLabel, roleChangePrompt, toOrgRole } from "../../../components/settings/org-membership" import { formatDateInTimezone } from "@maple/web-data/lib/timezone-format" @@ -7,8 +6,7 @@ import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" -import { warmAvatarTone } from "@maple/views/ui/avatar-tone" -import { avatarFallback } from "@maple/views/ui/display" +import { personAvatar } from "@maple/views/ui/person-avatar" import { field, type FieldIds, fieldLabel, input, type FieldState } from "@maple/views/ui/field" import * as Field from "@maple/views/ui/field" import { formDialog } from "@maple/views/ui/form-dialog" @@ -123,7 +121,7 @@ const memberRows = (h: H, model: Model, member: MemberRow, inputs: Inputs): Read [ truncatedTableCell(h, [ hstack(h, { gap: "3" }, [ - personAvatar(h, member.name, member.imageUrl), + personAvatar(h, { name: member.name, imageUrl: member.imageUrl }), h.div( [h.Class("min-w-0")], [ @@ -249,18 +247,6 @@ const memberRows = (h: H, model: Model, member: MemberRow, inputs: Inputs): Read ] } -/** The warm initials tile the sidebar draws, or the member's own uploaded image. */ -const personAvatar = (h: H, seed: string, imageUrl: string): Html => - avatarFallback( - h, - { - className: "size-6 shrink-0", - fallbackClassName: `font-medium ${warmAvatarTone(seed)}`, - imageUrl, - }, - initialsFrom(seed), - ) - export const description = "Admins manage billing, keys and members." /** The tab's primary action, drawn in the settings page header beside the `

`. */ @@ -318,7 +304,7 @@ const invitationRow = (h: H, model: Model, invitation: InvitationRow, inputs: In [ truncatedTableCell(h, [ hstack(h, { gap: "3" }, [ - personAvatar(h, invitation.email, ""), + personAvatar(h, { name: invitation.email }), truncatedText(h, { text: invitation.email, className: "text-xs" }), ]), ]), diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 3d76b019e3..6cc1190873 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -34,6 +34,8 @@ export const AppRoute = defineRouteUnion({ AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, AlertsSloNew: { search: Search.AlertsSloNewSearch }, AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, + AlertsScheduleNew: { search: Search.AlertsScheduleNewSearch }, + AlertsSchedule: { scheduleId: Schema.String, search: Search.AlertsScheduleSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 2e5d718c41..08e3c2da40 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -103,6 +103,17 @@ const routers = { search(Search.AlertsSloSearch), Route.mapTo(AppRoute.AlertsSlo), ), + AlertsScheduleNew: pipe( + path("alerts", "schedules", "new"), + search(Search.AlertsScheduleNewSearch), + Route.mapTo(AppRoute.AlertsScheduleNew), + ), + AlertsSchedule: pipe( + path("alerts", "schedules"), + slash(segment("scheduleId")), + search(Search.AlertsScheduleSearch), + Route.mapTo(AppRoute.AlertsSchedule), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -515,6 +526,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsUptimeMonitor, routers.AlertsSloNew, routers.AlertsSlo, + routers.AlertsScheduleNew, + routers.AlertsSchedule, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index f07913dd33..6b4e5500a8 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -372,6 +372,13 @@ export const AlertsSloNewSearch = Schema.Struct({ export const AlertsSloSearch = Schema.Struct({}) +/** `/alerts/schedules/new`: `scheduleId` edits that on-call schedule. */ +export const AlertsScheduleNewSearch = Schema.Struct({ + scheduleId: Schema.optional(Schema.String), +}) + +export const AlertsScheduleSearch = Schema.Struct({}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 404050eca0..754840a01d 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -44,6 +44,8 @@ const SAMPLE_PATHS = [ "/alerts/uptime/new", "/alerts/slos/new", "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", + "/alerts/schedules/new", + "/alerts/schedules/onc_gQtGH2s65G6a7sJmuayXL6", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index c5b9eba5a4..490ce2e509 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -122,6 +122,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/overlay/transition.ts` `isLeaving`, `isMounted`, `stateAttributes`, `focusGuard`: Base UI's transition status on a Foldkit Animation: `data-starting-style` on the first frame of an open, `data-ending-style` while it closes, and `data-open` / `data-closed` thr... - `packages/views/src/ui/page-layout.ts` `pageLayoutRoot`, `pageTitle`, `pageDescription`, `pageHeader`, `pageHeaderActions`: Port of the header half of packages/ui/src/components/ui/page-layout.tsx (Root, Header, Title, Description, HeaderActions). - `packages/views/src/ui/panel.ts` `panel`, `panelTitle`, `panelHeader`, `panelBody`, `panelChartBody`: Port of packages/ui/src/components/ui/panel.tsx: the flat bordered section frame. +- `packages/views/src/ui/person-avatar.ts` `personAvatar`, `personLabel`: `PersonAvatar` and `PersonLabel`: a member of the org as a warm initials tile (or their own image), alone or beside their name. - `packages/views/src/ui/popover.ts` `titleId`, `descriptionId`, `open`, `close`, `view`, `popoverTitle`, `popoverDescription` (stateful: Model, init, update): Base UI `Popover` (`@maple/ui/popover`, non-modal): the trigger toggles it, Escape and a press outside close it, and it is placed under the trigger with `sideOffset` 4 and colli... - `packages/views/src/ui/pressable.ts` `pressable`: `Pressable`: a click target that draws its own look (a timeline lane, a chart bar, a colour swatch, a bespoke row). - `packages/views/src/ui/radio-group.ts` `radioGroup`, `labelId`, `radio` (stateful: Model, init, update): Base UI `RadioGroup`: the checked value. @@ -140,6 +141,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. - `packages/views/src/ui/sheet.ts` `leftSheet`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. - `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. - `packages/views/src/ui/signal-empty-state.ts` `emptyActions`, `filteredEmpty`, `presenceLoading`, `signalEmptyState`: `SignalEmptyState` and `FilteredEmpty`. @@ -185,7 +187,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. -- `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. @@ -200,6 +202,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/message-response.ts` `glyph`, `messageResponse`: Lightweight markdown over Streamdown: laid out with Streamdown's element classes. - `apps/web/src/page/kit/page-rail-sheet.ts` `pageRailSheet`: `PageLayout.FilterSidebar` / `RightSidebar` below `lg`: the page's rail in a side sheet (``) with an sr-only header. - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. +- `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. diff --git a/packages/views/src/ui/person-avatar.ts b/packages/views/src/ui/person-avatar.ts new file mode 100644 index 0000000000..00cc8926d6 --- /dev/null +++ b/packages/views/src/ui/person-avatar.ts @@ -0,0 +1,44 @@ +import { initialsFrom } from "@maple/ui/lib/initials" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { warmAvatarTone } from "./avatar-tone" +import { avatarFallback } from "./display" +import { hstack, vstack } from "./stack" +import { typography } from "./typography" + +// `PersonAvatar` and `PersonLabel`: a member of the org as a warm initials tile (or their own +// image), alone or beside their name. + +const SIZE = { xs: "size-5", sm: "size-6" } as const + +/** A member's avatar: their uploaded image, else the warm initials tile seeded by their name. */ +export const personAvatar = ( + h: HtmlBuilder, + options: Readonly<{ name: string; imageUrl?: string; size?: keyof typeof SIZE; className?: string }>, +): Html => + avatarFallback( + h, + { + className: cn(SIZE[options.size ?? "sm"], "shrink-0", options.className), + fallbackClassName: `font-medium ${warmAvatarTone(options.name)}`, + imageUrl: options.imageUrl ?? "", + }, + initialsFrom(options.name), + ) + +/** Avatar, name and an optional muted line under it ("until Mon 09:00"), truncating in a row. */ +export const personLabel = ( + h: HtmlBuilder, + options: Readonly<{ name: string; imageUrl?: string; detail?: string; size?: keyof typeof SIZE }>, +): Html => + hstack(h, { gap: "2", shrink: true }, [ + personAvatar(h, { name: options.name, imageUrl: options.imageUrl, size: options.size }), + options.detail === undefined + ? typography(h, { size: "sm", truncate: true }, [options.name]) + : vstack(h, { gap: "0", shrink: true }, [ + typography(h, { size: "sm", truncate: true }, [options.name]), + typography(h, { size: "xs", tone: "muted", truncate: true, numeric: true }, [ + options.detail, + ]), + ]), + ]) diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts new file mode 100644 index 0000000000..0181893586 --- /dev/null +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -0,0 +1,101 @@ +import { expect as expectView, given, role, scene, text } from "foldkit/scene" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { describe, test } from "vitest" +import { shiftTimeline } from "./shift-timeline" + +const Message = defineMessageUnion({ ClickedBar: {} }) +type Message = typeof Message.Type +type Model = Readonly<{ nowMs: number }> + +const HOUR = 3_600_000 +const FROM = Date.parse("2026-11-09T00:00:00.000Z") + +const view = (model: Model, h: HtmlBuilder): Html => + shiftTimeline(h, { + fromMs: FROM, + toMs: FROM + 7 * 24 * HOUR, + timeZone: "UTC", + nowMs: model.nowMs, + ariaLabel: "Shifts", + nowLabel: "Now, Tue 10 Nov, 11:30", + rows: [ + { + key: "final", + label: "Final schedule", + bars: [ + { + key: "a", + startMs: FROM, + endMs: FROM + HOUR, + label: "Ana", + title: "Ana, 1 hour", + tone: "primary", + onClick: Message.ClickedBar(), + }, + ], + }, + { + key: "layer", + label: "Primary", + bars: [ + { + key: "b", + startMs: FROM, + endMs: FROM + 24 * HOUR, + label: "Ben", + title: "Ben, Mon", + tone: "neutral", + }, + ], + }, + { + key: "later", + label: "Later layer", + emptyText: "Not active in this range", + bars: [ + { + key: "c", + startMs: FROM + 10 * 24 * HOUR, + endMs: FROM + 11 * 24 * HOUR, + label: "Cy", + title: "Cy, next week", + tone: "neutral", + }, + ], + }, + ], + }) +const update = (model: Model) => ({ model, commands: [] }) + +describe("shift timeline", () => { + test("reads as a table: one header over the track, a row header per lane, and every bar named", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(role("columnheader", { name: "Shifts" })).toExist(), + expectView(text("Mon 9")).toExist(), + expectView(role("rowheader", { name: /Primary/u })).toExist(), + expectView(role("button", { name: "Ana, 1 hour" })).toExist(), + expectView(role("img", { name: "Ben, Mon" })).toExist(), + expectView(text("Now, Tue 10 Nov, 11:30")).toExist(), + ) + }) + + test("says nothing of now when it is outside the range", () => { + scene( + { update, view }, + given({ nowMs: FROM - HOUR }), + expectView(text("Now, Tue 10 Nov, 11:30")).not.toExist(), + ) + }) + + test("a lane whose shifts all fall outside the range says so instead of drawing blank", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(text("Not active in this range")).toExist(), + expectView(role("img", { name: "Cy, next week" })).not.toExist(), + ) + }) +}) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts new file mode 100644 index 0000000000..a34045cf4f --- /dev/null +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -0,0 +1,58 @@ +import { Option, Array as Arr } from "effect" +import { describe, expect, test } from "vitest" +import { barRoomPct, placeBar, timelineDays, zonedDayStart } from "./shift-timeline" + +const HOUR = 3_600_000 + +describe("shift timeline layout", () => { + test("a bar is placed as a share of the range and clipped to it", () => { + expect(placeBar(0, 100, 25, 50)).toEqual(Option.some({ leftPct: 25, widthPct: 25 })) + expect(placeBar(0, 100, -50, 10)).toEqual(Option.some({ leftPct: 0, widthPct: 10 })) + expect(placeBar(0, 100, 100, 150)).toEqual(Option.none()) + }) + + test("a short bar's floor stops at the next bar in its lane, and the last one at the range's end", () => { + const bar = (key: string, startMs: number, endMs: number) => ({ + key, + startMs, + endMs, + label: key, + title: key, + tone: "neutral" as const, + }) + const week = 168 * HOUR + const hourLong = bar("a", 0, HOUR) + const next = bar("b", HOUR, 24 * HOUR) + const lane = [next, hourLong] + expect(barRoomPct(0, week, lane, hourLong)).toBeCloseTo((HOUR / week) * 100) + expect(barRoomPct(0, week, lane, next)).toBe(100) + }) + + test("days follow the zone's wall clock: the Berlin fall-back Sunday is 25 hours wide", () => { + const from = Date.parse("2026-10-22T22:00:00.000Z") // Fri 23 Oct, 00:00 in Berlin + const to = from + 7 * 24 * HOUR + HOUR + const days = timelineDays(from, to, "Europe/Berlin") + expect(Arr.map(days, (day) => `${day.weekday} ${day.dayOfMonth}`)).toEqual([ + "Fri 23", + "Sat 24", + "Sun 25", + "Mon 26", + "Tue 27", + "Wed 28", + "Thu 29", + ]) + const sunday = days[2] + expect(sunday === undefined ? 0 : (sunday.endMs - sunday.startMs) / HOUR).toBe(25) + expect(Arr.reduce(days, 0, (sum, day) => sum + day.widthPct)).toBeCloseTo(100, 6) + }) + + test("a day starts at the zone's midnight, not UTC's", () => { + const noonInNewYork = Date.parse("2026-11-10T17:00:00.000Z") + expect(new Date(zonedDayStart(noonInNewYork, "America/New_York")).toISOString()).toBe( + "2026-11-10T05:00:00.000Z", + ) + expect(new Date(zonedDayStart(noonInNewYork, "America/New_York", 1)).toISOString()).toBe( + "2026-11-11T05:00:00.000Z", + ) + }) +}) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts new file mode 100644 index 0000000000..38bfa4b949 --- /dev/null +++ b/packages/views/src/ui/shift-timeline.ts @@ -0,0 +1,348 @@ +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { cn } from "@maple/ui/lib/utils" +import { Array as Arr, Option, Result } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { pressable } from "./pressable" +import { hstack, vstack } from "./stack" +import { toneBoxClass } from "./tone-box" +import { tooltipTrigger } from "./tooltip-host" +import { typography } from "./typography" + +// `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an +// on-call schedule's layers and its final row). Days follow the zone's wall clock, so a DST day is +// 23 or 25 hours wide. + +/** A bar's look: a layer's shift, the effective shift, or an override over it. */ +export type TimelineBarTone = "neutral" | "primary" | "info" + +export type TimelineBar = Readonly<{ + key: string + startMs: number + endMs: number + label: string + /** The tooltip and accessible name: who, and from when to when. */ + title: string + tone: TimelineBarTone + onClick?: M +}> + +export type TimelineRow = Readonly<{ + key: string + label: string + detail?: string + bars: ReadonlyArray> + /** What an empty row says, for a row with no bars in the range. */ + emptyText?: string +}> + +/** One wall-clock day of the axis, as a share of the range. */ +export type TimelineDay = Readonly<{ + startMs: number + endMs: number + weekday: string + dayOfMonth: number + leftPct: number + widthPct: number +}> + +const weekdayFormatters = new Map() +const weekdayOf = (ms: number, timeZone: string): string => { + const cached = weekdayFormatters.get(timeZone) + const formatter = cached ?? new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }) + if (cached === undefined) weekdayFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + +/** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ +export const zonedDayStart = (ms: number, timeZone: string, offsetDays = 0): number => { + const parts = zonedDateParts(ms, timeZone) + return zonedPartsToEpochMs( + { ...parts, day: parts.day + offsetDays, hour: 0, minute: 0, second: 0 }, + timeZone, + ) +} + +/** Where `[startMs, endMs)` sits in the range, clipped to it; none when it falls outside. */ +export const placeBar = ( + fromMs: number, + toMs: number, + startMs: number, + endMs: number, +): Option.Option> => { + const span = toMs - fromMs + const start = Math.max(startMs, fromMs) + const end = Math.min(endMs, toMs) + return span <= 0 || end <= start + ? Option.none() + : Option.some({ leftPct: ((start - fromMs) / span) * 100, widthPct: ((end - start) / span) * 100 }) +} + +/** The wall-clock days the range covers in `timeZone`, each clipped to the range. */ +export const timelineDays = (fromMs: number, toMs: number, timeZone: string): ReadonlyArray => { + const count = Math.ceil((toMs - fromMs) / 82_800_000) + 1 + return Arr.flatMap(Arr.range(0, count), (offset): ReadonlyArray => { + const startMs = zonedDayStart(fromMs, timeZone, offset) + const endMs = zonedDayStart(fromMs, timeZone, offset + 1) + return Option.match(placeBar(fromMs, toMs, startMs, endMs), { + onNone: () => [], + onSome: (place) => [ + { + startMs, + endMs, + weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), + dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, + ...place, + }, + ], + }) + }) +} + +const BAR_TONE = { + neutral: "bg-surface-strong border-border text-foreground", + primary: toneBoxClass("primary"), + // An override is the one bar that must stand out, even 8px wide on a dark surface: a stronger + // wash and the tone's own edge. + info: "bg-severity-info-a3 border-severity-info text-severity-info", +} as const satisfies Record + +/** The narrowest a bar draws, so a short shift stays visible and clickable. */ +const MIN_BAR_PX = 8 + +const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" + +const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" + +/** How far a bar may grow to reach its floor: up to the next bar's start in the lane, or the range's end. */ +export const barRoomPct = ( + fromMs: number, + toMs: number, + bars: ReadonlyArray>, + bar: TimelineBar, +) => { + const starts = Arr.filterMap(bars, (other) => + other.startMs > bar.startMs + ? Option.match(placeBar(fromMs, toMs, other.startMs, other.endMs), { + onNone: () => Result.failVoid, + onSome: ({ leftPct }) => Result.succeed(leftPct), + }) + : Result.failVoid, + ) + return Arr.reduce(starts, 100, (room, start) => Math.min(room, start)) +} + +const barView = ( + h: HtmlBuilder, + fromMs: number, + toMs: number, + bar: TimelineBar, + limitPct: number, +): ReadonlyArray => + Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { + onNone: () => [], + onSome: ({ leftPct, widthPct }) => { + // A 2px gutter keeps back-to-back shifts apart; a floor keeps an hour-long one hittable, but + // never reaches past the next bar's start. + const style = h.Style({ + left: `${leftPct}%`, + width: `calc(${widthPct}% - 2px)`, + "min-width": `min(${MIN_BAR_PX}px, calc(${limitPct - leftPct}% - 2px))`, + }) + const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ + bar.label, + ]) + const attributes = [h.Key(bar.key), style, ...tooltipTrigger(h, bar.title)] + return [ + bar.onClick === undefined + ? h.div( + [ + ...attributes, + h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone], FOCUS_CLASS)), + // Read-only bars still reach the keyboard and a screen reader, named by their tooltip. + h.Role("img"), + h.Tabindex(0), + h.AriaLabel(bar.title), + ], + [text], + ) + : pressable( + h, + { + onClick: bar.onClick, + label: bar.title, + className: cn( + BAR_CLASS, + BAR_TONE[bar.tone], + "hover:border-ring active:border-ring", + ), + attributes, + }, + [text], + ), + ] + }, + }) + +const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 sm:w-40" + +/** + * The timeline: a header of days, then one row per lane (label column, bars track), ruled by day, + * with a now line when `nowMs` is inside the range. Scrolls sideways under a phone's width. + */ +export const shiftTimeline = ( + h: HtmlBuilder, + options: Readonly<{ + fromMs: number + toMs: number + timeZone: string + nowMs: number + rows: ReadonlyArray> + ariaLabel: string + /** Read out in place of the now line, which is drawn only ("Now, Tue 10 Nov, 11:30"). */ + nowLabel?: string + /** A key under the timeline for the bar tones it uses. */ + legend?: ReadonlyArray> + }>, +): Html => { + const { fromMs, toMs } = options + const days = timelineDays(fromMs, toMs, options.timeZone) + const now = placeBar(fromMs, toMs, options.nowMs, options.nowMs + 1) + const rules = h.div( + [h.AriaHidden(true), h.Class("pointer-events-none absolute inset-0")], + Arr.map(days, (day) => + h.div( + [ + h.Key(`rule-${day.startMs}`), + h.Class("absolute inset-y-0 border-l border-border-subtle"), + h.Style({ left: `${day.leftPct}%` }), + ], + [], + ), + ), + ) + const nowLine = Option.match(now, { + onNone: () => [], + onSome: ({ leftPct }) => [ + h.div( + [ + h.Key("now"), + h.DataAttribute("slot", "timeline-now"), + h.AriaHidden(true), + h.Class("pointer-events-none absolute inset-y-0 w-px bg-primary"), + h.Style({ left: `${leftPct}%` }), + ], + [], + ), + ], + }) + const header = h.div( + [h.Class("flex border-b"), h.Role("row")], + [ + h.div([h.Class(LABEL_COLUMN), h.Role("columnheader")], [h.span([h.Class("sr-only")], ["Lane"])]), + // One header for the track, as each row has one cell for it; the days label it visually. + h.div( + [h.Class("relative h-8 grow"), h.Role("columnheader"), h.AriaLabel("Shifts")], + Arr.map(days, (day) => + h.div( + [ + h.Key(`day-${day.startMs}`), + h.Class( + "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", + ), + h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), + ], + [ + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + truncate: true, + className: "block", + }, + [`${day.weekday} ${day.dayOfMonth}`], + ), + ], + ), + ), + ), + ], + ) + const rowView = (row: TimelineRow): Html => + h.div( + [h.Key(row.key), h.Class("flex border-b last:border-b-0"), h.Role("row")], + [ + vstack( + h, + { + gap: "0", + justify: "center", + className: cn(LABEL_COLUMN, "py-1.5"), + attributes: [h.Role("rowheader")], + }, + [ + typography(h, { size: "xs", weight: "medium", truncate: true }, [row.label]), + ...(row.detail === undefined + ? [] + : [typography(h, { size: "2xs", tone: "muted", truncate: true }, [row.detail])]), + ], + ), + h.div( + [h.Class("relative h-8 grow"), h.Role("cell")], + // Bars wholly outside the range draw nothing, so they count as none. + row.emptyText !== undefined && + !Arr.some(row.bars, (bar) => + Option.isSome(placeBar(fromMs, toMs, bar.startMs, bar.endMs)), + ) + ? [ + typography( + h, + { size: "xs", tone: "faint", className: "block px-2 leading-8" }, + [row.emptyText], + ), + ] + : Arr.flatMap(row.bars, (bar) => + barView(h, fromMs, toMs, bar, barRoomPct(fromMs, toMs, row.bars, bar)), + ), + ), + ], + ) + const legend = Arr.map(options.legend ?? [], (entry) => + hstack(h, { gap: "1.5", as: "span", attributes: [h.Key(entry.label)] }, [ + h.span([h.AriaHidden(true), h.Class(cn("size-3 rounded-xs border", BAR_TONE[entry.tone]))], []), + typography(h, { size: "xs", tone: "muted" }, [entry.label]), + ]), + ) + const frame = h.div( + [h.Class("overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], + [ + h.div( + [h.Class("relative min-w-[720px]"), h.Role("table"), h.AriaLabel(options.ariaLabel)], + [ + ...(options.nowLabel === undefined || Option.isNone(now) + ? [] + : [h.p([h.Class("sr-only")], [options.nowLabel])]), + header, + h.div( + [h.Class("relative")], + [ + h.div( + [ + h.Class( + "pointer-events-none absolute inset-y-0 right-0 left-32 sm:left-40", + ), + ], + [rules, ...nowLine], + ), + ...Arr.map(options.rows, rowView), + ], + ), + ], + ), + ], + ) + return legend.length === 0 + ? frame + : vstack(h, { gap: "2" }, [frame, hstack(h, { gap: "4", wrap: true }, legend)]) +} diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 999f2425f1..056dce9482 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -1,6 +1,6 @@ import { AlertDeliveryEventDocument, AlertIncidentDocument } from "@maple/domain/http" import { V2AlertIncidentEvent } from "@maple/domain/http/v2" -import { Option, Schema, Array as Arr } from "effect" +import { Option, Schema, Array as Arr, String as Str } from "effect" import { describe, expect, it } from "vitest" import { buildIncidentTimeline } from "./incident-timeline" @@ -272,8 +272,8 @@ describe("buildIncidentTimeline", () => { ) expect(Arr.map(entries.slice(1, 4), (entry) => [entry.title, entry.tone])).toEqual([ ["Paged Ben Brook by email", "info"], - ["Could not page Ben Brook by slack DM", "warn"], - ["Did not page Ben Brook by email", "neutral"], + ["Couldn't page Ben Brook by Slack DM", "crit"], + ["Skipped paging Ben Brook by email", "neutral"], ]) expect(entries[2]?.detail).toBe("The chat account could not be reached") }) @@ -317,3 +317,63 @@ describe("buildIncidentTimeline", () => { ]) }) }) + +describe("paged events", () => { + const paged = (id: string, at: string, page: Record) => + makeActivity({ id, type: "paged", actor: null, source: null, created_at: at, page }) + + it("read who was paged on which channel, in order, with a failure's reason", () => { + const entries = build( + makeIncident(), + [], + [ + paged("evt_tC4d9V79DCDzgbGKhAnffC", "2026-10-15T14:20:00.000Z", { + user_id: "user_ben", + name: "Ben Brook", + channel: "email", + status: "failed", + error_message: "Mailbox full", + }), + paged("evt_tC4d9V79DCDzgbGKhAnffD", "2026-10-15T14:15:00.000Z", { + user_id: "user_ben", + name: "Ben Brook", + channel: "slack", + status: "sent", + error_message: null, + }), + paged("evt_tC4d9V79DCDzgbGKhAnffE", "2026-10-15T14:21:00.000Z", { + user_id: "user_gone", + name: null, + channel: "discord", + status: "skipped", + error_message: null, + }), + ], + ) + const pages = Arr.filter(entries, (entry) => Str.startsWith("page:")(entry.key)) + expect(Arr.map(pages, (entry) => [entry.title, entry.detail, entry.tone])).toEqual([ + ["Paged Ben Brook by Slack DM", null, "info"], + ["Couldn't page Ben Brook by email", "Mailbox full", "crit"], + ["Skipped paging a member by Discord DM", "Acknowledged or resolved first", "neutral"], + ]) + }) + + it("a paged event without its page reads as a page to a member, inventing no channel or outcome", () => { + const entries = build( + makeIncident(), + [], + [ + makeActivity({ + id: "evt_tC4d9V79DCDzgbGKhAnffF", + type: "paged", + actor: null, + source: null, + created_at: "2026-10-15T14:20:00.000Z", + }), + ], + ) + expect(Arr.filter(entries, (entry) => Str.startsWith("page:")(entry.key))).toMatchObject([ + { title: "Paged a member", detail: null, tone: "neutral" }, + ]) + }) +}) diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 47fbc5a5cb..88fd02a1a6 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -93,25 +93,18 @@ function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { title: "Acknowledgement cleared", detail: "The incident escalated, so it notified again", } - case "paged": { - const page = event.page - const who = page?.name ?? "a member" - const channel = - page === undefined ? "" : page.channel === "email" ? " by email" : ` by ${page.channel} DM` - // The title says what happened to the page, not only that one was attempted. - const outcome = - page?.status === "failed" - ? { tone: "warn" as const, title: `Could not page ${who}${channel}`, pending: false } - : page?.status === "skipped" - ? { tone: "neutral" as const, title: `Did not page ${who}${channel}`, pending: false } - : page?.status === "queued" || page?.status === "sending" - ? { tone: "info" as const, title: `Paging ${who}${channel}`, pending: true } - : { tone: "info" as const, title: `Paged ${who}${channel}`, pending: false } - const error = page?.error_message ?? null - // A pending page with an error is between retries. - const detail = outcome.pending && error !== null ? `Retrying: ${error}` : error - return { ...base, ...outcome, detail } - } + case "paged": + // Without its page the event says only that someone was paged: no channel or outcome invented. + return event.page === undefined + ? { ...base, key: `page:${event.id}`, tone: "neutral", title: "Paged a member", detail: null } + : pageEntry({ + id: event.id, + at: event.created_at, + name: event.page.name, + channel: event.page.channel, + status: event.page.status, + errorMessage: event.page.error_message, + }) } } @@ -213,6 +206,63 @@ function deliveryEntry( } } +/** A `paged` incident event: an escalation step paged one member on one channel. */ +export interface IncidentPage { + readonly id: string + /** ISO timestamp. */ + readonly at: string + readonly name: string | null + readonly channel: string + readonly status: "queued" | "sending" | "sent" | "failed" | "skipped" + readonly errorMessage: string | null +} + +/** "email", "Slack DM", "Discord DM". */ +export function pageChannelLabel(channel: string): string { + switch (channel) { + case "email": + return "email" + case "slack": + return "Slack DM" + case "discord": + return "Discord DM" + default: + return `${channel} DM` + } +} + +/** "Paged Ben Brook by Slack DM"; a failed or skipped page says so and why. */ +export function pageEntry(page: IncidentPage): IncidentTimelineEntry { + // The directory could not name them: that is not proof they left. + const who = page.name ?? "a member" + const by = pageChannelLabel(page.channel) + const sending = page.status === "queued" || page.status === "sending" + const title = + page.status === "failed" + ? `Couldn't page ${who} by ${by}` + : page.status === "skipped" + ? `Skipped paging ${who} by ${by}` + : sending + ? `Paging ${who} by ${by}` + : `Paged ${who} by ${by}` + return { + key: `page:${page.id}`, + at: Date.parse(page.at), + tone: page.status === "failed" ? "crit" : page.status === "skipped" || sending ? "neutral" : "info", + pending: sending, + title, + detail: + page.status === "failed" || page.status === "skipped" + ? (page.errorMessage ?? + (page.status === "skipped" ? "Acknowledged or resolved first" : "The channel refused it")) + : // A pending page with an error is between retries. + sending && page.errorMessage !== null + ? `Retrying: ${page.errorMessage}` + : null, + investigation: null, + } +} + function investigationEntries(investigation: V2Investigation): ReadonlyArray { const started: IncidentTimelineEntry = { key: `investigation:${investigation.id}:started`,