From 2bcbbf01a7a244149ef650a9861b4dbeadf1c764 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:08:03 +0100 Subject: [PATCH 01/35] feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill - Kit: rightSheet over the Dialog Submodel (672px form size, scrolling body, sticky footer, slide dropped under reduced motion) with followUrl/isDismissed; page/kit/sheet-param holds the ?sheet= helpers (push to open, replace to close). - Sidebar group labels fold their rows (sidebarGroupToggle, aria-expanded), saved to localStorage; the group holding the current page and the collapsed rail always show every row. - Footer on-call pill from the schedules list's on_call_now and the session user: on call until the handoff, not on call, or hidden without schedules. Re-read at each handoff; a dot with a tooltip on the rail. - /lab/shell shows all three, with a demo form in the sheet. --- apps/web/src/lab/app-shell.ts | 52 +--- apps/web/src/lab/entries/shell.ts | 37 +++ apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/shell-frame.ts | 56 ++++ .../src/lab/shell-states-lab.scene.test.ts | 144 ++++++++++ apps/web/src/lab/shell-states-lab.ts | 261 ++++++++++++++++++ apps/web/src/page/kit/sheet-param.ts | 33 +++ apps/web/src/shell/command.ts | 17 +- apps/web/src/shell/data.ts | 28 ++ apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 2 + apps/web/src/shell/message.ts | 8 + apps/web/src/shell/model.ts | 9 + apps/web/src/shell/nav-groups.scene.test.ts | 95 +++++++ apps/web/src/shell/oncall.scene.test.ts | 167 +++++++++++ apps/web/src/shell/oncall.ts | 74 +++++ apps/web/src/shell/shell.scene.test.ts | 5 +- apps/web/src/shell/subscription.ts | 19 +- apps/web/src/shell/update.ts | 42 ++- apps/web/src/shell/view-nav.ts | 57 +++- apps/web/src/shell/view-sidebar.ts | 83 ++++++ apps/web/src/shell/view.ts | 29 +- apps/web/src/subscription.ts | 15 + packages/ui/src/components/ui/sheet.styles.ts | 5 +- packages/views/src/ui/INDEX.md | 5 +- packages/views/src/ui/identity-label.ts | 13 +- packages/views/src/ui/sheet.ts | 76 +++++ packages/views/src/ui/sidebar.ts | 51 +++- 28 files changed, 1315 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/lab/entries/shell.ts create mode 100644 apps/web/src/lab/shell-frame.ts create mode 100644 apps/web/src/lab/shell-states-lab.scene.test.ts create mode 100644 apps/web/src/lab/shell-states-lab.ts create mode 100644 apps/web/src/page/kit/sheet-param.ts create mode 100644 apps/web/src/shell/nav-groups.scene.test.ts create mode 100644 apps/web/src/shell/oncall.scene.test.ts create mode 100644 apps/web/src/shell/oncall.ts diff --git a/apps/web/src/lab/app-shell.ts b/apps/web/src/lab/app-shell.ts index aa7895bbe5..a3d1d53d06 100644 --- a/apps/web/src/lab/app-shell.ts +++ b/apps/web/src/lab/app-shell.ts @@ -1,10 +1,11 @@ import { Effect, Option, Schema, Stream, String as Str } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" +import { Subscription, type Update } from "foldkit" import { readSessionToken } from "../platform/auth-api" import type { Resources } from "../platform/resources" import * as Shell from "../shell" +export { foldLabShell, labShellView } from "./shell-frame" + // Shell lab pages render inside the real shell: the same Shell Submodel the // app root keeps, booted for the session's org, with the frame around the page and its overlays. @@ -14,6 +15,7 @@ export const LabShellFlags = Schema.Struct({ theme: Shell.Theme, pinnedNamespaces: Schema.Record(Schema.String, Schema.String), dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), }), maybeOrgId: Schema.Option(Schema.String), }) @@ -55,52 +57,6 @@ export const initLabShell = ( }) } -/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ -export const foldLabShell = ( - toParentMessage: (message: Shell.Message) => Message, -) => - Update.foldChild({ - update: Shell.update, - read: (model: Model) => Option.some(model.shell), - write: (model: Model, shell: Shell.Model): Model => ({ ...model, shell }), - toParentMessage, - foldOutMessage: () => (model: Model) => ({ model }), - }) - -/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ -export const labShellView = ( - h: HtmlBuilder, - shell: Shell.Model, - toParentMessage: (message: Shell.Message) => Message, - frame: Readonly<{ - currentPath: string - breadcrumbs: ReadonlyArray - page: () => Html - }>, -): ReadonlyArray => [ - h.submodel({ - slotId: "shell-frame", - model: shell, - view: Shell.frameView, - toParentMessage, - viewInputs: { ...frame, topbarActions: () => null }, - }), - h.submodel({ - slotId: "shell-sheet", - model: shell, - view: Shell.sheetLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), - h.submodel({ - slotId: "shell-menu", - model: shell, - view: Shell.menuLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), -] - /** The root's shell Subscriptions: `useIsMobile`, the sidebar shortcut and the Pinned group's shape. */ export const labShellSubscriptions = ( toParentMessage: (message: Shell.Message) => Message, diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts new file mode 100644 index 0000000000..8af4756284 --- /dev/null +++ b/apps/web/src/lab/entries/shell.ts @@ -0,0 +1,37 @@ +import { Effect, Stream } from "effect" +import { Dom, Subscription } from "foldkit" +import type { Resources } from "../../platform/resources" +import * as Shell from "../../shell" +import { labShellSubscriptions } from "../app-shell" +import { defineLabWithFlags } from "../define" +import { Flags, init, Message, Model, toShell, update, view } from "../shell-states-lab" + +// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held right sheet. + +const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window + +const currentHref = () => Message.ChangedUrl({ href: `${window.location.pathname}${window.location.search}` }) + +/** Back, Forward and the lab's own pushes and replaces. */ +const urlChanges: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: urlTarget, type: "popstate", mapEvent: currentHref }), + Dom.streamFromEvent({ target: urlTarget, type: "foldkit:urlchange", mapEvent: currentHref }), +) + +export const lab = defineLabWithFlags("Shell states", { + Model, + Flags, + flags: Effect.all({ + shell: Shell.readShellFlags, + href: Effect.sync(() => `${window.location.pathname}${window.location.search}`), + }), + init, + update, + view, + subscriptions: Subscription.aggregate()( + labShellSubscriptions(toShell), + Subscription.make()((entry) => ({ + url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }), + })), + ), +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..8ec40d1192 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -491,6 +491,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/shell", + title: "Shell states", + description: + "The real sidebar with foldable groups and the on-call pill's three states, and a right sheet held in ?sheet= with a demo form.", + kind: "lab", + session: "none", + }, { path: "/lab/query-builder", title: "Query builder", diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts new file mode 100644 index 0000000000..dd99c21860 --- /dev/null +++ b/apps/web/src/lab/shell-frame.ts @@ -0,0 +1,56 @@ +import { Option } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message as ShellMessage } from "../shell/message" +import type { Model as ShellModel } from "../shell/model" +import { update as shellUpdate } from "../shell/update" +import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" + +// The shell frame a lab page renders in, kept off the shell barrel (its streams read `window` on +// import) so a lab's own logic can run in a scene test. + +/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ +export const foldLabShell = ( + toParentMessage: (message: ShellMessage) => Message, +) => + Update.foldChild({ + update: shellUpdate, + read: (model: Model) => Option.some(model.shell), + write: (model: Model, shell: ShellModel): Model => ({ ...model, shell }), + toParentMessage, + foldOutMessage: () => (model: Model) => ({ model }), + }) + +/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ +export const labShellView = ( + h: HtmlBuilder, + shell: ShellModel, + toParentMessage: (message: ShellMessage) => Message, + frame: Readonly<{ + currentPath: string + breadcrumbs: ReadonlyArray + page: () => Html + }>, +): ReadonlyArray => [ + h.submodel({ + slotId: "shell-frame", + model: shell, + view: frameView, + toParentMessage, + viewInputs: { ...frame, topbarActions: () => null }, + }), + h.submodel({ + slotId: "shell-sheet", + model: shell, + view: sheetLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), + h.submodel({ + slotId: "shell-menu", + model: shell, + view: menuLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), +] diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts new file mode 100644 index 0000000000..56a7b6324c --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -0,0 +1,144 @@ +import * as Animation from "@foldkit/ui/animation" +import { + Command, + click, + expect, + given, + keydown, + Mount, + role, + scene, + selector, + Subscription, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Dialog from "@maple/views/ui/dialog" +import { + closeSheetNavigation, + openSheetNavigation, + sheetParamOf, + withSheetParam, +} from "../page/kit/sheet-param" +import { Array as Arr, Option } from "effect" +import { MeasureSidebarOverflow } from "../shell/mounts" +import { Message as ShellMessage } from "../shell/message" +import { + init, + Message, + type Model, + PushSheetUrl, + ReplaceSheetUrl, + SHEET_ID, + update, + view, +} from "./shell-states-lab" + +// The right sheet held in `?sheet=`: the URL opens it, Escape and the backdrop clear it, Back closes it. + +const shellFlags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const at = (href: string): Model => init({ shell: shellFlags, href }).model + +const dialog = role("dialog", { name: "New alert rule" }) +const portal = selector("[data-base-ui-portal]") + +const measured = Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()) + +/** The portal Mount and the enter transition (or none, for a sheet that boots open). */ +const mounted = Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()) + +const enter = (generation: number) => [ + mounted, + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +const leave = (generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Dialog.WaitForDialogTransition({ id: SHEET_ID, generation }), + Dialog.Message.GotAnimationMessage({ message: Animation.Message.EndedAnimation({ generation }) }), + ), + Mount.expectEnded(Dialog.MountDialog), + expect(dialog).toBeAbsent(), +] + +describe("URL-held right sheet", () => { + test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + scene( + { update, view }, + given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + expect(dialog).toContainText("Error rate above (%)"), + expect(role("button", { name: "Create rule" })).toExist(), + keydown(portal, "Escape"), + Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), + Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + // Booting open counted the show as generation 1. + ...leave(2), + ) + }) + + test("the page's button pushes ?sheet=, the URL change opens it, and Back closes it", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + expect(dialog).toBeAbsent(), + click(role("button", { name: "New alert rule" })), + Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.resolve(PushSheetUrl, Message.CompletedNavigate()), + expect(dialog).toBeAbsent(), + Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), + ...enter(1), + expect(dialog).toHaveAttr("data-open", ""), + Subscription.emit(Message.ChangedUrl({ href: "/lab/shell" })), + ...leave(2), + Command.expectNone(), + ) + }) + + test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => { + const open = at(`/lab/shell?sheet=${SHEET_ID}`) + const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) + expectVitest(back.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( + ReplaceSheetUrl.name, + ) + }) + + test("Cancel in the footer closes it and clears the param", () => { + const closed = update( + at(`/lab/shell?sheet=${SHEET_ID}`), + Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), + ) + expectVitest(closed.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( + ReplaceSheetUrl.name, + ) + }) +}) + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("opening pushes a history entry; dismissing replaces it", () => { + expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ + url: "/alerts?sheet=new-rule", + isReplace: false, + }) + expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ + url: "/alerts", + isReplace: true, + }) + }) +}) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts new file mode 100644 index 0000000000..478ea5404f --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.ts @@ -0,0 +1,261 @@ +import { Effect, Option, Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { pushUrl, replaceUrl } from "foldkit/navigation" +import { button } from "@maple/views/ui/button" +import * as Dialog from "@maple/views/ui/dialog" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { Resources } from "../platform/resources" +import { dashboardPage } from "../page/kit/dashboard-page" +import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { Message as ShellMessage, Theme } from "../shell/message" +import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import type { OnCallSnapshot } from "../shell/oncall" +import { foldLabShell, labShellView } from "./shell-frame" + +// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the +// right sheet held in `?sheet=` (Back closes it, the link reopens it). + +export const SHEET_ID = "new-alert-rule" +const LAB_PATH = "/lab/shell" + +export const OnCallFixture = Schema.Literals(["onCall", "notOnCall", "none"]) +export type OnCallFixture = typeof OnCallFixture.Type + +export const StandOn = Schema.Literals(["lab", "alerts"]) +export type StandOn = typeof StandOn.Type + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) + +const LAB_USER = "user_lab" + +export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ + userId: LAB_USER, + shifts: + fixture === "none" + ? [] + : [ + { + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: fixture === "onCall" ? LAB_USER : "user_other", + endsAtMs: HANDOFF_MS, + }, + ], +}) + +export const Flags = Schema.Struct({ + shell: Schema.Struct({ + isMobile: Schema.Boolean, + theme: Theme, + pinnedNamespaces: Schema.Record(Schema.String, Schema.String), + dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), + }), + href: Schema.String, +}) +export type Flags = typeof Flags.Type + +export const Model = Schema.Struct({ + shell: ShellModel, + sheet: Dialog.Model, + href: Schema.String, + onCall: OnCallFixture, + standOn: StandOn, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotShellMessage: { message: ShellMessage }, + GotSheetMessage: { message: Dialog.Message }, + /** The URL changed (a push, a replace, Back or Forward). */ + ChangedUrl: { href: Schema.String }, + ClickedNewRule: {}, + ClickedCreateRule: {}, + ClickedOnCallFixture: { fixture: OnCallFixture }, + ClickedStandOn: { standOn: StandOn }, + CompletedNavigate: {}, +}) +export type Message = typeof Message.Type + +/** Opening the sheet: a new history entry carrying `?sheet=`. */ +export const PushSheetUrl = Command.define("PushSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** Dismissing it: the param leaves in place. */ +export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +type Return = Update.Return + +export const toShell = (message: ShellMessage): Message => Message.GotShellMessage({ message }) +const toSheet = (message: Dialog.Message): Message => Message.GotSheetMessage({ message }) +const foldShell = foldLabShell(toShell) + +const isSheetInUrl = (href: string): boolean => Option.contains(sheetParamOf(href), SHEET_ID) + +const sheetStep = (model: Model, step: SheetStep): Return => ({ + model: { ...model, sheet: step.model }, + commands: [ + ...mapSheetCommands(step, toSheet), + ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ], +}) + +const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ + ...shell, + onCall: OnCallData.Success({ data: onCallSnapshotOf(fixture) }), +}) + +/** No org: the shell sends nothing, so the fixtures are all it shows. */ +export const init = (flags: Flags): Return => { + const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), isSheetInUrl(flags.href)) + return { + model: { + shell: withOnCall(initShell(flags.shell), "onCall"), + sheet: sheet.model, + href: flags.href, + onCall: "onCall", + standOn: "lab", + }, + commands: mapSheetCommands(sheet, toSheet), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotShellMessage: ({ message: inner }) => foldShell(model, inner), + GotSheetMessage: ({ message: inner }) => sheetStep(model, Dialog.update(model.sheet, inner)), + ChangedUrl: ({ href }) => { + const step = followUrl(model.sheet, isSheetInUrl(href)) + return { model: { ...model, href, sheet: step.model }, commands: mapSheetCommands(step, toSheet) } + }, + ClickedNewRule: () => ({ + model, + commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + }), + ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), + ClickedOnCallFixture: ({ fixture }) => ({ + model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, + }), + ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + CompletedNavigate: () => ({ model }), + }) + +// VIEW + +const control = (h: HtmlBuilder, title: string, body: Html): Html => + vstack(h, { gap: "2" }, [typography(h, { size: "sm", weight: "medium" }, [title]), body]) + +const field = (h: HtmlBuilder, id: string, text: string, control: Html): Html => + vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) + +const sheet = (h: HtmlBuilder, model: Model): Html => + rightSheet(h, { + sheet: model.sheet, + title: "New alert rule", + description: "Page the on-call person when a service's error rate crosses a threshold.", + toParentMessage: toSheet, + body: [ + vstack(h, { gap: "5" }, [ + field( + h, + "rule-name", + "Name", + input(h, { id: "rule-name", attributes: [h.Placeholder("Checkout error rate")] }), + ), + field( + h, + "rule-service", + "Service", + input(h, { id: "rule-service", attributes: [h.Placeholder("checkout-svc")] }), + ), + field( + h, + "rule-threshold", + "Error rate above (%)", + input(h, { id: "rule-threshold", attributes: [h.Value("5")] }), + ), + field( + h, + "rule-notes", + "Notes for the responder", + textarea(h, { id: "rule-notes", attributes: [h.Placeholder("What to check first")] }), + ), + ]), + ], + footer: [ + button( + h, + { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, + ["Cancel"], + ), + button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), + ], + }) + +const page = (h: HtmlBuilder, model: Model): Html => + dashboardPage( + h, + { + title: "Shell states", + description: "Fold a sidebar group, switch the on-call fixture, or open the sheet.", + actions: button(h, { attributes: [h.OnClick(Message.ClickedNewRule())] }, ["New alert rule"]), + }, + [ + vstack(h, { gap: "6" }, [ + control( + h, + "On-call pill", + segmentedSelect(h, { + ariaLabel: "On-call fixture", + value: model.onCall, + onChange: (fixture) => Message.ClickedOnCallFixture({ fixture }), + options: [ + { value: "onCall", label: "On call" }, + { value: "notOnCall", label: "Not on call" }, + { value: "none", label: "No schedules" }, + ], + }), + ), + control( + h, + "Current page", + segmentedSelect(h, { + ariaLabel: "Current page", + value: model.standOn, + onChange: (standOn) => Message.ClickedStandOn({ standOn }), + options: [ + { value: "lab", label: "Lab (no group)" }, + { value: "alerts", label: "Alerts (Triage)" }, + ], + }), + ), + ]), + sheet(h, model), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + labShellView(h, model.shell, toShell, { + currentPath: model.standOn === "alerts" ? "/alerts" : LAB_PATH, + breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], + page: () => page(h, model), + }), + ) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts new file mode 100644 index 0000000000..68d05b7c5d --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.ts @@ -0,0 +1,33 @@ +import { Option, Schema } from "effect" +import { PageOutMessage } from "../contract" + +/** + * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the + * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + */ +export const SHEET_PARAM = "sheet" + +/** Spread into a route's search schema. */ +export const SheetSearchFields = { [SHEET_PARAM]: Schema.optional(Schema.String) } + +const BASE = "http://maple.local" + +/** `href` with `?sheet=` set to `id`, or removed when `id` is undefined; other params stay. */ +export const withSheetParam = (href: string, id: string | undefined): string => { + const url = new URL(href, BASE) + if (id === undefined) url.searchParams.delete(SHEET_PARAM) + else url.searchParams.set(SHEET_PARAM, id) + return `${url.pathname}${url.search}${url.hash}` +} + +/** The open sheet named in `href`, if any. */ +export const sheetParamOf = (href: string): Option.Option => + Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") + +/** A page opens the sheet: a new history entry, so Back closes it. */ +export const openSheetNavigation = (href: string, id: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) + +/** The sheet was dismissed: the param leaves without adding an entry. */ +export const closeSheetNavigation = (href: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ebadbbb555..ee7d602a64 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -9,6 +9,8 @@ import { Message, Theme } from "./message" export const NAMESPACE_STORAGE_PREFIX = "maple.global-namespace." /** `maple.infra-surfaces.`: the org's last settled infra surfaces, JSON. */ export const INFRA_SURFACES_STORAGE_PREFIX = "maple.infra-surfaces." +/** The nav group ids the user folded, JSON. */ +export const COLLAPSED_NAV_GROUPS_KEY = "maple.sidebar.collapsed-groups" /** The theme key the app and the pre-paint script in index.html share. */ export const THEME_STORAGE_KEY = "maple-theme" @@ -21,7 +23,7 @@ export class ShellStorageError extends Schema.TaggedError()( }, ) {} -const encodeSurfaces = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) +const encodeStrings = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) /** Base UI moves DOM focus to the open popup and to the hovered or arrowed item. */ export const FocusElement = Command.define("FocusElement", { @@ -96,11 +98,22 @@ export const SaveInfraSurfaces = Command.define("SaveInfraSurfaces", { messages: [Message.CompletedSaveInfraSurfaces], execute: ({ orgId, surfaces }) => Effect.sync(() => { - writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeSurfaces(surfaces)) + writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeStrings(surfaces)) return Message.CompletedSaveInfraSurfaces() }), }) +/** Remembers which nav groups are folded; storage that throws keeps them for this tab only. */ +export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { + args: { groups: Schema.Array(Schema.String) }, + messages: [Message.CompletedSaveNavGroups], + execute: ({ groups }) => + Effect.sync(() => { + writeLocalStorage(COLLAPSED_NAV_GROUPS_KEY, encodeStrings(groups)) + return Message.CompletedSaveNavGroups() + }), +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index a96756bed9..4e32d22ceb 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -7,6 +7,9 @@ import { MapleInternalApiClient } from "../platform/api-clients" import { loadServicesFacets } from "../data/services-facets-probe" import { loadSources, sourcesListKey } from "../page/sources/kit/data" import { Message } from "./message" +import { SessionTenant } from "../data/session-tenant" +import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" +import { isoMs } from "../page/alerts/shared/time" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -78,3 +81,28 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { return Message.SucceededLoadNamespaces({ orgId, namespaces }) }).pipe(failed(Message.FailedLoadNamespaces({ orgId }))), }) + +// ON CALL + +/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +export const LoadOnCall = Command.define("LoadOnCall", { + args: orgArgs, + messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], + execute: ({ orgId }) => + Effect.gen(function* () { + const tenant = yield* (yield* SessionTenant).get + const schedules = yield* listOnCallSchedules + return Message.SucceededLoadOnCall({ + orgId, + snapshot: { + userId: tenant.userId, + shifts: Arr.map(schedules, (schedule) => ({ + scheduleName: schedule.name, + timeZone: schedule.time_zone, + userId: schedule.on_call_now?.user_id ?? null, + endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), + })), + }, + }) + }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), +}) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index c320341099..644f4a4fbd 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -3,6 +3,7 @@ import { Effect, Option, Schema, Array as Arr, Record as Rec, String as Str } fr import { readQuotaApproachingDismissed } from "./billing-banners" import { readDashboardFavorites } from "./favorites" import { + COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, @@ -61,6 +62,12 @@ const readRememberedSurfaces = readPrefixed( Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(NavSurface))), ) +const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.String))) + +/** The folded nav groups; unreadable or blocked storage folds none. */ +export const readCollapsedNavGroups = (): ReadonlyArray => + Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -71,6 +78,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () theme: readTheme(), pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), + collapsedNavGroups: readCollapsedNavGroups(), rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 9fc3eedb40..78b2bea6ee 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,6 +8,8 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, + onCallHandoff, + onCallHandoffAt, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 4a2c263971..370ae54310 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -12,6 +12,7 @@ import * as ConnectPanel from "./connect-panel" import * as SupportChannel from "./support-channel" import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" +import { OnCallSnapshot } from "./oncall" /** The shell's dropdown menus; a collapsed-rail section menu is keyed by its section title. */ export const Menu = defineTaggedUnion({ @@ -79,6 +80,13 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, + FailedLoadOnCall: { orgId: Schema.String }, + /** A schedule handed off: who is on call may have changed. */ + ElapsedOnCallHandoff: {}, + /** A nav group's label: fold or unfold its rows. */ + ClickedNavGroup: { groupId: Schema.String }, + CompletedSaveNavGroups: {}, ChangedDashboardFavorites: { favorites: Schema.Array(Schema.String) }, SucceededLoadNamespaces: { orgId: Schema.String, namespaces: Schema.Array(Schema.String) }, FailedLoadNamespaces: { orgId: Schema.String }, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 13f1c0df7b..01736502fc 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -16,6 +16,7 @@ import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" import { NavSurface } from "./nav" +import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, PENDING_INTEGRATIONS, @@ -31,6 +32,7 @@ export const InfraPresenceData = AsyncData.Schema(Schema.Array(InfraSurfaceLiter export const DashboardsData = AsyncData.Schema(Schema.Array(PinnedDashboardRow), LoadFailure) export const NamespacesData = AsyncData.Schema(Schema.Array(Schema.String), LoadFailure) export const SourcesData = AsyncData.Schema(Schema.Array(V2Source), LoadFailure) +export const OnCallData = AsyncData.Schema(OnCallSnapshot, LoadFailure) export const OpenMenu = Schema.Struct({ menu: Menu, maybePosition: Schema.Option(PopupPosition) }) export type OpenMenu = typeof OpenMenu.Type @@ -76,6 +78,10 @@ export const Model = Schema.Struct({ /** `maple.infra-surfaces.`: each org's last settled surfaces, so the sidebar lays out once. */ rememberedSurfaces: Schema.Record(Schema.String, Schema.Array(NavSurface)), dashboards: DashboardsData.schema, + /** The org's schedules and who holds each now, for the footer's on-call pill. */ + onCall: OnCallData.schema, + /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ + collapsedNavGroups: Schema.Array(Schema.String), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -133,6 +139,7 @@ export type ShellFlags = Readonly<{ theme: Theme pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray + collapsedNavGroups?: ReadonlyArray rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -157,6 +164,8 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + onCall: OnCallData.Idle(), + collapsedNavGroups: flags.collapsedNavGroups ?? [], namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts new file mode 100644 index 0000000000..405111c4e6 --- /dev/null +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -0,0 +1,95 @@ +import { Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import { SaveCollapsedNavGroups } from "./command" +import { Message } from "./message" +import { init, type Model } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { update } from "./update" +import { frameView } from "./view" + +// The sidebar's group labels fold their rows, remembered across reloads. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const signedIn = (overrides: Partial = {}): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + ...overrides, +}) + +const shellAt = (currentPath: string) => { + const frame = withViewInputs(frameView, { + currentPath, + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +} + +const analyze = role("button", { name: "Analyze" }) +const dashboards = role("link", { name: "Dashboards" }) +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + +describe("sidebar groups", () => { + test("a group label folds its rows, saves the folded set, and unfolds again", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn()), + measured, + expect(analyze).toHaveAttr("aria-expanded", "true"), + expect(dashboards).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: ["analyze"] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(analyze).toHaveAttr("aria-expanded", "false"), + expect(dashboards).toBeAbsent(), + expect(role("link", { name: "Errors" })).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: [] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(dashboards).toExist(), + ) + }) + + test("a folded group read from storage starts folded", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "false"), + expect(role("link", { name: "Alerts" })).toBeAbsent(), + ) + }) + + test("the group holding the current page stays open and cannot fold", () => { + scene( + { update, view: shellAt("/alerts") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), + expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Alerts" })).toExist(), + ) + }) + + test("the collapsed rail shows every row of a folded group", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), + measured, + expect(dashboards).toExist(), + ) + }) + + test("folding persists only the groups the user folded", () => { + const folded = update( + update(signedIn(), Message.ClickedNavGroup({ groupId: "monitor" })).model, + Message.ClickedNavGroup({ groupId: "triage" }), + ) + expectVitest(folded.model.collapsedNavGroups).toEqual(["monitor", "triage"]) + }) +}) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts new file mode 100644 index 0000000000..0abc3f0fc8 --- /dev/null +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -0,0 +1,167 @@ +import * as Animation from "@foldkit/ui/animation" +import { Array as Arr, Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { + Command, + expect, + given, + hover, + Mount, + role, + scene, + selector, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Tooltip from "@maple/views/ui/tooltip" +import { LoadOnCall } from "./data" +import { Message } from "./message" +import { init, type Model, OnCallData } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" +import { enter, update } from "./update" +import { frameView } from "./view" + +// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +const HANDOFF = Date.UTC(2026, 10, 16, 8) + +const shift = (overrides: Partial = {}): OnCallShift => ({ + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: "user_me", + endsAtMs: HANDOFF, + ...overrides, +}) + +const withOnCall = (shifts: ReadonlyArray): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), +}) + +const view = (() => { + const frame = withViewInputs(frameView, { + currentPath: "/", + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +})() + +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) +const footer = selector('[data-slot="sidebar-footer"]') + +describe("on-call pill", () => { + test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift()])), + measured, + expect(footer).toContainText("You're on call"), + expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("Checkout primary"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + ) + }) + + test("not on call: says so with a way to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(footer).toContainText("My schedules"), + ) + }) + + test("an org without schedules shows no pill", () => { + scene( + { update, view }, + given(withOnCall([])), + measured, + expect(text("You're not on call")).toBeAbsent(), + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("a failed read shows no pill", () => { + scene( + { update, view }, + given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + measured, + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { + const pill = role("link", { name: /You're on call/ }) + scene( + { update, view }, + given({ ...withOnCall([shift()]), isSidebarOpen: false }), + measured, + expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + hover(pill), + Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), + Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(selector('[data-slot="tooltip-popup"]')).toHaveText( + "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + ), + ) + }) + + test("the sidebar reads the schedules with its other data, for its org only", () => { + const entered = enter("org_parity")(init(flags)) + expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) + const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } + const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) + expectVitest(stale.model.onCall._tag).toBe("Loading") + const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) + expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) + }) + + test("a handoff reads the schedules again", () => { + const loaded = withOnCall([shift()]) + const next = update(loaded, Message.ElapsedOnCallHandoff()) + expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + }) +}) + +describe("onCallStatusOf", () => { + test("the user's soonest-ending shift names the pill, counting the others", () => { + const status = onCallStatusOf({ + userId: "user_me", + shifts: [ + shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), + shift({ scheduleName: "Checkout primary" }), + shift({ scheduleName: "Search", userId: "user_someone" }), + ], + }) + expectVitest(status).toEqual({ + _tag: "OnCall", + scheduleName: "Checkout primary", + otherSchedules: 1, + until: "Mon 16 Nov, 09:00", + }) + }) + + test("the next handoff is the soonest across every schedule; none in a gap", () => { + const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] + expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) + expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + }) +}) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts new file mode 100644 index 0000000000..97099b2239 --- /dev/null +++ b/apps/web/src/shell/oncall.ts @@ -0,0 +1,74 @@ +import { Array as Arr, Option, Order, Schema } from "effect" +import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" + +// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. + +/** One schedule's current shift: who holds it until when, `null` in a gap. */ +export const OnCallShift = Schema.Struct({ + scheduleName: Schema.String, + timeZone: Schema.String, + userId: Schema.NullOr(Schema.String), + endsAtMs: Schema.NullOr(Schema.Number), +}) +export type OnCallShift = typeof OnCallShift.Type + +/** The signed-in user and every schedule of the org. */ +export const OnCallSnapshot = Schema.Struct({ + userId: Schema.String, + shifts: Schema.Array(OnCallShift), +}) +export type OnCallSnapshot = typeof OnCallSnapshot.Type + +export type OnCallStatus = + | Readonly<{ _tag: "NoSchedules" }> + | Readonly<{ _tag: "NotOnCall" }> + | Readonly<{ + _tag: "OnCall" + scheduleName: string + /** Other schedules the user also holds now. */ + otherSchedules: number + until: string + }> + +const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) + +/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ +export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { + if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } + const mine = Arr.sort( + Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + byEnd, + ) + return Option.match(Arr.head(mine), { + onNone: () => ({ _tag: "NotOnCall" }), + onSome: (shift) => ({ + _tag: "OnCall", + scheduleName: shift.scheduleName, + otherSchedules: mine.length - 1, + until: + shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + }), + }) +} + +/** The next handoff on any schedule, when who is on call may change. */ +export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { + const ends = Arr.flatMap(snapshot.shifts, (shift) => (shift.endsAtMs === null ? [] : [shift.endsAtMs])) + return ends.length === 0 ? null : Math.min(...ends) +} + +/** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ +export const scheduleLabel = (status: Extract): string => + status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` + +/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +export const onCallSentence = (status: OnCallStatus): string => { + switch (status._tag) { + case "OnCall": + return `You're on call until ${status.until} (${scheduleLabel(status)})` + case "NotOnCall": + return "You're not on call" + case "NoSchedules": + return "" + } +} diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index bc9e7aade1..65ee2707bb 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -22,7 +22,7 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { FocusElement, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme } from "./command" -import { LoadInfraSurfaces, LoadNamespaces } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall } from "./data" import { type Integration, Menu, Message, OutMessage } from "./message" import { DashboardsData, init, type Model, NamespacesData } from "./model" import { MeasureSidebarOverflow, MountNamespaceSearch, PositionMenu } from "./mounts" @@ -197,7 +197,8 @@ describe("sidebar collapse", () => { given(signedIn({ isMobile: true })), expect(sidebar).toBeAbsent(), ...open(), - Command.expectExact(LoadInfraSurfaces), + Command.expectExact(LoadInfraSurfaces, LoadOnCall), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 480f26a23c..076cfaba32 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,8 +1,11 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" -import { Option, Stream, String as Str } from "effect" +import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" +import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" +import type { Model } from "./model" +import { nextHandoffMs } from "./oncall" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -82,3 +85,17 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ type: DASHBOARD_FAVORITES_EVENT, mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) + +/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ +export const onCallHandoffAt = (model: Model): number | null => + Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) + +/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ +export const onCallHandoff = (atMs: number): Stream.Stream => + Stream.fromEffect( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) + return Message.ElapsedOnCallHandoff() + }), + ) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 66acbbe8eb..b671d58c33 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -11,12 +11,13 @@ import { FocusSiblingMenuItem, FocusShortcutSearch, LogOut, + SaveCollapsedNavGroups, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme, } from "./command" -import { LoadInfraSurfaces, LoadNamespaces, LoadSources } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall, LoadSources } from "./data" import { DismissQuotaBanner, OpenCustomerPortal } from "./billing-banners" import * as ClerkOrgSwitcher from "./clerk-org-switcher" import * as Checklist from "./onboarding-checklist" @@ -34,6 +35,7 @@ import { initRightSheet, type Model, NamespacesData, + OnCallData, shellProbe, SourcesData, } from "./model" @@ -61,6 +63,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + onCall: () => OnCallData.Idle(), namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -84,12 +87,34 @@ export const enter = const requestSidebarData = (model: Model): UpdateReturn => { if (model.hasRequestedSidebarData || Option.isNone(model.maybeOrgId)) return { model } const surfaces = requestInfraSurfaces(model) + const onCall = readOnCall(surfaces.model) return { - model: modifyFields(surfaces.model, { + model: modifyFields(onCall.model, { hasRequestedSidebarData: () => true, dashboards: () => DashboardsData.Loading(), }), - commands: surfaces.commands ?? [], + commands: [...(surfaces.commands ?? []), ...(onCall.commands ?? [])], + } +} + +/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ +const readOnCall = (model: Model): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, onCall]) => ({ + model: modifyFields(model, { onCall: () => onCall }), + commands: [LoadOnCall({ orgId })], + }), + }) + +/** Folds or unfolds a nav group and remembers it. */ +const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { + const groups = Array.contains(model.collapsedNavGroups, groupId) + ? Array.filter(model.collapsedNavGroups, (id) => id !== groupId) + : [...model.collapsedNavGroups, groupId] + return { + model: modifyFields(model, { collapsedNavGroups: () => groups }), + commands: [SaveCollapsedNavGroups({ groups })], } } @@ -461,6 +486,17 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, + SucceededLoadOnCall: ({ orgId, snapshot }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), + ), + FailedLoadOnCall: ({ orgId }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), + ), + ElapsedOnCallHandoff: () => readOnCall(model), + ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), + CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => forOrg(model, orgId, (current) => modifyFields(current, { namespaces: () => NamespacesData.Success({ data: namespaces }) }), diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 77296dc717..215e8d8a29 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -8,6 +8,7 @@ import { sidebarGroup, sidebarGroupContent, sidebarGroupLabel, + sidebarGroupToggle, sidebarMenu, sidebarMenuButton, sidebarMenuItem, @@ -276,6 +277,21 @@ export const pinnedGroup = ( ] } +export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" + +/** + * How a group's rows show: a labelled group folds, except while it holds the current page + * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + */ +export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { + if (group.label === undefined) return "unlabelled" + if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" + if (!model.isSidebarOpen && !model.isMobile) return "expanded" + return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" +} + +const navGroupContentId = (group: NavGroup) => `shell-nav-group-${group.id}` + export const navGroupSections = ( h: HtmlBuilder, model: Model, @@ -283,15 +299,32 @@ export const navGroupSections = ( tip: RailTip = NO_TIP, clerk: ShellClerk | null = null, ): ReadonlyArray => - Arr.map(shellNavGroups(clerk), (group) => - sidebarGroup(h, undefined, [ - ...(group.label === undefined ? [] : [sidebarGroupLabel(h, GROUP_LABEL, [group.label])]), - sidebarGroupContent(h, [ - sidebarMenu( - h, - undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), - ), - ]), - ]), - ) + Arr.map(shellNavGroups(clerk), (group) => { + const fold = navGroupFold(model, group, currentPath) + return sidebarGroup(h, undefined, [ + ...(group.label === undefined + ? [] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: + fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), + sidebarGroupContent( + h, + fold === "collapsed" + ? [] + : [ + sidebarMenu( + h, + undefined, + Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + ), + ], + [h.Id(navGroupContentId(group))], + ), + ]) + }) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 1bf4a398ec..324e27aa37 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -25,6 +25,9 @@ import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" +import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -244,6 +247,85 @@ const footerNavRow = ( ), ]) +/** The pill's state; nothing while the schedules read, after a failed read, or with none. */ +export const shellOnCallStatus = (model: Model): OnCallStatus => + Option.match(AsyncData.getData(model.onCall), { + onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), + onSome: onCallStatusOf, + }) + +/** + * "You're on call until …" with the schedule, or "You're not on call" with a way to the schedules; + * a dot with the sentence as its tooltip on the collapsed rail. + */ +const onCallPill = ( + h: HtmlBuilder, + currentPath: string, + status: OnCallStatus, + tip: RailTip, +): ReadonlyArray => + status._tag === "NoSchedules" + ? [] + : [ + sidebarMenu(h, undefined, [ + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + size: "lg", + className: + "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", + link: { href: oncallTabHref(), currentPath }, + hasTooltip: true, + attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + }, + [ + hstack( + h, + { align: "center", justify: "center", className: "size-4 shrink-0" }, + [ + statusDot(h, { + tone: status._tag === "OnCall" ? "ok" : "neutral", + size: "lg", + }), + ], + ), + // Three short lines: the full sentence does not fit the rail's width. + identityLabel( + h, + status._tag === "OnCall" + ? [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're on call"], + ), + typography(h, { size: "xs", truncate: true }, [ + `until ${status.until}`, + ]), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + scheduleLabel(status), + ]), + ] + : [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're not on call"], + ), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + "My schedules", + ]), + ], + // The rail shows the dot; the tooltip says the sentence. + "group-data-[collapsible=icon]:hidden", + ), + ], + ), + ]), + ]), + ] + /** The user avatar: the Clerk image over the initials on a warm tile keyed by the name. */ export const userAvatar = ( h: HtmlBuilder, @@ -338,6 +420,7 @@ export const shellSidebarFooter = ( ], [], ), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index af4ad7e522..171d7dea3f 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -23,7 +23,14 @@ import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" import { navGroupSections, pinnedGroup, shellNavGroups } from "./view-nav" -import { NO_TIP, type RailTip, shellSidebarFooter, shellSidebarHeader } from "./view-sidebar" +import { + NO_TIP, + type RailTip, + shellOnCallStatus, + shellSidebarFooter, + shellSidebarHeader, +} from "./view-sidebar" +import { onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -54,13 +61,17 @@ const sidebarRegions = ( const RAIL_TIP_PREFIX = "shell-rail-tip-" -/** The rows that carry a rail tooltip: Search, the nav rows this org is shown, the footer links. */ -const railTipTitles = (clerk: ShellClerk | null): ReadonlyArray => [ - "Search", - ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), - "Sources", - "Settings", -] +/** The rows that carry a rail tooltip: Search, the nav rows, the on-call pill, the footer links. */ +const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray => { + const onCall = shellOnCallStatus(model) + return [ + "Search", + ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), + ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + "Sources", + "Settings", + ] +} /** The desktop rail; collapsed, its rows share one Tooltip naming the row (`tooltip={title}`). */ const desktopSidebar = ( @@ -97,7 +108,7 @@ const desktopSidebar = ( isInstant, ), toContent: (triggerId) => [Str.slice(RAIL_TIP_PREFIX.length)(triggerId)], - triggerIds: Arr.map(railTipTitles(clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), + triggerIds: Arr.map(railTipTitles(model, clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), }, toParentMessage: (message) => Message.GotRailTooltipMessage({ message }), }) diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 628d8a73d8..655b59a89b 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,6 +215,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The on-call pill reads the schedules again once a shift hands off. */ + shellOnCallHandoff: entry( + { atMs: Schema.NullOr(Schema.Number) }, + { + modelToDependencies: (model) => ({ + atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + }), + dependenciesToStream: ({ atMs }) => + atMs === null + ? Stream.empty + : Stream.map(Shell.onCallHandoff(atMs), (message) => + Message.GotShellMessage({ message }), + ), + }, + ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ shellDashboardFavorites: entry( {}, diff --git a/packages/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index aa1487cabd..c05a45b746 100644 --- a/packages/ui/src/components/ui/sheet.styles.ts +++ b/packages/ui/src/components/ui/sheet.styles.ts @@ -29,12 +29,13 @@ export const sheetPopupRightStyles = "col-start-2 w-[calc(100%-(--spacing(12)))] max-w-md border-s data-ending-style:translate-x-8 data-starting-style:translate-x-8" /** - * A side sheet's width, by what it holds. `default` a form or filter list, `detail` a peek at one - * item, `wide` reading content (a log, a review, a setup guide), `full` a trace waterfall. + * A side sheet's width, by what it holds. `default` a filter list, `detail` a peek at one item, + * `form` a create or edit form, `wide` reading content (a log, a review), `full` a trace waterfall. */ export const sheetSizeStyles = { default: "sm:max-w-md", detail: "sm:max-w-xl", + form: "sm:max-w-2xl", wide: "sm:max-w-3xl", full: "sm:max-w-[min(1100px,calc(100vw-2rem))]", } as const diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..f5dfdb194d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,10 +142,10 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `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/sheet.ts` `leftSheet`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (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/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupToggle`, `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`. - `packages/views/src/ui/skeleton.ts` `skeleton`, `skeletonSlowNote`, `skeletonList`, `skeletonBlock`, `skeletonMeterClass`, `skeletonMeter`, `skeletonSwitchClass`, `skeletonSwitch`, `skeletonLine`: Port of packages/ui/src/components/ui/skeleton.tsx. - `packages/views/src/ui/spaced.ts` `spacedClass`, `spaced`: `Spaced`: a block whose children stack with `space-y-*` margins (not a flex column), usually a row's text column that fills the free width and lets its lines truncate. @@ -227,6 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/identity-label.ts b/packages/views/src/ui/identity-label.ts index 30a99e1178..bc3d0a8b0c 100644 --- a/packages/views/src/ui/identity-label.ts +++ b/packages/views/src/ui/identity-label.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { typography } from "./typography" @@ -5,5 +6,13 @@ import { typography } from "./typography" // of truncating lines that fills the rest of the row. /** The label's lines (the name, then an email, a region or a namespace). */ -export const identityLabel = (h: HtmlBuilder, lines: ReadonlyArray): Html => - typography(h, { size: "sm", as: "div", className: "grid flex-1 text-left leading-tight" }, lines) +export const identityLabel = ( + h: HtmlBuilder, + lines: ReadonlyArray, + className?: string, +): Html => + typography( + h, + { size: "sm", as: "div", className: cn("grid flex-1 text-left leading-tight", className) }, + lines, + ) diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 860b076795..2ca2ec85fd 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -9,9 +9,13 @@ import { sheetTitleStyles, sheetViewportLeftStyles, sheetViewportStyles, + type SheetSize, } from "@maple/ui/styles/ui/sheet" +import { Command } from "foldkit" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" +import * as Dialog from "./dialog" +import * as DialogView from "./dialog-view" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -127,3 +131,75 @@ export const leftSheet = ( ), ], ) + +// RIGHT SHEET + +export type RightSheetOptions = Readonly<{ + sheet: Dialog.Model + title: string + description?: string + /** `form` (672px) by default; `wide` for reading content. */ + size?: SheetSize + /** The scrolling body. */ + body: ReadonlyArray + /** The sticky footer (Cancel, then the primary action); none renders no footer. */ + footer?: ReadonlyArray + toParentMessage: (message: Dialog.Message) => Message +}> + +// Reduced motion keeps the fade and drops the slide. +const REDUCED_MOTION = + "motion-reduce:data-ending-style:translate-x-0 motion-reduce:data-starting-style:translate-x-0" + +/** + * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a + * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). + */ +export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { + const { sheet } = options + return h.submodel({ + slotId: `${sheet.id}-sheet`, + model: sheet, + view: DialogView.view, + viewInputs: { + side: "right", + size: options.size ?? "form", + className: REDUCED_MOTION, + hasDescription: options.description !== undefined, + toView: ({ portal }) => portal, + toContent: () => [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined + ? [] + : [DialogView.dialogFooter(h, "sheet", options.footer)]), + ], + }, + toParentMessage: options.toParentMessage, + }) +} + +export type SheetStep = ReturnType + +/** + * A sheet whose open state lives in the URL: open or close it to match. The Opened/Closed it + * reports are dropped, as the URL already says so. + */ +export const followUrl = (sheet: Dialog.Model, isOpenInUrl: boolean): SheetStep => { + const step = isOpenInUrl ? Dialog.open(sheet) : Dialog.close(sheet) + return { model: step.model, commands: step.commands } +} + +/** The sheet closed itself (Escape, backdrop, close button): the caller clears its URL state. */ +export const isDismissed = (step: SheetStep): boolean => step.outMessage?._tag === "Closed" + +/** A sheet step's commands under the parent's Message. */ +export const mapSheetCommands = ( + step: SheetStep, + toParentMessage: (message: Dialog.Message) => Message, +) => Command.mapMessages(step.commands ?? [], toParentMessage) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 0ee064f0f3..67c77e1c02 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -206,16 +206,65 @@ export const sidebarGroupLabel = ( children, ) -export const sidebarGroupContent = (h: HtmlBuilder, children: Children): Html => +export const sidebarGroupContent = ( + h: HtmlBuilder, + children: Children, + attributes: Attributes = [], +): Html => h.div( [ h.Class(cn(sidebarGroupContentStyles)), h.DataAttribute("sidebar", "group-content"), h.DataAttribute("slot", "sidebar-group-content"), + ...attributes, ], children, ) +export type SidebarGroupToggleOptions = Readonly<{ + label: string + isExpanded: boolean + /** The `sidebarGroupContent` id it shows and hides. */ + controlsId: string + /** None: the group cannot collapse right now (it holds the current page). */ + onToggle: Message | null +}> + +/** A group label that collapses its rows: a chevron button with `aria-expanded`. */ +export const sidebarGroupToggle = ( + h: HtmlBuilder, + options: SidebarGroupToggleOptions, +): Html => + h.button( + [ + h.Type("button"), + h.Class( + cn( + ...sidebarGroupLabelStyles, + "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", + options.onToggle === null + ? "cursor-default" + : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + ), + ), + h.DataAttribute("sidebar", "group-label"), + h.DataAttribute("slot", "sidebar-group-toggle"), + h.AriaExpanded(options.isExpanded), + h.AriaControls(options.controlsId), + ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + ], + [ + h.span([h.Class("flex-1 truncate")], [options.label]), + icon(h, "ChevronDownIcon", { + size: 12, + className: cn( + "opacity-0 transition-[opacity,rotate] duration-150 group-hover/toggle:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none", + options.isExpanded ? "" : "-rotate-90 opacity-100", + ), + }), + ], + ) + /** ``: the icon button in a group label's corner. */ export const sidebarGroupAction = ( h: HtmlBuilder, From 5535bc2dddaef3aba75ad20ce61df4b85d674609 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:26:21 +0100 Subject: [PATCH 02/35] fix(shell): sheet history, on-call freshness, foldable active group - Sheet: opening pushes a history entry marked as this tab's; dismissing goes back past it, and only a deep link replaces the param. Kit renamed to sidePanelSheet, footer receives close; sheet-param tests sit beside it. - On-call pill: a shift past its end reads as unknown; reads again at the handoff, hourly while off call, on focus and visibility, and with backoff (60s doubling, capped) after a stale or failed answer, keeping the last good data. Schedule and override writes announce a re-read. - An org with no schedules is cached per org for a day and skips the request; a schedule write clears it. - "until" uses the viewer's time zone with a short zone name. - Sidebar: the collapsed rail renders plain labels (no tab stops); the group holding the current page can fold and keeps only that row. The pill uses aria-label on the rail instead of a title. --- .../src/lab/shell-states-lab.scene.test.ts | 61 ++---- apps/web/src/lab/shell-states-lab.ts | 45 ++--- .../page/alerts/shared/oncall-schedules.ts | 28 ++- apps/web/src/page/kit/sheet-param.test.ts | 56 ++++++ apps/web/src/page/kit/sheet-param.ts | 41 +++- apps/web/src/shell/command.ts | 26 +++ apps/web/src/shell/data.ts | 32 +++- apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 6 +- apps/web/src/shell/message.ts | 19 +- apps/web/src/shell/model.ts | 13 ++ apps/web/src/shell/nav-groups.scene.test.ts | 16 +- apps/web/src/shell/oncall.scene.test.ts | 177 +++++++++++++----- apps/web/src/shell/oncall.ts | 119 ++++++++++-- apps/web/src/shell/shell.scene.test.ts | 2 +- apps/web/src/shell/subscription.ts | 41 +++- apps/web/src/shell/update.ts | 106 +++++++++-- apps/web/src/shell/view-nav.ts | 41 ++-- apps/web/src/shell/view-sidebar.ts | 15 +- apps/web/src/shell/view.ts | 4 +- apps/web/src/subscription.ts | 26 ++- packages/views/src/ui/INDEX.md | 4 +- packages/views/src/ui/sheet.ts | 22 ++- packages/views/src/ui/sidebar.ts | 10 +- 24 files changed, 696 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/page/kit/sheet-param.test.ts diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 56a7b6324c..445addbe8f 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -13,21 +13,15 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Dialog from "@maple/views/ui/dialog" -import { - closeSheetNavigation, - openSheetNavigation, - sheetParamOf, - withSheetParam, -} from "../page/kit/sheet-param" -import { Array as Arr, Option } from "effect" +import { Array as Arr } from "effect" import { MeasureSidebarOverflow } from "../shell/mounts" import { Message as ShellMessage } from "../shell/message" import { init, Message, type Model, + DismissSheetUrl, PushSheetUrl, - ReplaceSheetUrl, SHEET_ID, update, view, @@ -64,7 +58,7 @@ const leave = (generation: number) => [ ] describe("URL-held right sheet", () => { - test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + test("a link with ?sheet= opens it, and Escape dismisses it through the URL", () => { scene( { update, view }, given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), @@ -74,8 +68,8 @@ describe("URL-held right sheet", () => { expect(dialog).toContainText("Error rate above (%)"), expect(role("button", { name: "Create rule" })).toExist(), keydown(portal, "Escape"), - Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), - Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?tab=x&sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), // Booting open counted the show as generation 1. ...leave(2), ) @@ -88,7 +82,7 @@ describe("URL-held right sheet", () => { measured, expect(dialog).toBeAbsent(), click(role("button", { name: "New alert rule" })), - Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: SHEET_ID })), Command.resolve(PushSheetUrl, Message.CompletedNavigate()), expect(dialog).toBeAbsent(), Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), @@ -105,40 +99,21 @@ describe("URL-held right sheet", () => { const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) expectVitest(back.model.sheet.isOpen).toBe(false) expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( - ReplaceSheetUrl.name, + DismissSheetUrl.name, ) }) - test("Cancel in the footer closes it and clears the param", () => { - const closed = update( - at(`/lab/shell?sheet=${SHEET_ID}`), - Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), - ) - expectVitest(closed.model.sheet.isOpen).toBe(false) - expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( - ReplaceSheetUrl.name, - ) - }) -}) - -describe("sheet param", () => { - test("sets and clears ?sheet= while keeping the other params and the hash", () => { - expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( - "/alerts?tab=rules&sheet=new-rule#top", + test("Cancel in the footer dismisses it like Escape", () => { + scene( + { update, view }, + given(at(`/lab/shell?sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + click(role("button", { name: "Cancel" })), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), + ...leave(2), ) - expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") - expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) - expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) - }) - - test("opening pushes a history entry; dismissing replaces it", () => { - expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ - url: "/alerts?sheet=new-rule", - isReplace: false, - }) - expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ - url: "/alerts", - isReplace: true, - }) }) }) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 478ea5404f..6d017f71c7 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -2,19 +2,24 @@ import { Effect, Option, Schema } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import { pushUrl, replaceUrl } from "foldkit/navigation" import { button } from "@maple/views/ui/button" import * as Dialog from "@maple/views/ui/dialog" import { input } from "@maple/views/ui/input" import { label } from "@maple/views/ui/label" import { segmentedSelect } from "@maple/views/ui/segmented-select" -import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { + followUrl, + isDismissed, + mapSheetCommands, + type SheetStep, + sidePanelSheet, +} from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" -import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" import type { OnCallSnapshot } from "../shell/oncall" @@ -32,20 +37,20 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) const LAB_USER = "user_lab" export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ userId: LAB_USER, + viewerTimeZone: "Europe/Berlin", shifts: fixture === "none" ? [] : [ { scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: fixture === "onCall" ? LAB_USER : "user_other", endsAtMs: HANDOFF_MS, }, @@ -86,18 +91,20 @@ export const Message = defineMessageUnion({ }) export type Message = typeof Message.Type -/** Opening the sheet: a new history entry carrying `?sheet=`. */ +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opening the sheet: a history entry carrying `?sheet=`, marked as this tab's. */ export const PushSheetUrl = Command.define("PushSheetUrl", { - args: { url: Schema.String }, + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) -/** Dismissing it: the param leaves in place. */ -export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { - args: { url: Schema.String }, +/** Dismissing it: back past the entry this tab pushed, else the param is replaced away. */ +export const DismissSheetUrl = Command.define("DismissSheetUrl", { + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) type Return = Update.Return @@ -112,7 +119,7 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ model: { ...model, sheet: step.model }, commands: [ ...mapSheetCommands(step, toSheet), - ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: SHEET_ID })] : []), ], }) @@ -146,7 +153,7 @@ export const update = (model: Model, message: Message): Return => }, ClickedNewRule: () => ({ model, - commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + commands: [PushSheetUrl({ href: model.href, id: SHEET_ID })], }), ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), ClickedOnCallFixture: ({ fixture }) => ({ @@ -165,7 +172,7 @@ const field = (h: HtmlBuilder, id: string, text: string, control: Html): H vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) const sheet = (h: HtmlBuilder, model: Model): Html => - rightSheet(h, { + sidePanelSheet(h, { sheet: model.sheet, title: "New alert rule", description: "Page the on-call person when a service's error rate crosses a threshold.", @@ -198,12 +205,8 @@ const sheet = (h: HtmlBuilder, model: Model): Html => ), ]), ], - footer: [ - button( - h, - { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, - ["Cancel"], - ), + footer: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), ], }) diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index 0d2b0dd2fd..fc9c70a19e 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -22,6 +22,14 @@ 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" +/** + * Fired on `window` after a schedule or override is written, so the sidebar's on-call pill reads + * again (a page's single out-message is already its toast or navigation). + */ +export const ONCALL_CHANGED_EVENT = "maple:oncall-schedules-changed" + +const announceOnCallChange = Effect.sync(() => window.dispatchEvent(new Event(ONCALL_CHANGED_EVENT))) + export const listOnCallSchedules = Effect.gen(function* () { const client = yield* MapleApiV2Client const page = yield* client.oncallSchedules.list({ query: { limit: SCHEDULE_LIST_LIMIT } }) @@ -47,13 +55,19 @@ export const saveOnCallSchedule = ( params: { id: editingId }, payload: { ...payload, description: payload.description ?? null }, }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) export const deleteOnCallSchedule = (id: OnCallScheduleId) => Effect.gen(function* () { const client = yield* MapleApiV2Client yield* client.oncallSchedules.delete({ params: { id } }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) /** Each layer's shifts and the final ones over `[fromMs, toMs)`. */ export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) => @@ -98,13 +112,19 @@ export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCr Effect.gen(function* () { const client = yield* MapleApiV2Client return yield* client.oncallSchedules.createOverride({ params: { id }, payload }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) 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)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) // LINKS diff --git a/apps/web/src/page/kit/sheet-param.test.ts b/apps/web/src/page/kit/sheet-param.test.ts new file mode 100644 index 0000000000..fb713b1c0b --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.test.ts @@ -0,0 +1,56 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, test } from "vitest" +import { dismissalOf, SheetHistoryState, sheetParamOf, withSheetParam } from "./sheet-param" + +// `?sheet=` in the URL, and how a dismissal leaves the tab's history. + +type Entry = Readonly<{ url: string; state: unknown }> + +const topOf = (stack: ReadonlyArray): Entry => Option.getOrThrow(Arr.last(stack)) + +/** A tab's history as `pushSheetUrl` and `dismissSheetUrl` move it. */ +const open = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + return [...stack, { url: withSheetParam(top.url, id), state }] +} + +const dismiss = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const dismissal = dismissalOf(top.state, top.url, id) + return dismissal._tag === "Back" + ? stack.slice(0, -1) + : [...stack.slice(0, -1), { url: dismissal.url, state: null }] +} + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expect(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expect(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expect(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expect(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("open, Escape, then one Back leaves the page: the dismissal pops the entry it pushed", () => { + const before: ReadonlyArray = [ + { url: "/services", state: null }, + { url: "/alerts?tab=rules", state: null }, + ] + const closed = dismiss(open(before, "new-rule"), "new-rule") + expect(closed).toEqual(before) + expect(Option.map(Arr.last(closed.slice(0, -1)), (entry) => entry.url)).toEqual( + Option.some("/services"), + ) + }) + + test("a deep link (or a reload into another tab's entry) replaces the param away", () => { + const linked: ReadonlyArray = [{ url: "/alerts?tab=rules&sheet=new-rule", state: null }] + expect(dismiss(linked, "new-rule")).toEqual([{ url: "/alerts?tab=rules", state: null }]) + expect(dismissalOf({ mapleSheet: "other" }, "/alerts?sheet=new-rule", "new-rule")).toEqual({ + _tag: "Replace", + url: "/alerts", + }) + }) +}) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts index 68d05b7c5d..09cfb5f800 100644 --- a/apps/web/src/page/kit/sheet-param.ts +++ b/apps/web/src/page/kit/sheet-param.ts @@ -1,9 +1,11 @@ -import { Option, Schema } from "effect" -import { PageOutMessage } from "../contract" +import { Effect, Option, Schema } from "effect" +import { back, replaceUrl } from "foldkit/navigation" /** - * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the - * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + * A side sheet held in the URL as `?sheet=`. Opening pushes an entry this tab marks as its own, + * so dismissing it goes back (Back after a close never lands on the same page again); a deep link + * or a sheet opened by another tab's entry replaces the param away instead. Pages wrap + * `pushSheetUrl` / `dismissSheetUrl` in a Command and follow the URL with `followUrl`. */ export const SHEET_PARAM = "sheet" @@ -24,10 +26,29 @@ export const withSheetParam = (href: string, id: string | undefined): string => export const sheetParamOf = (href: string): Option.Option => Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") -/** A page opens the sheet: a new history entry, so Back closes it. */ -export const openSheetNavigation = (href: string, id: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) +/** The history entry state `pushSheetUrl` writes. */ +export const SheetHistoryState = Schema.Struct({ mapleSheet: Schema.String }) +const decodeSheetState = Schema.decodeUnknownOption(SheetHistoryState) -/** The sheet was dismissed: the param leaves without adding an entry. */ -export const closeSheetNavigation = (href: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) +export type SheetDismissal = Readonly<{ _tag: "Back" }> | Readonly<{ _tag: "Replace"; url: string }> + +/** Back when the current entry is the one this tab pushed for `id`, else replace the param away. */ +export const dismissalOf = (historyState: unknown, href: string, id: string): SheetDismissal => + Option.exists(decodeSheetState(historyState), (state) => state.mapleSheet === id) + ? { _tag: "Back" } + : { _tag: "Replace", url: withSheetParam(href, undefined) } + +/** Opens the sheet: a marked history entry, announced the way foldkit's `pushUrl` does. */ +export const pushSheetUrl = (href: string, id: string): Effect.Effect => + Effect.sync(() => { + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + window.history.pushState(state, "", withSheetParam(href, id)) + window.dispatchEvent(new CustomEvent("foldkit:urlchange")) + }) + +/** Dismisses it (Escape, backdrop, close, Cancel): see `dismissalOf`. */ +export const dismissSheetUrl = (href: string, id: string): Effect.Effect => + Effect.suspend(() => { + const dismissal = dismissalOf(window.history.state, href, id) + return dismissal._tag === "Back" ? back() : replaceUrl(dismissal.url) + }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ee7d602a64..3a69223ac3 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -114,6 +114,32 @@ export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { }), }) +/** `maple.oncall.no-schedules.`: when the org last answered with no schedules, epoch ms. */ +export const NO_SCHEDULES_STORAGE_PREFIX = "maple.oncall.no-schedules." + +/** Remembers an org's "no schedules" answer, or forgets it (`null`) once it has one. */ +export const SaveNoSchedules = Command.define("SaveNoSchedules", { + args: { orgId: Schema.String, checkedAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.CompletedSaveNoSchedules], + execute: ({ orgId, checkedAtMs }) => { + const key = `${NO_SCHEDULES_STORAGE_PREFIX}${orgId}` + return ( + checkedAtMs === null + ? Effect.ignore( + Effect.try({ + try: () => localStorage.removeItem(key), + catch: (cause) => + new ShellStorageError({ + message: "Could not clear the on-call cache", + cause, + }), + }), + ) + : Effect.sync(() => writeLocalStorage(key, String(checkedAtMs))) + ).pipe(Effect.as(Message.CompletedSaveNoSchedules())) + }, +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index 4e32d22ceb..f9dbf7960e 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -10,6 +10,8 @@ import { Message } from "./message" import { SessionTenant } from "../data/session-tenant" import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" import { isoMs } from "../page/alerts/shared/time" +import { readEffectiveTimeZone } from "../platform/time-zone" +import { isNoSchedulesFresh } from "./oncall" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -84,25 +86,45 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { // ON CALL -/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +/** + * The org's schedules (who holds each now, as the API reads it), who is looking and in which zone. + * An org whose "no schedules" answer is fresh is not asked at all. + */ export const LoadOnCall = Command.define("LoadOnCall", { - args: orgArgs, + args: { orgId: Schema.String, noSchedulesCheckedAtMs: Schema.NullOr(Schema.Number) }, messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], - execute: ({ orgId }) => + execute: ({ orgId, noSchedulesCheckedAtMs }) => Effect.gen(function* () { + const readAtMs = yield* Clock.currentTimeMillis + if (isNoSchedulesFresh(noSchedulesCheckedAtMs ?? undefined, readAtMs)) + return Message.SucceededLoadOnCall({ + orgId, + readAtMs, + isFromCache: true, + snapshot: { userId: "", viewerTimeZone: "UTC", shifts: [] }, + }) const tenant = yield* (yield* SessionTenant).get const schedules = yield* listOnCallSchedules + const viewerTimeZone = yield* readEffectiveTimeZone return Message.SucceededLoadOnCall({ orgId, + readAtMs, + isFromCache: false, snapshot: { userId: tenant.userId, + viewerTimeZone, shifts: Arr.map(schedules, (schedule) => ({ scheduleName: schedule.name, - timeZone: schedule.time_zone, userId: schedule.on_call_now?.user_id ?? null, endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), })), }, }) - }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), + }).pipe( + Effect.catch(() => + Effect.map(Clock.currentTimeMillis, (failedAtMs) => + Message.FailedLoadOnCall({ orgId, failedAtMs }), + ), + ), + ), }) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index 644f4a4fbd..f9abf86a83 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -5,6 +5,7 @@ import { readDashboardFavorites } from "./favorites" import { COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, + NO_SCHEDULES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, THEME_STORAGE_KEY, @@ -68,6 +69,12 @@ const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Arr export const readCollapsedNavGroups = (): ReadonlyArray => Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) +/** Every org's last "no schedules" answer, epoch ms; an entry that is not a number is skipped. */ +const readNoScheduleOrgs = readPrefixed( + NO_SCHEDULES_STORAGE_PREFIX, + Schema.decodeUnknownOption(Schema.NumberFromString), +) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -79,6 +86,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), collapsedNavGroups: readCollapsedNavGroups(), + noScheduleOrgs: yield* readNoScheduleOrgs, rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 78b2bea6ee..6fa454d18f 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,8 +8,10 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, - onCallHandoff, - onCallHandoffAt, + onCallChanges, + onCallReadAt, + onCallTimer, + onCallWakes, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 370ae54310..966f002435 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,10 +80,21 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, - SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, - FailedLoadOnCall: { orgId: Schema.String }, - /** A schedule handed off: who is on call may have changed. */ - ElapsedOnCallHandoff: {}, + /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ + SucceededLoadOnCall: { + orgId: Schema.String, + snapshot: OnCallSnapshot, + readAtMs: Schema.Number, + isFromCache: Schema.Boolean, + }, + FailedLoadOnCall: { orgId: Schema.String, failedAtMs: Schema.Number }, + /** The pill's next read is due: a handoff, the hourly check off call, or a retry. */ + ElapsedOnCallTimer: { nowMs: Schema.Number }, + /** The tab came back into view or focus. */ + WokeOnCall: { nowMs: Schema.Number }, + /** A page wrote a schedule or an override. */ + ChangedOnCallSchedules: {}, + CompletedSaveNoSchedules: {}, /** A nav group's label: fold or unfold its rows. */ ClickedNavGroup: { groupId: Schema.String }, CompletedSaveNavGroups: {}, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 01736502fc..e36edc6948 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -82,6 +82,14 @@ export const Model = Schema.Struct({ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ collapsedNavGroups: Schema.Array(Schema.String), + /** The pill's clock: when an on-call event last happened, so ended shifts read as unknown. */ + onCallNowMs: Schema.Number, + /** When the pill reads again; `null` for an org without schedules. */ + onCallNextReadAtMs: Schema.NullOr(Schema.Number), + /** The current retry delay; 0 after a good answer. */ + onCallBackoffMs: Schema.Number, + /** `maple.oncall.no-schedules.`: when each org last answered with no schedules. */ + noScheduleOrgs: Schema.Record(Schema.String, Schema.Number), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -140,6 +148,7 @@ export type ShellFlags = Readonly<{ pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray collapsedNavGroups?: ReadonlyArray + noScheduleOrgs?: Readonly> rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -166,6 +175,10 @@ export const init = (flags: ShellFlags): Model => ({ dashboards: DashboardsData.Idle(), onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], + onCallNowMs: 0, + onCallNextReadAtMs: null, + onCallBackoffMs: 0, + noScheduleOrgs: flags.noScheduleOrgs ?? {}, namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 405111c4e6..77503cb45c 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -65,23 +65,29 @@ describe("sidebar groups", () => { ) }) - test("the group holding the current page stays open and cannot fold", () => { + test("folding the group holding the current page keeps only the current page's row", () => { + const triage = role("button", { name: "Triage" }) scene( { update, view: shellAt("/alerts") }, - given(signedIn({ collapsedNavGroups: ["triage"] })), + given(signedIn()), measured, - expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), - expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Errors" })).toExist(), + click(triage), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(triage).toHaveAttr("aria-expanded", "false"), expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Errors" })).toBeAbsent(), + expect(role("link", { name: "Code review" })).toBeAbsent(), ) }) - test("the collapsed rail shows every row of a folded group", () => { + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), measured, expect(dashboards).toExist(), + expect(analyze).toBeAbsent(), ) }) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 0abc3f0fc8..5ce01cc3bf 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -15,36 +15,59 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Tooltip from "@maple/views/ui/tooltip" -import { LoadOnCall } from "./data" import { Message } from "./message" import { init, type Model, OnCallData } from "./model" import { MeasureSidebarOverflow } from "./mounts" -import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" -import { enter, update } from "./update" +import { + isNoSchedulesFresh, + nextHandoffMs, + ONCALL_IDLE_READ_MS, + ONCALL_RETRY_MS, + type OnCallShift, + type OnCallSnapshot, + onCallStatusOf, +} from "./oncall" +import { enter, update, type UpdateReturn } from "./update" import { frameView } from "./view" -// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. +// The footer's on-call pill: what it says, and when it reads the schedules again. const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF = Date.UTC(2026, 10, 16, 8) +const NOW = HANDOFF - 3 * 60 * 60_000 +const ORG = "org_parity" const shift = (overrides: Partial = {}): OnCallShift => ({ scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: "user_me", endsAtMs: HANDOFF, ...overrides, }) -const withOnCall = (shifts: ReadonlyArray): Model => ({ +const snapshotOf = (shifts: ReadonlyArray): OnCallSnapshot => ({ + userId: "user_me", + viewerTimeZone: "Europe/Berlin", + shifts, +}) + +const signedIn = (overrides: Partial = {}): Model => ({ ...init(flags), - maybeOrgId: Option.some("org_parity"), + maybeOrgId: Option.some(ORG), hasRequestedSidebarData: true, - onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), + ...overrides, }) +const withOnCall = (shifts: ReadonlyArray, overrides: Partial = {}): Model => + signedIn({ onCall: OnCallData.Success({ data: snapshotOf(shifts) }), onCallNowMs: NOW, ...overrides }) + +const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache = false) => + Message.SucceededLoadOnCall({ orgId: ORG, snapshot: snapshotOf(shifts), readAtMs, isFromCache }) + +const commandsOf = (step: UpdateReturn) => + Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) + const view = (() => { const frame = withViewInputs(frameView, { currentPath: "/", @@ -59,13 +82,13 @@ const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOv const footer = selector('[data-slot="sidebar-footer"]') describe("on-call pill", () => { - test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + test("on call: until the handoff on the viewer's clock with its zone, the schedule, a link to schedules", () => { scene( { update, view }, given(withOnCall([shift()])), measured, expect(footer).toContainText("You're on call"), - expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), ) @@ -81,32 +104,33 @@ describe("on-call pill", () => { ) }) - test("an org without schedules shows no pill", () => { + test("an org without schedules, a first read that failed, or a shift that ended show no pill", () => { + const absent = [ + expect(text("My schedules")).toBeAbsent(), + expect(text("You're on call")).toBeAbsent(), + ] + scene({ update, view }, given(withOnCall([])), measured, ...absent) scene( { update, view }, - given(withOnCall([])), + given(signedIn({ onCall: OnCallData.Failure({ error: "Failed" }) })), measured, - expect(text("You're not on call")).toBeAbsent(), - expect(text("My schedules")).toBeAbsent(), + ...absent, ) - }) - - test("a failed read shows no pill", () => { scene( { update, view }, - given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + given(withOnCall([shift()], { onCallNowMs: HANDOFF + 1 })), measured, - expect(text("My schedules")).toBeAbsent(), + ...absent, ) }) - test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { - const pill = role("link", { name: /You're on call/ }) + test("on the collapsed rail the pill is a dot named by the sentence, which its tooltip shows", () => { + const pill = role("link", { name: "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)" }) scene( { update, view }, - given({ ...withOnCall([shift()]), isSidebarOpen: false }), + given(withOnCall([shift()], { isSidebarOpen: false })), measured, - expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + expect(pill).toExist(), hover(pill), Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), @@ -119,49 +143,114 @@ describe("on-call pill", () => { Animation.Message.EndedAnimation({ generation: 1 }), ), expect(selector('[data-slot="tooltip-popup"]')).toHaveText( - "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)", ), ) }) +}) - test("the sidebar reads the schedules with its other data, for its org only", () => { - const entered = enter("org_parity")(init(flags)) - expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) - const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } - const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) +describe("on-call reads", () => { + test("the sidebar reads the schedules with its other data; an answer for another org is dropped", () => { + const entered = enter(ORG)(init(flags)) + expectVitest(commandsOf(entered)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: null }, + }) + const stale = update(entered.model, { + ...answer([shift()]), + orgId: "org_other", + }) expectVitest(stale.model.onCall._tag).toBe("Loading") - const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) - expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) }) - test("a handoff reads the schedules again", () => { + test("an answer reads again after the next handoff, or within the hour while the user is off call", () => { + const onCall = update(signedIn(), answer([shift()])).model + expectVitest(onCall.onCallNextReadAtMs).toBe(HANDOFF + 5_000) + const offCall = update(signedIn(), answer([shift({ userId: "user_someone", endsAtMs: null })])).model + expectVitest(offCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) + }) + + test("an answer whose handoff has already passed retries with a growing delay", () => { + const late = HANDOFF + 1_000 + const first = update(signedIn(), answer([shift()], late)).model + expectVitest(first.onCallNextReadAtMs).toBe(late + ONCALL_RETRY_MS) + const second = update(first, answer([shift()], late)).model + expectVitest(second.onCallNextReadAtMs).toBe(late + 2 * ONCALL_RETRY_MS) + }) + + test("a failed re-read keeps the last answer up and retries from a minute, doubling", () => { const loaded = withOnCall([shift()]) - const next = update(loaded, Message.ElapsedOnCallHandoff()) - expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + const failed = update(loaded, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(failed.onCall).toEqual(OnCallData.Success({ data: snapshotOf([shift()]) })) + expectVitest(failed.onCallNextReadAtMs).toBe(NOW + ONCALL_RETRY_MS) + const again = update(failed, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(again.onCallNextReadAtMs).toBe(NOW + 2 * ONCALL_RETRY_MS) + }) + + test("the timer reads again; coming back into focus reads only once a minute has passed", () => { + const loaded = withOnCall([shift()]) + expectVitest( + Arr.map( + commandsOf(update(loaded, Message.ElapsedOnCallTimer({ nowMs: HANDOFF }))), + (c) => c.name, + ), + ).toEqual(["LoadOnCall"]) + expectVitest(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 10_000 })))).toEqual([]) + expectVitest( + Arr.map(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 61_000 }))), (c) => c.name), + ).toEqual(["LoadOnCall"]) + }) + + test("an org with no schedules is remembered, skipped on the next read, and forgotten on a write", () => { + const empty = update(signedIn(), answer([])) + expectVitest(commandsOf(empty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: NOW } }, + ]) + expectVitest(empty.model.onCallNextReadAtMs).toBeNull() + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + expectVitest(commandsOf(booted)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: NOW }, + }) + const written = update(empty.model, Message.ChangedOnCallSchedules()) + expectVitest(commandsOf(written)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) }) }) describe("onCallStatusOf", () => { - test("the user's soonest-ending shift names the pill, counting the others", () => { - const status = onCallStatusOf({ - userId: "user_me", - shifts: [ + test("the user's soonest-ending live shift names the pill, counting the others", () => { + const status = onCallStatusOf( + snapshotOf([ shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), shift({ scheduleName: "Checkout primary" }), shift({ scheduleName: "Search", userId: "user_someone" }), - ], - }) + ]), + NOW, + ) expectVitest(status).toEqual({ _tag: "OnCall", scheduleName: "Checkout primary", otherSchedules: 1, - until: "Mon 16 Nov, 09:00", + until: "Mon 16 Nov, 09:00 CET", + }) + }) + + test("a shift past its end is not trusted: the next live one names the pill, or none does", () => { + const shifts = [shift(), shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 })] + expectVitest(onCallStatusOf(snapshotOf(shifts), HANDOFF + 1)).toMatchObject({ + scheduleName: "Payments", }) + expectVitest(onCallStatusOf(snapshotOf([shift()]), HANDOFF + 1)).toEqual({ _tag: "Unknown" }) }) test("the next handoff is the soonest across every schedule; none in a gap", () => { const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] - expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) - expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + expectVitest(nextHandoffMs(snapshotOf(shifts))).toBe(HANDOFF) + expectVitest(nextHandoffMs(snapshotOf([shift({ endsAtMs: null })]))).toBeNull() }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 97099b2239..72664980f1 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -1,26 +1,28 @@ import { Array as Arr, Option, Order, Schema } from "effect" -import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" -// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. +// The sidebar's on-call pill: each schedule's current shift as the schedules list reads it, what +// the pill says, and when it asks again. /** One schedule's current shift: who holds it until when, `null` in a gap. */ export const OnCallShift = Schema.Struct({ scheduleName: Schema.String, - timeZone: Schema.String, userId: Schema.NullOr(Schema.String), endsAtMs: Schema.NullOr(Schema.Number), }) export type OnCallShift = typeof OnCallShift.Type -/** The signed-in user and every schedule of the org. */ +/** The signed-in user, the zone they read times in, and every schedule of the org. */ export const OnCallSnapshot = Schema.Struct({ userId: Schema.String, + viewerTimeZone: Schema.String, shifts: Schema.Array(OnCallShift), }) export type OnCallSnapshot = typeof OnCallSnapshot.Type export type OnCallStatus = | Readonly<{ _tag: "NoSchedules" }> + /** Every shift the user held has ended since the last read: hidden until the next one. */ + | Readonly<{ _tag: "Unknown" }> | Readonly<{ _tag: "NotOnCall" }> | Readonly<{ _tag: "OnCall" @@ -30,23 +32,59 @@ export type OnCallStatus = until: string }> +const untilFormatters = new Map() + +/** "Mon 16 Nov, 09:00 CET" on the viewer's clock, naming the zone; built from parts, as ICU builds differ in the joiner. */ +export const formatUntil = (ms: number, timeZone: string): string => { + const cached = untilFormatters.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", + timeZoneName: "short", + }) + if (cached === undefined) untilFormatters.set(timeZone, formatter) + const parts = formatter.formatToParts(ms) + const part = (type: Intl.DateTimeFormatPartTypes) => + Option.match( + Arr.findFirst(parts, (candidate) => candidate.type === type), + { onNone: () => "", onSome: (found) => found.value }, + ) + return `${part("weekday")} ${part("day")} ${part("month")}, ${part("hour")}:${part("minute")} ${part("timeZoneName")}` +} + const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) -/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ -export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { +const hasEnded = (shift: OnCallShift, nowMs: number): boolean => + shift.endsAtMs !== null && shift.endsAtMs <= nowMs + +/** + * The user's soonest-ending live shift names the pill. A shift past its end is unknown (the read + * is stale), so a user whose every shift has ended sees nothing until the next read. + */ +export const onCallStatusOf = (snapshot: OnCallSnapshot, nowMs: number): OnCallStatus => { if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } - const mine = Arr.sort( - Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + const held = Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId) + const live = Arr.sort( + Arr.filter(held, (shift) => !hasEnded(shift, nowMs)), byEnd, ) - return Option.match(Arr.head(mine), { - onNone: () => ({ _tag: "NotOnCall" }), + return Option.match(Arr.head(live), { + onNone: (): OnCallStatus => (held.length > 0 ? { _tag: "Unknown" } : { _tag: "NotOnCall" }), onSome: (shift) => ({ _tag: "OnCall", scheduleName: shift.scheduleName, - otherSchedules: mine.length - 1, + otherSchedules: live.length - 1, until: - shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + shift.endsAtMs === null + ? "further notice" + : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone), }), }) } @@ -57,11 +95,61 @@ export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { return ends.length === 0 ? null : Math.min(...ends) } +// WHEN TO ASK AGAIN + +/** The API may lag a handoff by a moment. */ +const HANDOFF_SETTLE_MS = 5_000 +export const ONCALL_RETRY_MS = 60_000 +const ONCALL_RETRY_CAP_MS = 15 * 60_000 +/** A shift starting out of a gap shows up in no handoff, so a user off call checks hourly. */ +export const ONCALL_IDLE_READ_MS = 60 * 60_000 +/** Focus re-reads at most this often. */ +export const ONCALL_WAKE_MIN_MS = 60_000 +/** An org's "no schedules" answer is trusted this long before asking again. */ +export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 + +export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> + +const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { + const next = backoffMs === 0 ? ONCALL_RETRY_MS : Math.min(backoffMs * 2, ONCALL_RETRY_CAP_MS) + return { nextReadAtMs: nowMs + next, backoffMs: next } +} + +/** + * After an answer: at the next handoff, or within the hour while the user is off call; a handoff + * the answer already places in the past (the API has not caught up) retries with backoff. + */ +export const scheduleAfterAnswer = ( + snapshot: OnCallSnapshot, + nowMs: number, + backoffMs: number, +): OnCallSchedule => { + if (snapshot.shifts.length === 0) return { nextReadAtMs: null, backoffMs: 0 } + const handoff = nextHandoffMs(snapshot) + if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) + const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" + const candidates = [ + ...(handoff === null ? [] : [handoff + HANDOFF_SETTLE_MS]), + ...(isOnCall ? [] : [nowMs + ONCALL_IDLE_READ_MS]), + ] + return { nextReadAtMs: candidates.length === 0 ? null : Math.min(...candidates), backoffMs: 0 } +} + +/** After a failed read: retry from a minute, doubling, capped. */ +export const scheduleAfterFailure = (nowMs: number, backoffMs: number): OnCallSchedule => + backedOff(nowMs, backoffMs) + +/** The cached "no schedules" answer for an org is still fresh. */ +export const isNoSchedulesFresh = (checkedAtMs: number | undefined, nowMs: number): boolean => + checkedAtMs !== undefined && nowMs - checkedAtMs < NO_SCHEDULES_TTL_MS + +// COPY + /** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ export const scheduleLabel = (status: Extract): string => status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` -/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +/** The pill's full sentence: the collapsed rail's tooltip and accessible name. */ export const onCallSentence = (status: OnCallStatus): string => { switch (status._tag) { case "OnCall": @@ -69,6 +157,11 @@ export const onCallSentence = (status: OnCallStatus): string => { case "NotOnCall": return "You're not on call" case "NoSchedules": + case "Unknown": return "" } } + +/** The pill shows for these. */ +export const isPillShown = (status: OnCallStatus): boolean => + status._tag === "OnCall" || status._tag === "NotOnCall" diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index 65ee2707bb..f395917878 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -198,7 +198,7 @@ describe("sidebar collapse", () => { expect(sidebar).toBeAbsent(), ...open(), Command.expectExact(LoadInfraSurfaces, LoadOnCall), - Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity", failedAtMs: 0 })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 076cfaba32..ec188cab53 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,11 +1,10 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" -import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" import type { Model } from "./model" -import { nextHandoffMs } from "./oncall" +import { ONCALL_CHANGED_EVENT } from "../page/alerts/shared/oncall-schedules" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -86,16 +85,40 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) -/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ -export const onCallHandoffAt = (model: Model): number | null => - Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) +/** When the on-call pill next reads the schedules; `null` while one is in flight or none is due. */ +export const onCallReadAt = (model: Model): number | null => model.onCallNextReadAtMs -/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ -export const onCallHandoff = (atMs: number): Stream.Stream => +/** Waits until the pill's next read is due. */ +export const onCallTimer = (atMs: number): Stream.Stream => Stream.fromEffect( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis - yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) - return Message.ElapsedOnCallHandoff() + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now))) + return Message.ElapsedOnCallTimer({ nowMs: yield* Clock.currentTimeMillis }) }), ) + +const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window + +/** The tab came back into view or focus (a handoff may have passed while it slept). */ +export const onCallWakes: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), +).pipe( + Stream.filter((isAwake) => isAwake), + Stream.mapEffect(() => Clock.currentTimeMillis), + Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), +) + +const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + +/** A page wrote a schedule or an override. */ +export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ + target: onCallChangeTarget, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), +}) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index b671d58c33..0bdca57292 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -12,6 +12,7 @@ import { FocusShortcutSearch, LogOut, SaveCollapsedNavGroups, + SaveNoSchedules, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, @@ -27,6 +28,7 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" +import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" import { DashboardsData, InfraPresenceData, @@ -64,6 +66,8 @@ export const enter = integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), onCall: () => OnCallData.Idle(), + onCallNextReadAtMs: () => null, + onCallBackoffMs: () => 0, namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -97,16 +101,95 @@ const requestSidebarData = (model: Model): UpdateReturn => { } } -/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ -const readOnCall = (model: Model): UpdateReturn => +/** + * The footer's on-call pill: read with the sidebar and again when due, the last answer staying up. + * A forced read skips the org's cached "no schedules" answer (a schedule was just written). + */ +const readOnCall = (model: Model, isForced = false): UpdateReturn => Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { onNone: () => ({ model }), onSome: ([orgId, onCall]) => ({ - model: modifyFields(model, { onCall: () => onCall }), - commands: [LoadOnCall({ orgId })], + model: modifyFields(model, { onCall: () => onCall, onCallNextReadAtMs: () => null }), + commands: [ + LoadOnCall({ + orgId, + noSchedulesCheckedAtMs: isForced ? null : (model.noScheduleOrgs[orgId] ?? null), + }), + ], }), }) +/** An answer: schedule the next read, and remember (or forget) that the org has no schedules. */ +const onCallAnswered = ( + model: Model, + answer: Readonly<{ orgId: string; snapshot: OnCallSnapshot; readAtMs: number; isFromCache: boolean }>, +): UpdateReturn => { + const { orgId, snapshot, readAtMs, isFromCache } = answer + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterAnswer(snapshot, readAtMs, model.onCallBackoffMs) + const isEmpty = snapshot.shifts.length === 0 + const wasEmpty = model.noScheduleOrgs[orgId] !== undefined + return { + model: modifyFields(model, { + onCall: () => OnCallData.Success({ data: snapshot }), + onCallNowMs: () => readAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + noScheduleOrgs: (all) => + isFromCache ? all : isEmpty ? Record.set(all, orgId, readAtMs) : Record.remove(all, orgId), + }), + commands: isFromCache + ? [] + : isEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: readAtMs })] + : wasEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: null })] + : [], + } +} + +/** A failed read keeps the last good answer up and retries with backoff. */ +const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateReturn => { + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterFailure(failedAtMs, model.onCallBackoffMs) + return { + model: modifyFields(model, { + onCall: (current) => + Option.match(AsyncData.getData(current), { + onNone: () => OnCallData.Failure({ error: "Failed" }), + onSome: (data) => OnCallData.Success({ data }), + }), + onCallNowMs: () => failedAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + }), + } +} + +/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { + const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) + if (!hasSchedules) return { model } + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } +} + +/** A page wrote a schedule or override: the org may have its first schedule now. */ +const onCallChanged = (model: Model): UpdateReturn => + Option.match(model.maybeOrgId, { + onNone: () => ({ model }), + onSome: (orgId) => { + const read = readOnCall( + modifyFields(model, { noScheduleOrgs: (all) => Record.remove(all, orgId) }), + true, + ) + return { + model: read.model, + commands: [...(read.commands ?? []), SaveNoSchedules({ orgId, checkedAtMs: null })], + } + }, + }) + /** Folds or unfolds a nav group and remembers it. */ const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { const groups = Array.contains(model.collapsedNavGroups, groupId) @@ -486,15 +569,12 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, - SucceededLoadOnCall: ({ orgId, snapshot }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), - ), - FailedLoadOnCall: ({ orgId }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), - ), - ElapsedOnCallHandoff: () => readOnCall(model), + SucceededLoadOnCall: (answer) => onCallAnswered(model, answer), + FailedLoadOnCall: ({ orgId, failedAtMs }) => onCallFailed(model, orgId, failedAtMs), + ElapsedOnCallTimer: ({ nowMs }) => readOnCall(modifyFields(model, { onCallNowMs: () => nowMs })), + WokeOnCall: ({ nowMs }) => onCallWoke(model, nowMs), + ChangedOnCallSchedules: () => onCallChanged(model), + CompletedSaveNoSchedules: () => ({ model }), ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 215e8d8a29..749a57a46f 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -277,16 +277,15 @@ export const pinnedGroup = ( ] } -export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" +export type NavGroupFold = "unlabelled" | "rail" | "expanded" | "collapsed" /** - * How a group's rows show: a labelled group folds, except while it holds the current page - * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + * How a group's rows show. The collapsed rail hides labels, so every row shows there; elsewhere a + * labelled group folds to the rows of the current page, if it holds it. */ -export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { +export const navGroupFold = (model: Model, group: NavGroup): NavGroupFold => { if (group.label === undefined) return "unlabelled" - if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" - if (!model.isSidebarOpen && !model.isMobile) return "expanded" + if (!model.isSidebarOpen && !model.isMobile) return "rail" return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" } @@ -300,28 +299,34 @@ export const navGroupSections = ( clerk: ShellClerk | null = null, ): ReadonlyArray => Arr.map(shellNavGroups(clerk), (group) => { - const fold = navGroupFold(model, group, currentPath) + const fold = navGroupFold(model, group) + const rows = + fold === "collapsed" + ? Arr.filter(group.items, (item) => isNavItemActive(currentPath, item)) + : group.items return sidebarGroup(h, undefined, [ ...(group.label === undefined ? [] - : [ - sidebarGroupToggle(h, { - label: group.label, - isExpanded: fold !== "collapsed", - controlsId: navGroupContentId(group), - onToggle: - fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), - }), - ]), + : fold === "rail" + ? // Hidden on the rail: a plain label keeps the spacing and is no tab stop. + [sidebarGroupLabel(h, GROUP_LABEL, [group.label])] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), sidebarGroupContent( h, - fold === "collapsed" + rows.length === 0 ? [] : [ sidebarMenu( h, undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + Arr.map(rows, (item) => navRow(h, model, item, currentPath, tip)), ), ], [h.Id(navGroupContentId(group))], diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 324e27aa37..403da11b4e 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -27,7 +27,7 @@ import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" -import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" +import { isPillShown, type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -251,7 +251,7 @@ const footerNavRow = ( export const shellOnCallStatus = (model: Model): OnCallStatus => Option.match(AsyncData.getData(model.onCall), { onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), - onSome: onCallStatusOf, + onSome: (snapshot) => onCallStatusOf(snapshot, model.onCallNowMs), }) /** @@ -263,8 +263,9 @@ const onCallPill = ( currentPath: string, status: OnCallStatus, tip: RailTip, + isRail: boolean, ): ReadonlyArray => - status._tag === "NoSchedules" + !isPillShown(status) ? [] : [ sidebarMenu(h, undefined, [ @@ -277,7 +278,11 @@ const onCallPill = ( "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", link: { href: oncallTabHref(), currentPath }, hasTooltip: true, - attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + // The rail hides the lines, so the link names itself there. + attributes: [ + ...tip(onCallSentence(status)), + ...(isRail ? [h.AriaLabel(onCallSentence(status))] : []), + ], }, [ hstack( @@ -420,7 +425,7 @@ export const shellSidebarFooter = ( ], [], ), - ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 171d7dea3f..ae898eb3eb 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -30,7 +30,7 @@ import { shellSidebarFooter, shellSidebarHeader, } from "./view-sidebar" -import { onCallSentence } from "./oncall" +import { isPillShown, onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -67,7 +67,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), - ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + ...(isPillShown(onCall) ? [onCallSentence(onCall)] : []), "Sources", "Settings", ] diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 655b59a89b..da920252d9 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,19 +215,33 @@ const root = Subscription.make()((entry) => ({ ), }, ), - /** The on-call pill reads the schedules again once a shift hands off. */ - shellOnCallHandoff: entry( + /** The on-call pill's next read: a handoff, the hourly check off call, or a retry. */ + shellOnCallTimer: entry( { atMs: Schema.NullOr(Schema.Number) }, { modelToDependencies: (model) => ({ - atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + atMs: model.session._tag === "SignedIn" ? Shell.onCallReadAt(model.shell) : null, }), dependenciesToStream: ({ atMs }) => atMs === null ? Stream.empty - : Stream.map(Shell.onCallHandoff(atMs), (message) => - Message.GotShellMessage({ message }), - ), + : Stream.map(Shell.onCallTimer(atMs), (message) => Message.GotShellMessage({ message })), + }, + ), + /** Focus and visibility, and a page's schedule writes, refresh the on-call pill. */ + shellOnCallEvents: entry( + { isShellOpen: Schema.Boolean }, + { + modelToDependencies: (model) => ({ + isShellOpen: model.session._tag === "SignedIn" && rendersShell(model.route), + }), + dependenciesToStream: ({ isShellOpen }) => + Stream.when( + Stream.map(Stream.merge(Shell.onCallWakes, Shell.onCallChanges), (message) => + Message.GotShellMessage({ message }), + ), + Effect.sync(() => isShellOpen), + ), }, ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index f5dfdb194d..3ce934a3bd 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,7 +142,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `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`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/sheet.ts` `leftSheet`, `sidePanelSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (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`, `sidebarGroupToggle`, `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. @@ -227,7 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. -- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `dismissalOf`, `pushSheetUrl`, `dismissSheetUrl`: A side sheet held in the URL as `?sheet=`. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 2ca2ec85fd..966070641b 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -12,7 +12,7 @@ import { type SheetSize, } from "@maple/ui/styles/ui/sheet" import { Command } from "foldkit" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" @@ -132,9 +132,9 @@ export const leftSheet = ( ], ) -// RIGHT SHEET +// SIDE PANEL SHEET -export type RightSheetOptions = Readonly<{ +export type SidePanelSheetOptions = Readonly<{ sheet: Dialog.Model title: string description?: string @@ -142,8 +142,11 @@ export type RightSheetOptions = Readonly<{ size?: SheetSize /** The scrolling body. */ body: ReadonlyArray - /** The sticky footer (Cancel, then the primary action); none renders no footer. */ - footer?: ReadonlyArray + /** + * The sticky footer (Cancel, then the primary action), handed the close button's attributes so + * Cancel dismisses like Escape does; none renders no footer. + */ + footer?: (close: ReadonlyArray) => ReadonlyArray toParentMessage: (message: Dialog.Message) => Message }> @@ -155,7 +158,10 @@ const REDUCED_MOTION = * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). */ -export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { +export const sidePanelSheet = ( + h: HtmlBuilder, + options: SidePanelSheetOptions, +): Html => { const { sheet } = options return h.submodel({ slotId: `${sheet.id}-sheet`, @@ -167,7 +173,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: () => [ + toContent: (close) => [ DialogView.dialogHeader(h, "sheet", [ DialogView.dialogTitle(h, "sheet", sheet.id, options.title), ...(options.description === undefined @@ -177,7 +183,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet DialogView.dialogPanel(h, "sheet", sheet.id, options.body), ...(options.footer === undefined ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer)]), + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), ], }, toParentMessage: options.toParentMessage, diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 67c77e1c02..528a4fd6cd 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -226,8 +226,7 @@ export type SidebarGroupToggleOptions = Readonly<{ isExpanded: boolean /** The `sidebarGroupContent` id it shows and hides. */ controlsId: string - /** None: the group cannot collapse right now (it holds the current page). */ - onToggle: Message | null + onToggle: Message }> /** A group label that collapses its rows: a chevron button with `aria-expanded`. */ @@ -241,17 +240,14 @@ export const sidebarGroupToggle = ( h.Class( cn( ...sidebarGroupLabelStyles, - "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", - options.onToggle === null - ? "cursor-default" - : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + "group/toggle h-6 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", ), ), h.DataAttribute("sidebar", "group-label"), h.DataAttribute("slot", "sidebar-group-toggle"), h.AriaExpanded(options.isExpanded), h.AriaControls(options.controlsId), - ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + h.OnClick(options.onToggle), ], [ h.span([h.Class("flex-1 truncate")], [options.label]), From 18c83a33daccd2249eb142049d4c926f36e53b63 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:00:58 +0100 Subject: [PATCH 03/35] feat(alerts): sections at their own paths, sidebar sub-items, silence side panel - Each hub section has its own path (/alerts/rules, /alerts/uptime, /alerts/slos, /alerts/silences, /alerts/maintenance, /alerts/on-call, /alerts/routing); /alerts stays the Inbox. One page instance serves them all, so a section change reads nothing it already has. - Old /alerts?tab= links replace themselves with the section's path and keep their other params ("oncall" and "settings" map to on-call and routing). - The sidebar's Alerts row lists the sections when active and replaces the hub's own section rail. Sub-item aria-current now follows the most specific match. - New silence opens in a URL-held side panel (?sheet=silence) with a sticky Cancel / Save footer; Back, Cancel, Escape and a save close it. sidePanelSheet gains onSubmit so the panel is a form. --- .../components/settings/audit-log-resource.ts | 4 +- apps/web/src/lab/alerts-hub-lab.ts | 40 ++++- apps/web/src/lab/anomaly-alerts-lab.ts | 6 +- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/escalation-lab.ts | 4 +- apps/web/src/lab/maintenance-lab.ts | 6 +- apps/web/src/lab/oncall-lab.ts | 6 +- apps/web/src/lab/registry.ts | 2 +- apps/web/src/lab/shell-lab.ts | 7 +- apps/web/src/lab/slos-lab.ts | 4 +- .../alerts/editor/editor-chains.scene.test.ts | 2 +- apps/web/src/page/alerts/hub/command.ts | 17 +++ apps/web/src/page/alerts/hub/derive.test.ts | 4 +- apps/web/src/page/alerts/hub/derive.ts | 33 ----- .../alerts/hub/hub-anomalies.scene.test.ts | 33 ++--- .../page/alerts/hub/hub-chains.scene.test.ts | 4 +- .../alerts/hub/hub-maintenance.scene.test.ts | 21 +-- .../page/alerts/hub/hub-oncall.scene.test.ts | 33 +++-- .../alerts/hub/hub-silences.scene.test.ts | 137 ++++++++++++++---- .../page/alerts/hub/hub-slos.scene.test.ts | 14 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 16 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 80 ++++++---- apps/web/src/page/alerts/hub/index.ts | 8 +- apps/web/src/page/alerts/hub/message.ts | 2 + apps/web/src/page/alerts/hub/model.ts | 8 +- apps/web/src/page/alerts/hub/update.ts | 135 +++++++++++------ apps/web/src/page/alerts/hub/view-empty.ts | 2 +- apps/web/src/page/alerts/hub/view-inbox.ts | 4 - apps/web/src/page/alerts/hub/view.ts | 118 ++------------- .../src/page/alerts/rule/rule.scene.test.ts | 2 +- apps/web/src/page/alerts/rule/update.ts | 2 +- .../src/page/alerts/rule/view-diagnosis.ts | 2 +- .../page/alerts/shared/escalation-chains.ts | 2 +- apps/web/src/page/alerts/shared/links.ts | 16 +- .../page/alerts/shared/maintenance-windows.ts | 2 +- .../page/alerts/shared/oncall-schedules.ts | 2 +- .../src/page/alerts/shared/slo-commands.ts | 2 +- apps/web/src/page/alerts/shared/test-rows.ts | 9 ++ .../src/page/alerts/shared/uptime-commands.ts | 2 +- .../src/page/alerts/silence-dialog/dialog.ts | 9 +- .../src/page/alerts/silence-dialog/view.ts | 55 ++++--- .../services/detail/uptime-card.scene.test.ts | 2 +- .../page/settings/audit-log/view-detail.ts | 10 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/index.ts | 2 +- apps/web/src/route/redirect.ts | 35 ++++- apps/web/src/route/route.test.ts | 38 ++++- apps/web/src/route/routers.ts | 7 + apps/web/src/route/search.ts | 25 +++- apps/web/src/shell/nav.ts | 43 +++++- apps/web/src/shell/oncall.scene.test.ts | 4 +- apps/web/src/shell/shell.scene.test.ts | 50 +++++++ packages/views/src/ui/sheet.ts | 30 ++-- packages/views/src/ui/sidebar.ts | 3 +- 54 files changed, 693 insertions(+), 416 deletions(-) diff --git a/apps/web/src/components/settings/audit-log-resource.ts b/apps/web/src/components/settings/audit-log-resource.ts index 4432954756..72c9590f98 100644 --- a/apps/web/src/components/settings/audit-log-resource.ts +++ b/apps/web/src/components/settings/audit-log-resource.ts @@ -14,7 +14,7 @@ export type AuditResourceLink = | { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } } | { readonly to: "/investigations/$id"; readonly params: { readonly id: string } } | { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } } - | { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } } + | { readonly to: "/alerts/routing" } | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } | { readonly to: "/settings"; readonly search: { readonly tab: string } } @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } } } case "alert_destination": - return { to: "/alerts", search: { tab: "routing" } } + return { to: "/alerts/routing" } case "scrape_target": return { to: "/integrations", search: { integration: "prometheus" } } case "gcp_connector": diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index 8a4e55067a..e87756b96b 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" +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" @@ -11,13 +11,15 @@ import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view" import * as SilenceDialog from "../page/alerts/silence-dialog/dialog" -import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows" +import { alertsHref } from "../page/alerts/shared/links" +import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" -// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over +// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. The shell +// stands on the section's own path, and `?sheet=` is played here rather than pushed. export const LabState = Schema.Literals([ "inbox", @@ -55,9 +57,15 @@ const CONTEXT: PageContext = { } const GRANTS = Policy.grantsOf(["org:engineer"]) -/** The hub's reads and writes are dropped: the fixture is all the data there is. */ +/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name)) + Arr.filter( + commands ?? [], + (command) => + !/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test( + command.name, + ), + ) const MINUTE = 60_000 const iso = (ms: number) => new Date(ms).toISOString() @@ -140,10 +148,23 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), CONTEXT) + +/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ +const step = (model: HubModel.Model, message: HubMessage.Message) => { + const next = HubUpdate.update(model, message, CONTEXT) + const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) + if (asked._tag === "None") return next + const routed = routeTo( + next.model, + asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + ) + return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } +} const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), CONTEXT).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -201,6 +222,9 @@ export const update = (model: Model, message: Message): Return => export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub) +/** The section's own path, so the sidebar opens Alerts on it. */ +export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab) + export const view = (model: Model, h: HtmlBuilder): Html => h.submodel({ slotId: "lab-alerts-hub", diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 6a9e917e01..805810cc42 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -46,6 +46,8 @@ import { ruleId, ruleRow, stateRow, + hubAt, + type HubAt, } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -156,7 +158,7 @@ const RESOLVED = [ ] const hubFor = (state: LabState) => { - const search: HubModel.Model["search"] = + const search: HubAt = state === "hub-rules" ? { tab: "rules" } : state === "hub-anomalies-resolved" @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => { ? [incidentRow({ rule_name: "Payment p95 latency" })] : [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED] return fold( - HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model, + HubUpdate.init(hubAt(search), CONTEXT).model, [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index de2a9f57ed..9e69ef8a00 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,5 +1,5 @@ import { defineShellLab } from "../shell-lab" -import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" +import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" // `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). @@ -11,6 +11,6 @@ export const lab = defineShellLab("Alerts hub", { init, update, view, - currentPath: "/lab/alerts-hub", + currentPath, breadcrumbs, }) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 08d3416e53..f710bea7d1 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -28,7 +28,7 @@ import { populatedChains, runOf, } from "../page/alerts/shared/escalation-test-fixtures" -import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -112,7 +112,7 @@ const SAVE_FAILURE: LoadFailure = { } const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/maintenance-lab.ts b/apps/web/src/lab/maintenance-lab.ts index b8a04cb397..72cf955c67 100644 --- a/apps/web/src/lab/maintenance-lab.ts +++ b/apps/web/src/lab/maintenance-lab.ts @@ -15,7 +15,7 @@ import { weeklyWindow, } from "../page/alerts/shared/maintenance-test-fixtures" import { windowPublicId } from "../page/alerts/shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -78,7 +78,7 @@ const fold = (start: HubModel.Model, messages: ReadonlyArray const dialog = (message: WindowDialog.Message) => HubMessage.Message.GotWindowDialogMessage({ message }) -const searchFor = (state: LabState): HubModel.Model["search"] => +const searchFor = (state: LabState): HubAt => state === "silences" ? { tab: "silences" } : state === "silences-filtered" @@ -112,7 +112,7 @@ const extraFor = (state: LabState): ReadonlyArray => : [] const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: searchFor(state) }, CONTEXT).model + const entered = HubUpdate.init(hubAt(searchFor(state)), CONTEXT).model const windows = state === "empty" ? [] : populatedWindows return fold(entered, [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index e94302533f..5f7dd1883f 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -40,7 +40,7 @@ import { populatedSchedules, schedulePublicId, } from "../page/alerts/shared/oncall-test-fixtures" -import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, ORG, ruleRow, hubAt } 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" @@ -128,8 +128,8 @@ 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 tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "on-call" + const entered = HubUpdate.init(hubAt({ tab }), CONTEXT).model const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW) const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")] return fold( diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 8ec40d1192..843e1bdbb2 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index e63276dbb6..2003012daa 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -31,7 +31,8 @@ export type ShellLabPage Update.Return view: (model: PageModel, h: HtmlBuilder) => Html subscriptions?: Subscription.Subscriptions - currentPath: string + /** Fixed, or read from the page (a lab standing on the route it renders). */ + currentPath: string | ((model: PageModel) => string) /** Fixed, or read from the page (a lab switching between pages). */ breadcrumbs: | ReadonlyArray @@ -88,6 +89,8 @@ export const defineShellLab = (page.view) const before = page.before const beforeView = before === undefined ? null : Submodel.defineView(before) + const currentPathOf = (model: PageModel) => + typeof page.currentPath === "function" ? page.currentPath(model) : page.currentPath const breadcrumbsOf = (model: PageModel) => typeof page.breadcrumbs === "function" ? page.breadcrumbs(model) : page.breadcrumbs const own = Subscription.lift(page.subscriptions ?? {})({ @@ -154,7 +157,7 @@ export const defineShellLab = h.submodel({ diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 58b043672d..c50337a9ba 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -20,7 +20,7 @@ import { sloUuid, type SloScenario, } from "../page/alerts/shared/slo-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG } from "../page/alerts/shared/test-rows" +import { destinationRow, incidentRow, NOW, ORG, hubAt } from "../page/alerts/shared/test-rows" import * as SloMessage from "../page/alerts/slo/message" import * as SloModel from "../page/alerts/slo/model" import * as SloUpdate from "../page/alerts/slo/update" @@ -106,7 +106,7 @@ const INCIDENT = incidentRow({ }) const hubFor = (state: LabState) => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "slos" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "slos" }), CONTEXT).model const rows = state === "tab-empty" ? [] diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index fbbb61e587..496064507e 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,7 @@ describe("alert rule editor, escalation chains", () => { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts?tab=routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), ) }) diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 785a833772..abc46e5491 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -17,6 +17,7 @@ import { readThrough } from "../../../data/read-cache" import { readGrants } from "../../../data/session-tenant" import { toLoadFailure } from "@maple/views/data/load-failure" import { Command } from "foldkit" +import { dismissSheetUrl, pushSheetUrl } from "../../kit/sheet-param" import { deleteDestination, listDeliveries, @@ -41,6 +42,22 @@ import { import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ +export const PushHubSheet = Command.define("PushAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + +/** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ +export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + export const ReadHubClock = Command.define("ReadAlertsHubClock", { messages: [Message.ReadClock], execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), diff --git a/apps/web/src/page/alerts/hub/derive.test.ts b/apps/web/src/page/alerts/hub/derive.test.ts index 3ee3c8e8d2..b95e0d6e1d 100644 --- a/apps/web/src/page/alerts/hub/derive.test.ts +++ b/apps/web/src/page/alerts/hub/derive.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Array as Arr, Option, Schema } from "effect" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { incidentRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, NOW, ORG, ruleRow, hubAt } from "../shared/test-rows" import { overviewOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -17,7 +17,7 @@ const loaded = (): Model => Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), ], - init({ _tag: "Alerts", search: {} }, context).model, + init(hubAt(), context).model, (model, message) => update(model, message, context).model, ) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 572950d044..e9f48fefbb 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -112,39 +112,6 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = - | "inbox" - | "rules" - | "uptime" - | "slos" - | "silences" - | "maintenance" - | "oncall" - | "routing" - -/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ -export const resolveTab = (tab: string | undefined): AlertsTab => { - switch (tab) { - case "rules": - return "rules" - case "uptime": - return "uptime" - case "slos": - return "slos" - case "silences": - return "silences" - case "maintenance": - return "maintenance" - case "oncall": - return "oncall" - case "routing": - case "settings": - return "routing" - default: - return "inbox" - } -} - /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { switch (status) { diff --git a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts index 9d6e02aedf..1329c7864b 100644 --- a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts @@ -21,7 +21,17 @@ import { defaultAnomalyRuleRow, scopedAnomalyRuleRow, } from "../shared/anomaly-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, ruleMenuId, update } from "./update" @@ -57,7 +67,7 @@ const RESOLVED_CHECKOUT = anomalyIncidentRow({ }) const loaded = ( - search: Model["search"], + at: HubAt, incidents = [ incidentRow({ rule_name: "Payment p95 latency" }), OPEN_CHECKOUT, @@ -87,7 +97,7 @@ const loaded = ( Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - init({ _tag: "Alerts", search }, context).model, + init(hubAt(at), context).model, (model, message) => pageUpdate(model, message).model, ) @@ -100,7 +110,7 @@ describe("alerts hub: anomalies", () => { click(role("button", { name: /^Anomalies/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=inbox&signal=anomaly", + url: "/alerts?signal=anomaly", isReplace: false, }), ), @@ -245,19 +255,4 @@ describe("alerts hub: anomalies", () => { expectView(text("Search anomalies")).toExist(), ) }) - - test("leaving the Inbox drops the Anomalies view's filters from the tab links", () => { - scene( - { update: pageUpdate, view }, - given(loaded({ signal: "anomaly", anomalyStatus: "resolved", serviceName: "checkout" })), - expectView(role("link", { name: /^Rules/ })).toHaveAttr( - "href", - "/alerts?tab=rules&serviceName=checkout", - ), - expectView(role("link", { name: /^Inbox/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly&anomalyStatus=resolved", - ), - ) - }) }) 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 623064ac52..f7adc99b7f 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 @@ -24,7 +24,7 @@ import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" -import { NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleId, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { chainMenuId, init, update } from "./update" @@ -43,7 +43,7 @@ const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Chec const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const onRouting = (grants: ReturnType, chains: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "routing" } }, context) + const entered = init(hubAt({ tab: "routing" }), context) expect(names(entered.commands)).toContain("FetchAlertsHubChains") return Arr.reduce( [ diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts index de1fbab3af..570e9b8986 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../shared/links" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" @@ -26,7 +27,7 @@ import { weeklyWindow, } from "../shared/maintenance-test-fixtures" import { windowPublicId, windowSilencesHref } from "../shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update, windowMenuId } from "./update" @@ -49,14 +50,14 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onTab = ( - search: Model["search"], + at: HubAt, options: Readonly<{ grants?: ReturnType windows: ReadonlyArray silences?: ReadonlyArray }>, ): Model => { - const entered = init({ _tag: "Alerts", search }, context) + const entered = init(hubAt(at), context) const loaded = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -86,7 +87,7 @@ const onMaintenance = (windows: ReadonlyArray, grants describe("alerts hub, Maintenance tab", () => { test("reads the windows only while the Maintenance or Silences tab is open", () => { - const fetched = (tab: string) => names(init({ _tag: "Alerts", search: { tab } }, context).commands) + const fetched = (tab: AlertsTab) => names(init(hubAt({ tab }), context).commands) expect(fetched("maintenance")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("silences")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("rules")).not.toContain("FetchAlertsHubMaintenanceWindows") @@ -129,7 +130,7 @@ describe("alerts hub, Maintenance tab", () => { test("a window change on the Maintenance tab makes the Silences tab read its silences again", () => { const model = onTab({ tab: "silences" }, { windows: [weeklyWindow], silences: silencesWithWindows }) const onMaint = update(model, Message.ReadClock({ nowMs: NOW }), context).model - const moved = routeChanged(onMaint, { _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const moved = routeChanged(onMaint, hubAt({ tab: "maintenance" }), context).model const toggled = pageUpdate(moved, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).model const settled = pageUpdate( toggled, @@ -141,7 +142,7 @@ describe("alerts hub, Maintenance tab", () => { // Following the row's Occurrences link lands on the Silences tab filtered to the window. const back = routeChanged( settled, - { _tag: "Alerts", search: { tab: "silences", window: windowPublicId(weeklyWindow.id) } }, + hubAt({ tab: "silences", window: windowPublicId(weeklyWindow.id) }), context, ) expect(back.commands ?? []).toContainEqual( @@ -150,7 +151,7 @@ describe("alerts hub, Maintenance tab", () => { args: expect.objectContaining({ windowId: weeklyWindow.id }), }), ) - const unfiltered = routeChanged(settled, { _tag: "Alerts", search: { tab: "silences" } }, context) + const unfiltered = routeChanged(settled, hubAt({ tab: "silences" }), context) expect(names(unfiltered.commands)).toContain("FetchAlertsHubSilences") }) @@ -247,7 +248,7 @@ describe("alerts hub, Maintenance tab", () => { const step = pageUpdate(onMaintenance([weeklyWindow]), Message.ClickedNewWindow()) expect(step.model.windowDialog.dialog.isOpen).toBe(true) expect(names(step.commands)).toContain("ReadMaintenanceWindowZones") - const early = init({ _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const early = init(hubAt({ tab: "maintenance" }), context).model expect(pageUpdate(early, Message.ClickedNewWindow()).model.windowDialog.dialog.isOpen).toBe(false) }) }) @@ -268,13 +269,13 @@ describe("alerts hub, Silences tab with maintenance windows", () => { click(role("button", { name: "Remove Window: Nightly database backup" })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "silences" }), + url: alertsHref({}, "silences"), isReplace: false, }), ), ) // The route without the param reads every silence and drops the chip. - const cleared = routeChanged(model, { _tag: "Alerts", search: { tab: "silences" } }, context) + const cleared = routeChanged(model, hubAt({ tab: "silences" }), context) const fetch = Arr.findFirst(cleared.commands ?? [], ({ name }) => name === "FetchAlertsHubSilences") expect(fetch).toMatchObject({ value: { args: { windowId: null } } }) const shown = pageUpdate( 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 index 5376879745..2102c64f20 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../shared/links" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" @@ -16,7 +17,7 @@ import { populatedSchedules, scheduleUuid, } from "../shared/oncall-test-fixtures" -import { incidentRow, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, ORG, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, scheduleMenuId, update } from "./update" @@ -34,11 +35,11 @@ const MEMBER = Policy.grantsOf(["org:member"]) const SCHEDULES = populatedSchedules(ONCALL_NOW) const onTab = ( - tab: string, + tab: AlertsTab, grants: ReturnType, schedules: ReadonlyArray | null, ) => { - const entered = init({ _tag: "Alerts", search: { tab } }, context) + const entered = init(hubAt({ tab }), context) expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") return Arr.reduce( [ @@ -72,7 +73,7 @@ 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, [])), + given(onTab("on-call", ENGINEER, [])), expectView(text("No on-call schedules yet")).toExist(), expectView(text(/people take turns in layers/u)).toExist(), expectView(role("link", { name: "Create schedule" })).toExist(), @@ -82,7 +83,7 @@ describe("alerts hub, on-call schedules", () => { 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)), + given(onTab("on-call", ENGINEER, SCHEDULES)), expectView(text("Checkout primary")).toExist(), expectView(text("Europe/Berlin · 3 layers")).toExist(), expectView(text("Sofia Alvarez")).toExist(), @@ -97,7 +98,7 @@ describe("alerts hub, on-call schedules", () => { test("a member sees the schedules but cannot create, edit or delete one", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", MEMBER, SCHEDULES)), + given(onTab("on-call", 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(), @@ -122,7 +123,7 @@ describe("alerts hub, on-call schedules", () => { }) test("the clock reads the schedules again once a shown shift has handed off, not before", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) expect( names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 60_000 })).commands), ).not.toContain("FetchAlertsHubSchedules") @@ -169,7 +170,7 @@ describe("alerts hub, on-call schedules", () => { }) 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 entered = init(hubAt({ tab: "inbox" }), context) const model = Arr.reduce( [ Message.ReadClock({ nowMs: ONCALL_NOW }), @@ -196,15 +197,15 @@ describe("alerts hub, on-call schedules", () => { }) test("a failed members read is asked again on the next route change", () => { - const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const entered = init(hubAt({ 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") + expect(names(routeChanged(failed, hubAt({ tab: "on-call" }), context).commands)).toContain( + "FetchAlertsHubMembers", + ) }) test("a chain's summary names the people and schedules it pages", () => { @@ -220,7 +221,7 @@ describe("alerts hub, on-call schedules", () => { }) test("a refused delete names the chains that still page the schedule", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) const asked = pageUpdate( pageUpdate( @@ -263,11 +264,11 @@ describe("alerts hub, on-call schedules", () => { }) test("members are read only by the tabs that name people, once", () => { - const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + const rules = init(hubAt({ tab: "rules" }), context) expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") - const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + const oncall = routeChanged(rules.model, hubAt({ tab: "on-call" }), context) expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") - const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + const routing = routeChanged(oncall.model, hubAt({ tab: "routing" }), context) expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") }) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 8e741f9fae..2d7d322363 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -3,27 +3,17 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - text, - withViewInputs, -} from "foldkit/scene" +import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG } from "../shared/test-rows" +import { NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { view as pageView } from "./view" +import * as SilenceDialog from "../silence-dialog/dialog" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -57,7 +47,7 @@ const silence = ( } const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + const entered = init(hubAt({ tab: "silences" }), context) expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") return Arr.reduce( [ @@ -165,7 +155,7 @@ describe("alerts hub, Silences tab", () => { }) test("reads the clock again when the list lands, so a new silence is placed by it", () => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const entered = init(hubAt({ tab: "silences" }), context).model const step = pageUpdate( entered, Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), @@ -195,24 +185,117 @@ describe("alerts hub, Silences tab", () => { expectView(text(/· 1 rule$/u)).toExist(), ) }) +}) + +const SILENCES_URL = "/alerts/silences" +const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined) => + Arr.map(commands ?? [], ({ name, args }) => [name, args]) +const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) - test("New silence opens the dialog with typed scope fields", () => { +/** The Silences tab with `?sheet=silence` followed: the panel is open. */ +const withPanel = (grants = ENGINEER) => + routeChanged(onSilences(grants, []), hubAt({ tab: "silences", sheet: "silence" }), context) + +describe("alerts hub, silence side panel", () => { + test("New silence opens the panel through the URL, as a history entry on this section", () => { + const model = onSilences(ENGINEER, []) + const step = pageUpdate(model, Message.ClickedNewSilence()) + expect(callsOf(step.commands)).toEqual([ + ["PushAlertsHubSheet", { href: SILENCES_URL, id: "silence" }], + ]) + expect(step.model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("the URL opens it with typed scope fields, Cancel and a save that waits for a valid form", () => { + const opened = withPanel() + expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(onSilences(ENGINEER, [])), - click(role("button", { name: "New silence" })), - Command.resolve( - Animation.WaitForPaint, - Animation.Message.CompletedWaitForPaint({ generation: 1 }), - ), - Command.resolve( - Animation.WaitForAnimationSettled, - Animation.Message.EndedAnimation({ generation: 1 }), - ), + given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), ) }) + + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + expect(entered.silenceDialog.dialog.isOpen).toBe(false) + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + expect(clocked.silenceDialog.dialog.isOpen).toBe(false) + const granted = pageUpdate( + clocked, + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ).model + expect(granted.silenceDialog.dialog.isOpen).toBe(true) + expect(granted.silenceDialog.nowMs).toBe(NOW) + expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { + const dismissed = [ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ] + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(cancelled.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + const escaped = pageUpdate( + withPanel().model, + silenceMessage( + SilenceDialog.Message.GotDialogMessage({ message: Dialog.Message.PressedEscape() }), + ), + ) + expect(escaped.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(escaped.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + }) + + test("Back closes it without touching the URL again, and the form starts fresh when it reopens", () => { + const typed = pageUpdate( + withPanel().model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "x" })), + ) + const back = routeChanged(typed.model, hubAt({ tab: "silences" }), context) + expect(back.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(back.commands)).not.toContain("DismissAlertsHubSheet") + const reopened = routeChanged(back.model, hubAt({ tab: "silences", sheet: "silence" }), context) + expect(reopened.model.silenceDialog.dialog.isOpen).toBe(true) + expect(reopened.model.silenceDialog.reason).toBe("") + }) + + test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { + const filled = Arr.reduce( + [ + silenceMessage( + SilenceDialog.Message.ChangedScopeField({ field: "services", value: "checkout" }), + ), + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saving = pageUpdate(filled, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(saving.commands)).toEqual(["SaveAlertSilence"]) + const saved = pageUpdate( + saving.model, + silenceMessage( + SilenceDialog.Message.CompletedSave({ + openCount: saving.model.silenceDialog.openCount, + result: Result.succeed(silence({ startMin: 0, endMin: 60 })), + }), + ), + ) + expect(saved.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(saved.commands)).toEqual( + expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), + ) + expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + }) }) diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index b6b9ff22e4..6526d14694 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -8,7 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" import { sloHref } from "../shared/slo-commands" import { checkoutSlo, SLO_RULE_UUID, searchSlo, sloStatus, sloUuid } from "../shared/slo-test-fixtures" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model, SloListRow } from "./model" import { init, update } from "./update" @@ -25,7 +25,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onSlos = (grants = ENGINEER): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "slos" } }, context).model + const entered = init(hubAt({ tab: "slos" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants })], entered, @@ -46,12 +46,8 @@ const HEALTHY_AND_BURNING: ReadonlyArray = [ describe("alerts hub, SLOs tab", () => { test("the SLOs and their statuses are read only while the tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubSlos", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "slos" } }, context).commands)).toContain( - "FetchAlertsHubSlos", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubSlos") + expect(names(init(hubAt({ tab: "slos" }), context).commands)).toContain("FetchAlertsHubSlos") }) test("an org without SLOs is told what one is and offered the first", () => { @@ -231,7 +227,7 @@ describe("alerts hub, SLOs tab", () => { describe("alerts hub inbox, SLO incidents", () => { test("an org whose only rules are SLOs' sees their open incidents, not the empty state", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index a62ab85d2a..5694c615e2 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -18,7 +18,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { MountMenu } from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { monitorHref } from "../shared/uptime-commands" import type { UptimeMonitor } from "../shared/uptime-derive" import { downMonitor, httpMonitor, monitorId, pausedMonitor } from "../shared/uptime-test-monitors" @@ -38,7 +38,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onUptime = (): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "uptime" } }, context).model + const entered = init(hubAt({ tab: "uptime" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER })], entered, @@ -74,7 +74,7 @@ const NAMESPACES = { "checkout-api": "shop", "payments-api": "billing" } describe("alerts hub inbox, uptime incidents", () => { test("an uptime incident reads as its monitor being down, without the ungrouped key", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -123,16 +123,12 @@ describe("alerts hub, Uptime tab", () => { }) test("the monitors are read only when the Uptime tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubMonitors", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( - "FetchAlertsHubMonitors", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubMonitors") + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain("FetchAlertsHubMonitors") }) test("opening the tab also reads which namespace each service reports under", () => { - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain( "FetchAlertsHubServiceNamespaces", ) }) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 1327330cb8..9e44f27e3d 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -25,10 +25,20 @@ import { encodePublicId, PublicIdPrefixes, publicError, V2AlertIncident } from " import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, ruleMenuId, update } from "./update" +import { init, routeChanged, ruleMenuId, update } from "./update" import { breadcrumbs, view as pageView } from "./view" const ENGINEER = Policy.grantsOf(["org:engineer"]) @@ -42,10 +52,10 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = const RULE_2 = Schema.decodeSync(AlertRuleId)(ruleId(2)) const DESTINATION = Schema.decodeSync(AlertDestinationId)(destinationRow().id) -const entered = (search: Model["search"] = {}) => init({ _tag: "Alerts", search }, context) +const entered = (at: HubAt = {}) => init(hubAt(at), context) /** The four shapes and the clock have answered: rule 2 is firing, rule 4 is off. */ -const loaded = (search: Model["search"] = {}): Model => +const loaded = (at: HubAt = {}): Model => Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -68,7 +78,7 @@ const loaded = (search: Model["search"] = {}): Model => Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - entered(search).model, + entered(at).model, (model, message) => pageUpdate(model, message).model, ) @@ -96,7 +106,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(loaded()), expectView(text("1 firing")).toExist(), - expectView(role("link", { name: /^Inbox\s*1$/ })).toExist(), expectView(role("link", { name: "P95 latency 1.84s on all" })).toHaveAttr( "href", "/alerts/incidents/0199a003-0000-7000-8000-000000000001", @@ -104,31 +113,42 @@ describe("alerts hub", () => { ) }) - test("the section rail marks the open section, which titles the page and its crumbs", () => { + test("each section's own path opens it, titled by it with its crumbs", () => { + const sections = [ + ["rules", "Rules"], + ["uptime", "Uptime"], + ["slos", "SLOs"], + ["silences", "Silences"], + ["maintenance", "Maintenance"], + ["on-call", "On-call"], + ["routing", "Routing"], + ] as const + for (const [tab, label] of sections) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(role("heading", { name: label })).toExist(), + ) + expect(breadcrumbs(loaded({ tab }))).toEqual([{ label: "Alerts", href: "/alerts" }, { label }]) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "rules" })), - expectView(role("link", { name: /^Rules\s*3$/ })).toHaveAttr("aria-current", "page"), - expectView(role("link", { name: /^Inbox\s*1$/ })).not.toHaveAttr("aria-current", "page"), - expectView(role("heading", { name: "Rules" })).toExist(), - ) - // The anomaly filters belong to the Inbox: its row keeps them, every other row drops them. - scene( - { update: pageUpdate, view }, - given(loaded({ tab: "inbox", signal: "anomaly", serviceName: "checkout" })), - expectView(role("link", { name: /^Inbox\s*1$/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly", - ), - expectView(role("link", { name: "Silences" })).toHaveAttr( - "href", - "/alerts?tab=silences&serviceName=checkout", - ), + given(loaded()), + expectView(role("heading", { name: "Inbox" })).toExist(), ) - expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([ - { label: "Alerts", href: "/alerts" }, - { label: "Silences" }, + }) + + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { + const inbox = loaded() + expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ + "ReadAlertsHubClock", ]) + const uptime = routeChanged(inbox, hubAt({ tab: "uptime" }), context) + expect(names(uptime.commands)).toContain("FetchAlertsHubMonitors") + const back = routeChanged(uptime.model, hubAt(), context) + expect(names(routeChanged(back.model, hubAt({ tab: "uptime" }), context).commands)).not.toContain( + "FetchAlertsHubMonitors", + ) }) const v2Incident = Schema.decodeSync(V2AlertIncident)({ @@ -183,8 +203,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(acknowledged), expectView(text("0 firing")).toExist(), - // The tab's red badge counts only what nobody is on yet. - expectView(role("link", { name: "Inbox" })).toExist(), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(), @@ -313,7 +331,7 @@ describe("alerts hub", () => { click(role("button", { name: /^Firing/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=rules&status=firing", + url: "/alerts/rules?status=firing", isReplace: false, }), ), @@ -323,7 +341,7 @@ describe("alerts hub", () => { Message.ClickedStatusFilter({ bucket: "firing" }), ) expect(cleared.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=rules", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts/rules", isReplace: false }), ) }) diff --git a/apps/web/src/page/alerts/hub/index.ts b/apps/web/src/page/alerts/hub/index.ts index d004453ea2..b2ad39c61d 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -5,12 +5,16 @@ import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { breadcrumbs, view } from "./view" -/** `/alerts`: the incident-first hub and its sections. One instance per org; the section is search. */ +/** + * `/alerts` (the Inbox) and `/alerts/
`: the incident-first hub as one instance per org, so a + * section change keeps what the last one read. + */ export const page = definePage( "AlertsHub", { Model, Message }, { - routes: ["Alerts"], + routes: ["Alerts", "AlertsSection"], + key: () => "alerts", init, update, routeChanged, diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..5f0c12fffb 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,6 +52,8 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ + /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ + CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 4c448ddcad..c23d373893 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,7 +28,7 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsSearch } from "../../../route/search" +import { AlertsHubSearch, AlertsSection } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" @@ -53,8 +53,12 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) +/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) + export const Model = Schema.Struct({ - search: AlertsSearch, + tab: AlertsTab, + search: AlertsHubSearch, /** `Clock.currentTimeMillis` when the overview model was built; `null` until read. */ nowMs: Schema.NullOr(Schema.Number), rules: Rows(AlertRuleRowSchema), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 935b7a6cb4..f47c40d86d 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -62,19 +62,28 @@ import { EndHubSilence, FetchHubSilences, DeleteWindow, + DismissHubSheet, FetchHubWindows, + PushHubSheet, ToggleWindow, } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" -import { resolveStatus, resolveTab } from "./derive" +import { resolveStatus } from "./derive" import { Message, type RuleBucket } from "./message" -import type { Model } from "./model" +import { canManage, type Model } from "./model" type Return = PageReturn type Commands = ReadonlyArray> type Requested = Readonly<{ model: Model; commands: Commands }> -type HubRoute = Extract +export type HubRoute = Extract + +/** The section and filters a hub route names; a legacy `tab` on `/alerts` is left out. */ +const fromRoute = (route: HubRoute): Pick => { + if (route._tag === "AlertsSection") return { tab: route.section, search: route.search } + const { tab: _legacyTab, ...search } = route.search + return { tab: "inbox", search } +} const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" @@ -115,7 +124,7 @@ const requestDeliveries = (model: Model, context: PageContext, mode: Keyed.Reque /** The Routing tab mounts its own deliveries read; the overview's stays mounted with the tab counts. */ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keyed.RequestMode): Requested => { const key = deliveriesKey(context) - return resolveTab(model.search.tab) !== "routing" + return model.tab !== "routing" ? { model, commands: [] } : Option.match(Keyed.request(model.routingDeliveries, key, mode), { onNone: () => ({ model, commands: [] }), @@ -135,7 +144,7 @@ const silencesKey = (context: PageContext, windowId: AlertMaintenanceWindowId | /** The Silences tab reads the org's silences, or one window's, only while it is open. */ const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "silences") return { model, commands: [] } + if (model.tab !== "silences") return { model, commands: [] } const windowId = Option.getOrNull(windowIdOfParam(model.search.window)) return Option.match(Keyed.requestFor(model.silences, silencesKey(context, windowId), mode), { onNone: () => ({ model, commands: [] }), @@ -156,7 +165,7 @@ const windowsKey = (context: PageContext) => /** The Maintenance tab lists the windows; the Silences tab names the windows its silences belong to. */ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - const tab = resolveTab(model.search.tab) + const tab = model.tab if (tab !== "maintenance" && tab !== "silences") return { model, commands: [] } return Option.match(Keyed.requestFor(model.windows, windowsKey(context), mode), { onNone: () => ({ model, commands: [] }), @@ -174,7 +183,7 @@ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode const refreshWindows = (model: Model, context: PageContext): Return => { const windows = requestWindows(model, context, "Supersede") const silences: Requested = - resolveTab(windows.model.search.tab) === "silences" + windows.model.tab === "silences" ? requestSilences(windows.model, context, "Supersede") : { model: { ...windows.model, silences: Keyed.idle() }, commands: [] } return { model: silences.model, commands: [...windows.commands, ...silences.commands] } @@ -198,7 +207,7 @@ const requestServiceNamespaces = (model: Model, context: PageContext): Requested /** The Uptime tab reads the monitors, and the namespaces they group by, only while it is open. */ const requestMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "uptime") return { model, commands: [] } + if (model.tab !== "uptime") return { model, commands: [] } const namespaces = requestServiceNamespaces(model, context) return Option.match(Keyed.requestFor(namespaces.model.monitors, monitorsKey(context), mode), { onNone: () => namespaces, @@ -214,7 +223,7 @@ const slosKey = (context: PageContext) => /** The SLOs tab reads the SLOs and their statuses only while it is open. */ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "slos") return { model, commands: [] } + if (model.tab !== "slos") return { model, commands: [] } return Option.match(Keyed.requestFor(model.slos, slosKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (slos) => ({ model: { ...model, slos }, commands: [FetchHubSlos({ key: slos.key })] }), @@ -226,8 +235,8 @@ const chainsKey = (context: PageContext) => /** 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 => { - const tab = resolveTab(model.search.tab) - if (tab !== "routing" && tab !== "oncall") return { model, commands: [] } + const tab = model.tab + if (tab !== "routing" && tab !== "on-call") return { model, commands: [] } return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (chains) => ({ @@ -271,10 +280,10 @@ const isSchedulesStale = (model: Model, nowMs: number): boolean => { /** 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 } + const sheet = followSilenceSheet({ ...model, nowMs }, context) + if (!isSchedulesStale(sheet.model, nowMs)) return sheet + const step = requestSchedules({ ...sheet.model, schedulesAskedAtMs: nowMs }, context, "Refresh") + return { model: step.model, commands: [...sheet.commands, ...step.commands] } } /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ @@ -286,15 +295,15 @@ const refreshDeliveries = (model: Model, context: PageContext) => { /** 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") + const tab = model.tab + return model.isMembersRequested || (tab !== "inbox" && tab !== "on-call" && tab !== "routing") ? { model, commands: [] } : { model: { ...model, isMembersRequested: true }, commands: [FetchHubMembers()] } } export const init = (route: HubRoute, context: PageContext): Return => { const model: Model = { - search: route.search, + ...fromRoute(route), nowMs: null, rules: null, states: null, @@ -380,7 +389,8 @@ export const init = (route: HubRoute, context: PageContext): Return => { } export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { - const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") + const sheet = followSilenceSheet({ ...model, ...fromRoute(route) }, context) + const routing = requestRoutingDeliveries(sheet.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") const windows = requestWindows(silences.model, context, "Load") @@ -399,13 +409,14 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext ...chains.commands, ...schedules.commands, ...members.commands, + ...sheet.commands, ReadHubClock(), ], } } -const navigate = (search: Model["search"]): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: alertsHref(search), isReplace: false }) +const navigate = (search: Model["search"], tab: Model["tab"]): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: alertsHref(search, tab), isReplace: false }) const toast = (type: "success" | "error", title: string, description = ""): PageOutMessage => PageOutMessage.RequestedToast({ type, title, description }) @@ -734,15 +745,46 @@ const deletedSchedule = ( // SILENCES +/** `?sheet=silence`: the new-silence side panel. */ +export const SILENCE_SHEET = "silence" + +const hubHref = (model: Model): string => alertsHref(model.search, model.tab) + +const silenceCommands = (step: ReturnType): Commands => + FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) + +/** + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens + * once the clock is read, as the form checks its end against it, and only for a viewer who may save. + */ +const followSilenceSheet = (model: Model, context: PageContext): Requested => { + const isInUrl = model.search.sheet === SILENCE_SHEET + const isOpen = model.silenceDialog.dialog.isOpen + if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { + const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + if (!isInUrl && isOpen) { + const step = SilenceDialog.close(model.silenceDialog) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + return { model, commands: [] } +} + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { const next = { ...model, silenceDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotSilenceDialogMessage({ message }), - ) + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes too. + const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + const commands = [ + ...silenceCommands(step), + ...(hasClosed && model.search.sheet === SILENCE_SHEET + ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] + : []), + ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { RequestedToast: ({ type, title, description }) => ({ @@ -1044,10 +1086,10 @@ const updateMenu = (model: Model, context: PageContext, menuId: string, message: const statusToggled = (model: Model, bucket: RuleBucket): Return => ({ model, - outMessage: navigate({ - ...model.search, - status: resolveStatus(model.search.status) === bucket ? undefined : bucket, - }), + outMessage: navigate( + { ...model.search, status: resolveStatus(model.search.status) === bucket ? undefined : bucket }, + model.tab, + ), }) export const update = (model: Model, message: Message, context: PageContext): Return => @@ -1077,29 +1119,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? { model: { ...model, grants } } : { model }, + orgId === context.orgId ? followSilenceSheet({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ model: { ...model, rulesQuery: "" }, - outMessage: navigate({ ...model.search, status: undefined }), + outMessage: navigate({ ...model.search, status: undefined }, model.tab), }), ClickedInboxSignal: ({ signal }) => ({ model, // The status and service belong to the signal view: plain Inbox drops them. - outMessage: navigate( + outMessage: signal === null - ? { ...model.search, signal: undefined, anomalyStatus: undefined, serviceName: undefined } - : { ...model.search, tab: "inbox", signal, anomalyStatus: undefined }, - ), + ? navigate( + { + ...model.search, + signal: undefined, + anomalyStatus: undefined, + serviceName: undefined, + }, + model.tab, + ) + : navigate({ ...model.search, signal, anomalyStatus: undefined }, "inbox"), }), ClickedIncidentStatus: ({ status }) => ({ model, - outMessage: navigate({ ...model.search, anomalyStatus: status === "open" ? undefined : status }), + outMessage: navigate( + { ...model.search, anomalyStatus: status === "open" ? undefined : status }, + model.tab, + ), }), ClickedClearInboxService: () => ({ model, - outMessage: navigate({ ...model.search, serviceName: undefined }), + outMessage: navigate({ ...model.search, serviceName: undefined }, model.tab), }), ClickedShowAllRules: () => ({ model: { ...model, isShowingAllRules: true } }), ClickedToggleRule: ({ ruleId, isEnabled }) => toggleRule(model, ruleId, isEnabled), @@ -1284,11 +1336,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => - liftSilenceDialog( - model, - context, - SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), - ), + model.search.sheet === SILENCE_SHEET + ? { model } + : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, + CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), @@ -1353,7 +1404,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re : updateDeleteWindowDialog(model, Dialog.update(model.deleteWindowDialog, child)), ClickedClearWindowFilter: () => ({ model, - outMessage: navigate({ ...model.search, window: undefined }), + outMessage: navigate({ ...model.search, window: undefined }, model.tab), }), ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index 5332b2de89..44384ef70f 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -122,7 +122,7 @@ const destinationStep = (h: HtmlBuilder, onAddDestination: M | undefined): ], ), onAddDestination === undefined - ? linkButton(h, { size: "sm", href: alertsHref({ tab: "routing" }) }, [ + ? linkButton(h, { size: "sm", href: alertsHref({}, "routing") }, [ icon(h, "PaperPlaneIcon"), "Add destination", ]) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 735256f805..cc2e0ec9f8 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -63,10 +63,6 @@ export type InboxAcknowledge = Readonly<{ const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null -/** The inbox tab's badge: open incidents nobody has acknowledged yet. */ -export const unacknowledgedCount = (incidents: ReadonlyArray): number => - Arr.filter(incidents, (incident) => !isAcknowledged(incident)).length - export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ea70cd025a..94d722f8fa 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -15,13 +15,11 @@ import * as MenuView from "@maple/views/ui/menu-view" import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { navShell, type NavShellCount, type NavShellSection } from "../../kit/nav-shell" import type { BreadcrumbEntry } from "../../../shell" -import { alertsHref, createHref } from "../shared/links" +import { type AlertsTab, alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { - type AlertsTab, anomalyOverviewOf, destinationsOf, isAboutService, @@ -29,13 +27,12 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, - resolveTab, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" import { anomalyInbox, inboxFilters } from "./view-anomalies" -import { inboxTab, unacknowledgedCount } from "./view-inbox" +import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import * as ChainDialogView from "../escalation-dialog/view" @@ -58,7 +55,7 @@ import { import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" -// `AlertsPage` and `AlertsHub`: the `/alerts` page and the rail of its sections. +// `AlertsPage` and `AlertsHub`: `/alerts` and `/alerts/
`. The sidebar lists the sections. type H = HtmlBuilder @@ -69,112 +66,18 @@ const TAB_LABEL = { slos: "SLOs", silences: "Silences", maintenance: "Maintenance", - oncall: "On-call", + "on-call": "On-call", routing: "Routing", } as const satisfies Record -type RailRow = Readonly<{ tab: AlertsTab; icon: IconName }> - -const RAIL: ReadonlyArray }>> = [ - { id: "triage", title: "Triage", rows: [{ tab: "inbox", icon: "BellIcon" }] }, - { - id: "monitors", - title: "Monitors", - rows: [ - { tab: "rules", icon: "ChartLineIcon" }, - { tab: "uptime", icon: "GlobeIcon" }, - { tab: "slos", icon: "GaugeIcon" }, - ], - }, - { - id: "quiet", - title: "Quiet", - rows: [ - { tab: "silences", icon: "BellSlashIcon" }, - { tab: "maintenance", icon: "CalendarIcon" }, - ], - }, - { - id: "response", - title: "Response", - rows: [ - { tab: "oncall", icon: "UsersIcon" }, - { tab: "routing", icon: "BranchForkIcon" }, - ], - }, -] - const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"] -/** `Alerts ›
`: the rail's active row names the page. */ +/** `Alerts ›
`: the sidebar's current sub-item names the page. */ export const breadcrumbs = (model: Model): ReadonlyArray => [ { label: "Alerts", href: alertsHref({}) }, - { label: TAB_LABEL[resolveTab(model.search.tab)] }, + { label: TAB_LABEL[model.tab] }, ] -const countOf = ( - model: Model, - overview: Option.Option, - tab: AlertsTab, -): number | undefined => { - const lengthOf = (rows: Option.Option>) => - Option.getOrUndefined(Option.map(rows, Arr.length)) - switch (tab) { - case "uptime": - return lengthOf(AsyncData.getData(model.monitors.data)) - case "slos": - return lengthOf(AsyncData.getData(model.slos.data)) - case "oncall": - return lengthOf(AsyncData.getData(model.schedules.data)) - case "inbox": - return Option.getOrUndefined( - Option.map(overview, (data) => unacknowledgedCount(data.openIncidents)), - ) - case "rules": - return Option.getOrUndefined(Option.map(overview, (data) => data.rules.length)) - case "silences": - case "maintenance": - case "routing": - return undefined - } -} - -/** The section rail in `dashboardPage`'s side slot, which folds into a sheet below `lg`. */ -const rail = (h: H, model: Model, overview: Option.Option, active: AlertsTab): Html => - navShell( - h, - Arr.map(RAIL, (section): NavShellSection => ({ - id: section.id, - title: section.title, - items: Arr.map(section.rows, (row) => { - const count = countOf(model, overview, row.tab) - const tone: NavShellCount["tone"] = row.tab === "inbox" ? "error" : "muted" - return { - kind: "link", - id: row.tab, - label: TAB_LABEL[row.tab], - icon: row.icon, - count: count !== undefined && count > 0 ? { value: count, tone } : undefined, - // The Anomalies view belongs to the Inbox: other sections drop its filters. - href: alertsHref( - row.tab === "inbox" - ? { ...model.search, tab: row.tab } - : { - ...model.search, - tab: row.tab, - signal: undefined, - anomalyStatus: undefined, - window: undefined, - }, - ), - } - }), - })), - active, - (tab) => tab, - "Alerts sections", - ) - /** A "New rule" menu row: icon, label and one line of what it watches. */ const newRuleItem = ( h: H, @@ -309,7 +212,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), - ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), + ...(canManage(model) && tab === "on-call" && hasSchedules(model) ? [newScheduleButton(h)] : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -440,7 +343,7 @@ const deleteDialog = (h: H, model: Model): Html => { } export const view = Submodel.defineView((model, inputs, h) => { - const tab = resolveTab(model.search.tab) + const tab = model.tab const overview = overviewOf(model) return h.div( [h.Class("contents")], @@ -450,9 +353,6 @@ export const view = Submodel.defineView((model, { title: TAB_LABEL[tab], actions: headerActions(h, model, tab), - filters: rail(h, model, overview, tab), - filtersLabel: "Alerts", - filtersWidth: "w-56", }, [ tab === "routing" @@ -469,7 +369,7 @@ export const view = Submodel.defineView((model, ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) : tab === "maintenance" ? maintenanceTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : tab === "oncall" + : tab === "on-call" ? oncallTab(h, model, model.nowMs ?? 0) : hubBody(h, model, overview, tab), ], diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index a6cb93c90a..f9f7c1bc38 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -348,7 +348,7 @@ describe("alert rule", () => { }), ) expect(viewAll.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=overview", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts", isReplace: false }), ) }) }) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 06177371b6..6c56441c30 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -322,7 +322,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return model: next, commands, outMessage: PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "overview" }), + url: alertsHref({}), isReplace: false, }), } diff --git a/apps/web/src/page/alerts/rule/view-diagnosis.ts b/apps/web/src/page/alerts/rule/view-diagnosis.ts index 0de938ee0c..5a24e324e2 100644 --- a/apps/web/src/page/alerts/rule/view-diagnosis.ts +++ b/apps/web/src/page/alerts/rule/view-diagnosis.ts @@ -290,7 +290,7 @@ const stageAction = (h: H, action: NonNullable): Reado ] if (action.kind === "destinations") return [ - linkButton(h, { variant: "outline", size: "xs", href: alertsHref({ tab: "routing" }) }, [ + linkButton(h, { variant: "outline", size: "xs", href: alertsHref({}, "routing") }, [ action.label, ]), ] diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4b8cbb53d7..c2c01db6b0 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -60,7 +60,7 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => return incident.escalation }).pipe(Effect.mapError(toLoadFailure)) -export const routingTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "routing" } })) +export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) // SUMMARY diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index bf622381e2..4d86fc8328 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,7 +1,12 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" -import { AppRoute, hrefOf } from "../../../route" -import type { AlertRuleSearch, AlertsCreateSearch, AlertsSearch } from "../../../route/search" +import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" +import type { + AlertRuleSearch, + AlertsCreateSearch, + AlertsHubSearch, + AlertsSection, +} from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -15,7 +20,12 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -export const alertsHref = (search: typeof AlertsSearch.Type): string => hrefOf(AppRoute.Alerts({ search })) +/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export type AlertsTab = "inbox" | AlertsSection + +/** A hub section's URL with the hub's filters; the Inbox by default. */ +export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => + hrefOf(alertsSectionRoute(tab, search)) export const createHref = (search: typeof AlertsCreateSearch.Type): string => hrefOf(AppRoute.AlertsCreate({ search })) diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts index 2f4f7d785b..926b397430 100644 --- a/apps/web/src/page/alerts/shared/maintenance-windows.ts +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -85,7 +85,7 @@ export const windowIdOfParam = (raw: string | undefined): Option.Option - alertsHref({ tab: "silences", window: windowPublicId(id) }) + alertsHref({ window: windowPublicId(id) }, "silences") export const WEEKDAYS: ReadonlyArray<{ value: MaintenanceWeekday; label: string }> = [ { value: "mon", label: "Mon" }, diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index fc9c70a19e..b35b9a8b57 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -143,7 +143,7 @@ export const newScheduleHref = (search: typeof AlertsScheduleNewSearch.Type = {} export const editScheduleHref = (id: OnCallScheduleId): string => newScheduleHref({ scheduleId: encodePublicId(id) }) -export const oncallTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "oncall" } })) +export const oncallTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "on-call", search: {} })) // FORMAT diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index ae9de12599..e2d66603af 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -114,4 +114,4 @@ export const newSloHref = (search: typeof AlertsSloNewSearch.Type = {}): string export const editSloHref = (id: SloId): string => newSloHref({ sloId: encodePublicId(id) }) -export const slosTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "slos" } })) +export const slosTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "slos", search: {} })) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index b6b41b16a6..2259f78b67 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -4,8 +4,17 @@ import type { AlertRuleRow, AlertRuleStateRow, } from "@maple/web-data/collections/alerts" +import { alertsSectionRoute } from "../../../route" +import type { AlertsHubSearch } from "../../../route/search" +import type { AlertsTab } from "./links" import { isoMs } from "./time" +/** A hub section and its filters, as the tests and labs spell where they stand. */ +export type HubAt = typeof AlertsHubSearch.Type & { readonly tab?: AlertsTab } + +/** The hub route for `at`: `/alerts` for the Inbox (the default), else `/alerts/`. */ +export const hubAt = ({ tab = "inbox", ...search }: HubAt = {}) => alertsSectionRoute(tab, search) + // Synced alert rows for the alerts pages' scene tests, the shape Electric delivers. export const ORG = "org_acme" diff --git a/apps/web/src/page/alerts/shared/uptime-commands.ts b/apps/web/src/page/alerts/shared/uptime-commands.ts index 18d2b3d1f7..847410c969 100644 --- a/apps/web/src/page/alerts/shared/uptime-commands.ts +++ b/apps/web/src/page/alerts/shared/uptime-commands.ts @@ -104,7 +104,7 @@ export const newMonitorHref = (search: typeof AlertsUptimeNewSearch.Type = {}): export const editMonitorHref = (id: UptimeMonitorId): string => newMonitorHref({ monitorId: encodePublicId(id) }) -export const uptimeTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "uptime" } })) +export const uptimeTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "uptime", search: {} })) /** Every check is a span in the `maple-uptime` service, namespace `maple-services`. */ export const UPTIME_SERVICE = UPTIME_SERVICE_NAME diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c2c034cdc7..2eba7bba2e 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -9,7 +9,7 @@ import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" -// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. +// "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) export type SilenceDuration = typeof SilenceDuration.Type @@ -41,7 +41,7 @@ export const Model = Schema.Struct({ export type Model = typeof Model.Type export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), + dialog: Dialog.init(id, "sheet"), scope: { _tag: "Typed", services: "", tags: "", environments: "" }, reason: "", duration: "1h", @@ -210,6 +210,9 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) +/** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ +export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) + const save = (model: Model): Return => model.isSaving || !isFormReady(model) ? { model } @@ -237,7 +240,7 @@ export const update = (model: Model, message: Message): Return => model.scope._tag === "Fixed" ? { model } : { model: { ...model, scope: { ...model.scope, [field]: value } } }, - ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 6e1b31bfc3..d2758eb2df 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -2,9 +2,10 @@ import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "@maple/views/ui/eyebrow" -import { formDialog } from "@maple/views/ui/form-dialog" +import { button } from "@maple/views/ui/button" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" @@ -90,31 +91,43 @@ const durationSection = (h: H, model: Model): Html => : []), ]) -/** The "Silence notifications" dialog: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, inputs, h) => - formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView((model, _inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-lg", title: "Silence notifications", description: "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - panelClassName: "space-y-5", - fields: [ - scopeSection(h, model), - durationSection(h, model), - textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), - }), + body: [ + vstack(h, { gap: "5" }, [ + scopeSection(h, model), + durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ]), ], onSubmit: Message.ClickedSave(), - submitLabel: submitLabel(model), - isPending: model.isSaving, - isSubmitDisabled: !isFormReady(model), + footer: (close) => [ + button( + h, + { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [submitLabel(model)], + ), + ], }), ) diff --git a/apps/web/src/page/services/detail/uptime-card.scene.test.ts b/apps/web/src/page/services/detail/uptime-card.scene.test.ts index 0e683b9dc5..60558c0397 100644 --- a/apps/web/src/page/services/detail/uptime-card.scene.test.ts +++ b/apps/web/src/page/services/detail/uptime-card.scene.test.ts @@ -73,7 +73,7 @@ describe("service detail, Uptime card", () => { "href", "/alerts/uptime/new?serviceName=checkout-api", ), - expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts?tab=uptime"), + expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts/uptime"), ) }) }) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 506af359a4..8e1120611f 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -39,8 +39,8 @@ export const resourceHref = (link: AuditResourceLink): string => { return `/investigations/${encodeURIComponent(link.params.id)}` case "/anomalies/$incidentId": return `/anomalies/${encodeURIComponent(link.params.incidentId)}` - case "/alerts": - return hrefOf(AppRoute.Alerts({ search: link.search })) + case "/alerts/routing": + return hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) case "/integrations": return hrefOf(AppRoute.Integrations({ search: link.search })) case "/settings": @@ -99,11 +99,7 @@ const identifier = (h: H, model: Model, value: string, label: string): Html => { const changesTable = (h: H, changes: V2AuditChanges): Html => table(h, { size: "sm", className: "font-mono" }, [ tableHeader(h, [ - tableRow(h, [ - tableHead(h, ["Field"], "w-40"), - tableHead(h, ["Before"]), - tableHead(h, ["After"]), - ]), + tableRow(h, [tableHead(h, ["Field"], "w-40"), tableHead(h, ["Before"]), tableHead(h, ["After"])]), ]), tableBody( h, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 6cc1190873..69069410ea 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -78,6 +78,7 @@ export const AppRoute = defineRouteUnion({ Trace: { traceId: Schema.String, search: Search.TraceSearch }, AgentSessions: { search: Search.AgentSessionsSearch }, Alerts: { search: Search.AlertsSearch }, + AlertsSection: { section: Search.AlertsSection, search: Search.AlertsHubSearch }, Analytics: { search: Search.AnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, diff --git a/apps/web/src/route/index.ts b/apps/web/src/route/index.ts index 952e2e784a..71e42e5619 100644 --- a/apps/web/src/route/index.ts +++ b/apps/web/src/route/index.ts @@ -1,3 +1,3 @@ export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" -export { flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" +export { alertsSectionRoute, flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" export { hrefOf, urlToAppRoute } from "./routers" diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index e21bd3f792..a897eff99f 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -3,7 +3,13 @@ import { Option, Predicate, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" import { DATA_INTEGRATIONS } from "./data-integrations" -import { type AlertsSearch, type AnomaliesSearch, ErrorsSearch } from "./search" +import { + type AlertsHubSearch, + type AlertsSearch, + AlertsSection, + type AnomaliesSearch, + ErrorsSearch, +} from "./search" /** Where a route sends the browser instead of rendering, and whether it replaces history. */ export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) @@ -60,6 +66,23 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts } } +const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) + +/** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ +const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => + tab === "oncall" + ? "on-call" + : tab === "settings" + ? "routing" + : Option.getOrElse(decodeAlertsSection(tab), (): "inbox" => "inbox") + +/** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ +export const alertsSectionRoute = ( + section: AlertsSection | "inbox", + search: typeof AlertsHubSearch.Type, +): Extract => + section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) + const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => @@ -100,9 +123,13 @@ export const routeRedirect = (route: AppRoute): Option.Option => /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), - /** Status pages left the Alerts hub for their own sidebar entry. */ - Alerts: ({ search }) => - search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), + /** A legacy `?tab=` opens its section's own path; status pages left the hub for the sidebar. */ + Alerts: ({ search: { tab, ...search } }) => + tab === undefined + ? Option.none() + : tab === "status-pages" + ? redirect(AppRoute.StatusPages(), true) + : redirect(alertsSectionRoute(legacyAlertsSection(tab), search), true), AlertsStatusPage: ({ pageId, search }) => redirect( AppRoute.StatusPage({ diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index e9d860e27a..91812635e3 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -69,11 +69,47 @@ describe("routes", () => { expect(hrefOf(route)).toBe("/status-pages/stp_abc?view=settings") }) + test("each Alerts section has its own path, beside the detail pages under it", () => { + for (const section of ["rules", "uptime", "slos", "silences", "maintenance", "on-call", "routing"]) { + const route = parse(`/alerts/${section}`) + expect(route).toMatchObject({ _tag: "AlertsSection", section }) + expect(hrefOf(route)).toBe(`/alerts/${section}`) + } + expect(parse("/alerts")._tag).toBe("Alerts") + expect(hrefOf(parse("/alerts/rules?status=firing"))).toBe("/alerts/rules?status=firing") + expect(parse("/alerts/uptime/new")._tag).toBe("AlertsUptimeNew") + expect(parse("/alerts/uptime/mon_1")._tag).toBe("AlertsUptimeMonitor") + expect(parse("/alerts/slos/slo_1")._tag).toBe("AlertsSlo") + expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") + expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") + expect(parse("/alerts/create")._tag).toBe("AlertsCreate") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/nope")._tag).toBe("NotFound") + }) + + test("an old `?tab=` link replaces itself with its section's path and keeps its other params", () => { + const redirectOf = (url: string) => Option.map(routeRedirect(parse(url)), (to) => to.url) + expect(redirectOf("/alerts?tab=rules&status=firing")).toEqual( + Option.some("/alerts/rules?status=firing"), + ) + expect(redirectOf("/alerts?tab=%22silences%22&window=mw_1")).toEqual( + Option.some("/alerts/silences?window=mw_1"), + ) + expect(redirectOf("/alerts?tab=oncall")).toEqual(Option.some("/alerts/on-call")) + expect(redirectOf("/alerts?tab=settings")).toEqual(Option.some("/alerts/routing")) + expect(redirectOf("/alerts?tab=inbox&signal=anomaly")).toEqual(Option.some("/alerts?signal=anomaly")) + expect(redirectOf("/alerts?tab=overview")).toEqual(Option.some("/alerts")) + expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( + Option.some({ url: "/alerts/uptime", isReplace: true }), + ) + expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) + expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) + }) + test("the old status page links under Alerts redirect to their own section", () => { expect(routeRedirect(parse("/alerts?tab=%22status-pages%22"))).toEqual( Option.some({ url: "/status-pages", isReplace: true }), ) - expect(routeRedirect(parse("/alerts?tab=%22uptime%22"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/status-pages/stp_abc"))).toEqual( Option.some({ url: "/status-pages/stp_abc", isReplace: true }), ) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 08e3c2da40..e1710c59c2 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -284,6 +284,12 @@ const routers = { Route.mapTo(AppRoute.AgentSessions), ), Alerts: pipe(path("alerts"), search(Search.AlertsSearch), Route.mapTo(AppRoute.Alerts)), + AlertsSection: pipe( + path("alerts"), + slash(schemaSegment("section", Search.AlertsSection)), + search(Search.AlertsHubSearch), + Route.mapTo(AppRoute.AlertsSection), + ), Analytics: pipe(path("analytics"), search(Search.AnalyticsSearch), Route.mapTo(AppRoute.Analytics)), Anomalies: pipe(path("anomalies"), search(Search.AnomaliesSearch), Route.mapTo(AppRoute.Anomalies)), CodeReview: pipe(path("code-review"), search(Search.CodeReviewSearch), Route.mapTo(AppRoute.CodeReview)), @@ -521,6 +527,7 @@ const routers = { // Literal routes come before the param routes that would also match them (`/infra/hosts`). export const urlToAppRoute = Route.parseUrlWithFallback( Route.oneOf( + routers.AlertsSection, routers.AlertsCreate, routers.AlertsUptimeNew, routers.AlertsUptimeMonitor, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 84b89168c0..c0999c525f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -514,8 +514,19 @@ export const AgentSessionsSearch = Schema.Struct({ sortDir: Schema.optional(AiSessionSortDir), }) -export const AlertsSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), +/** The Alerts hub's sections past the Inbox, each at `/alerts/
`. */ +export const AlertsSection = Schema.Literals([ + "rules", + "uptime", + "slos", + "silences", + "maintenance", + "on-call", + "routing", +]) +export type AlertsSection = typeof AlertsSection.Type + +const alertsHubSearchFields = { serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ @@ -524,7 +535,15 @@ export const AlertsSearch = Schema.Struct({ anomalyStatus: Schema.optional(Schema.String), /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ window: Schema.optional(Schema.String), -}) + /** The open side panel (`SHEET_PARAM`): "silence" is the new-silence form. */ + sheet: Schema.optional(Schema.String), +} + +/** `/alerts` and `/alerts/
`: the filters every section shares. */ +export const AlertsHubSearch = Schema.Struct(alertsHubSearchFields) + +/** `/alerts`: a legacy `tab` redirects to its section's own path. */ +export const AlertsSearch = Schema.Struct({ tab: Schema.optional(Schema.String), ...alertsHubSearchFields }) // sessionTimeRangeSearchMiddleware() export const AnalyticsSearch = Schema.Struct({ diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 22ded041ce..71f756559c 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -34,6 +34,8 @@ export interface NavSubItem { }> /** The command palette's title, when the row's own reads ambiguously there. */ readonly paletteTitle?: string + /** Pages under another path that this row stands in for (schedules under On-call). */ + readonly alsoActive?: ReadonlyArray } export interface NavItem { @@ -129,6 +131,40 @@ const exploreItem: NavItem = { ], } +const alertsItem: NavItem = { + title: "Alerts", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Alerts", + alsoActive: ["/anomalies"], + }, + { + title: "Rules", + href: "/alerts/rules", + icon: "ChartLineIcon", + paletteTitle: "Alert rules", + alsoActive: ["/alerts/create"], + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon" }, + { + title: "Maintenance", + href: "/alerts/maintenance", + icon: "CalendarIcon", + paletteTitle: "Maintenance windows", + }, + { title: "On-call", href: "/alerts/on-call", icon: "UsersIcon", alsoActive: ["/alerts/schedules"] }, + { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + ], +} + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ releases: boolean @@ -174,7 +210,7 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ label: "Triage", items: [ { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, - { title: "Alerts", href: "/alerts", icon: "BellIcon", alsoActive: ["/anomalies"] }, + alertsItem, { title: "Code review", href: "/code-review", @@ -197,8 +233,9 @@ export const isPathActive = (currentPath: string, href: string): boolean => export const matchSubItem = (currentPath: string, sub: NavSubItem): string | undefined => Arr.reduce( - Arr.filter([sub.href, ...Arr.map(sub.views ?? [], (view) => view.href)], (href) => - isPathActive(currentPath, href), + Arr.filter( + [sub.href, ...Arr.map(sub.views ?? [], (view) => view.href), ...(sub.alsoActive ?? [])], + (href) => isPathActive(currentPath, href), ), undefined, (best, href) => (best === undefined || href.length > best.length ? href : best), diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 5ce01cc3bf..825096ee87 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -90,7 +90,7 @@ describe("on-call pill", () => { expect(footer).toContainText("You're on call"), expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), - expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts/on-call"), ) }) @@ -99,7 +99,7 @@ describe("on-call pill", () => { { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts/on-call"), expect(footer).toContainText("My schedules"), ) }) diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index f395917878..d1f5daa5a0 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -425,6 +425,56 @@ describe("app update banner", () => { }) }) +describe("alerts section", () => { + const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + + test("on an alerts page, Alerts opens its sections with the open one current", () => { + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).toHaveAttr("href", "/alerts/rules"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Silences" })).toHaveAttr("href", "/alerts/silences"), + expect(role("link", { name: "On-call" })).toHaveAttr("href", "/alerts/on-call"), + ) + scene( + { update, view: shellAt("/alerts") }, + given(signedIn()), + measured, + expect(role("link", { name: "Inbox" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("a detail page marks the section it belongs to", () => { + scene( + { update, view: shellAt("/alerts/schedules/sch_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "On-call" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/uptime/mon_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), + ) + }) + + test("elsewhere the sections stay hidden", () => { + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Silences" })).toBeAbsent(), + ) + }) +}) + describe("services section", () => { test("on the service map, Services opens with the map row current", () => { scene( diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 966070641b..5c5709fbfc 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -16,6 +16,7 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" +import { formDialogForm } from "./form-dialog" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -147,6 +148,8 @@ export type SidePanelSheetOptions = Readonly<{ * Cancel dismisses like Escape does; none renders no footer. */ footer?: (close: ReadonlyArray) => ReadonlyArray + /** Makes the panel a `
`: Enter in a field or a `type: "submit"` footer button sends this. */ + onSubmit?: Message toParentMessage: (message: Dialog.Message) => Message }> @@ -173,18 +176,23 @@ export const sidePanelSheet = ( className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: (close) => [ - DialogView.dialogHeader(h, "sheet", [ - DialogView.dialogTitle(h, "sheet", sheet.id, options.title), - ...(options.description === undefined + toContent: (close) => { + const content = [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined ? [] - : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), - ]), - DialogView.dialogPanel(h, "sheet", sheet.id, options.body), - ...(options.footer === undefined - ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), - ], + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), + ] + return options.onSubmit === undefined + ? content + : [formDialogForm(h, options.onSubmit, content)] + }, }, toParentMessage: options.toParentMessage, }) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 528a4fd6cd..b632d9b440 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -427,7 +427,8 @@ export const sidebarMenuSubButton = ( options: Readonly<{ className?: string; isActive: boolean; link?: LinkTarget; href?: string }>, children: Children, ): Html => { - const linkActive = options.link !== undefined && isLinkActive(options.link.currentPath, options.link.href) + // The caller picks the most specific sibling, so an index row (`/alerts`) doesn't claim its siblings' pages. + const linkActive = options.link !== undefined && options.isActive const className = cn( ...sidebarMenuSubButtonStyles, sidebarMenuSubButtonMdStyles, From 58883959f0d6f11e220a44eb44de2b1cf69e9648 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:03:18 +0100 Subject: [PATCH 04/35] fix(shell): read window and document on subscribe, not on import --- apps/web/src/shell/subscription.ts | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index ec188cab53..210f70f782 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -98,27 +98,30 @@ export const onCallTimer = (atMs: number): Stream.Stream => }), ) -const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window - /** The tab came back into view or focus (a handoff may have passed while it slept). */ -export const onCallWakes: Stream.Stream = Stream.merge( - Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), - Dom.streamFromEvent({ - target: document, - type: "visibilitychange", - mapEvent: () => document.visibilityState === "visible", - }), -).pipe( +// Suspended: `window` and `document` are read on subscribe, so importing the shell needs no DOM. +export const onCallWakes: Stream.Stream = Stream.suspend(() => { + const wakeTarget: Dom.TypedEventTarget<{ focus: FocusEvent }> = window + return Stream.merge( + Dom.streamFromEvent({ target: wakeTarget, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), + ) +}).pipe( Stream.filter((isAwake) => isAwake), Stream.mapEffect(() => Clock.currentTimeMillis), Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), ) -const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window - /** A page wrote a schedule or an override. */ -export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ - target: onCallChangeTarget, - type: ONCALL_CHANGED_EVENT, - mapEvent: () => Message.ChangedOnCallSchedules(), +export const onCallChanges: Stream.Stream = Stream.suspend(() => { + const target: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + return Dom.streamFromEvent({ + target, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), + }) }) From 280ea477269f044edefd27267ef094b0f2ab7938 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:31:47 +0100 Subject: [PATCH 05/35] fix(alerts): review follow-ups for section routes and the silence panel - The silence panel clears ?sheet= in the model as soon as it closes itself, so a clock tick before the URL change can't reopen it. It opens only on the Silences section, and a viewer who can't manage loses a linked ?sheet= once the grants load (replaced away). - A rule lives at /alerts/rules/; /alerts/ (notifications, the apps) replaces itself with it, keeping its tab and window. The rule page and editor go back to Rules and their crumbs go through it. - Settings automation and setup health link to the new section paths. - The sidebar reveals the open section, every sub-item past the edge fades, on each route change; sub-rows are tighter, thin rules split the former groups, and the Inbox shows its unacknowledged count from the alert_incidents collection, read only while an alerts page is open. - One AlertsTab schema next to AlertsSection; the sheet commands answer with a clock read instead of a no-op message. --- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/shell-lab.ts | 4 +- apps/web/src/page/alerts/editor/actions.ts | 2 +- .../page/alerts/editor/editor.scene.test.ts | 11 +- .../src/page/alerts/editor/view-action-bar.ts | 14 ++- apps/web/src/page/alerts/editor/view.ts | 1 + apps/web/src/page/alerts/hub/command.ts | 15 ++- .../alerts/hub/hub-maintenance.scene.test.ts | 2 +- .../page/alerts/hub/hub-oncall.scene.test.ts | 2 +- .../alerts/hub/hub-silences.scene.test.ts | 37 +++++++ apps/web/src/page/alerts/hub/message.ts | 2 - apps/web/src/page/alerts/hub/model.ts | 5 +- apps/web/src/page/alerts/hub/update.ts | 35 ++++-- apps/web/src/page/alerts/hub/view.ts | 3 +- apps/web/src/page/alerts/incident/view.ts | 4 +- apps/web/src/page/alerts/rule/index.ts | 2 +- apps/web/src/page/alerts/rule/model.ts | 2 +- .../src/page/alerts/rule/rule.scene.test.ts | 13 ++- apps/web/src/page/alerts/rule/view.ts | 3 +- apps/web/src/page/alerts/shared/links.ts | 10 +- apps/web/src/page/alerts/shared/test-rows.ts | 3 +- .../page/settings/audit-log/view-detail.ts | 2 +- apps/web/src/page/settings/automation/view.ts | 3 +- .../src/page/settings/setup-health/derive.ts | 2 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/redirect.ts | 7 +- apps/web/src/route/route.test.ts | 10 +- apps/web/src/route/routers.ts | 9 +- apps/web/src/route/search.ts | 4 + apps/web/src/shell/alerts-inbox.ts | 23 ++++ apps/web/src/shell/index.ts | 1 + apps/web/src/shell/message.ts | 2 + apps/web/src/shell/model.ts | 3 + apps/web/src/shell/mounts.ts | 47 ++++++-- apps/web/src/shell/nav.ts | 19 +++- apps/web/src/shell/shell.scene.test.ts | 28 +++++ apps/web/src/shell/update.ts | 5 + apps/web/src/shell/view-nav.ts | 100 ++++++++++++------ apps/web/src/subscription.ts | 37 ++++++- apps/web/src/view.lazy.test.ts | 2 +- packages/views/src/ui/sidebar.ts | 9 +- 41 files changed, 385 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/shell/alerts-inbox.ts diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index 9e69ef8a00..3a0c51ebf6 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,7 +1,8 @@ import { defineShellLab } from "../shell-lab" import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" -// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). +// `/lab/alerts-hub`: the hub sections and the silence side panel over fixtures (`?state=`), with +// two unacknowledged incidents counted on the sidebar's Inbox. export const lab = defineShellLab("Alerts hub", { Model, @@ -13,4 +14,5 @@ export const lab = defineShellLab("Alerts hub", { view, currentPath, breadcrumbs, + seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }), }) diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index 2003012daa..921480308d 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -39,6 +39,8 @@ export type ShellLabPage ReadonlyArray) /** Rendered in `#app` before the shell: lab toolbars that sit beside the shell. */ before?: (model: PageModel, h: HtmlBuilder) => Html + /** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */ + seedShell?: (shell: Shell.Model) => Shell.Model }> export const defineShellLab = ( @@ -107,7 +109,7 @@ export const defineShellLab = { expect(late.preview).toEqual(edited.preview) }) - test("save sends once when ready, and lands on /alerts with a toast", () => { + test("save sends once when ready, and lands on the rules with a toast; the crumbs go through Rules", () => { const ready = withLists(entered({ template: "slow_p95" })) const saving = pageUpdate(ready, Message.ClickedSave()) expect(names(saving.commands)).toEqual(["SaveAlertRule"]) expect(names(pageUpdate(saving.model, Message.ClickedSave()).commands)).toEqual([]) const saved = pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(undefined) })) - expect(saved.outMessage?._tag).toBe("RequestedNavigation") + expect(saved.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/rules" }) + expect(breadcrumbsOf(ready)).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "New rule" }, + ]) }) test("a created rule records alert_rule_created with its signal; a failed save does not", () => { diff --git a/apps/web/src/page/alerts/editor/view-action-bar.ts b/apps/web/src/page/alerts/editor/view-action-bar.ts index 1a451f62a2..dbf195f94e 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -120,7 +120,13 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu vstack(h, { gap: "0.5", shrink: true }, [ typography( h, - { size: "sm", tone: "foreground", truncate: true, as: "p", attributes: [h.Title(summary)] }, + { + size: "sm", + tone: "foreground", + truncate: true, + as: "p", + attributes: [h.Title(summary)], + }, [summary], ), validationSummary(h, model, issues), @@ -131,10 +137,12 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu ? [] : [ typography(h, { size: "xs", tone: "muted", className: "hidden sm:inline" }, [ - changeCount === 0 ? "No changes" : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, + changeCount === 0 + ? "No changes" + : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, ]), ]), - linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}) }, ["Cancel"]), + linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}, "rules") }, ["Cancel"]), button( h, { diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index ebd1bae99e..0632c3ec1f 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -247,6 +247,7 @@ export const view = Submodel.defineView((model, /** `AlertRuleFormShell`'s breadcrumb. */ export const breadcrumbsOf = (model: Model) => [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: (model.form === null ? model.search.ruleId !== undefined : model.editingRuleId !== null) ? "Edit rule" diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index abc46e5491..69b04f3dbc 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -44,18 +44,25 @@ import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" const sheetArgs = { href: Schema.String, id: Schema.String } +/** A command must answer with a message: a sheet change answers with the clock, as a route change reads it. */ +const thenReadClock = (effect: Effect.Effect) => + effect.pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.ReadClock({ nowMs })), + ) + /** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ export const PushHubSheet = Command.define("PushAlertsHubSheet", { args: sheetArgs, - messages: [Message.CompletedSheetUrl], - execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(pushSheetUrl(href, id)), }) /** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { args: sheetArgs, - messages: [Message.CompletedSheetUrl], - execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(dismissSheetUrl(href, id)), }) export const ReadHubClock = Command.define("ReadAlertsHubClock", { diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts index 570e9b8986..c02a9c6fb0 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -1,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" 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 index 2102c64f20..37f8bc9d73 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 2d7d322363..395a2fcd02 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -236,6 +236,39 @@ describe("alerts hub, silence side panel", () => { expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) }) + test("a member's link loses its `?sheet=` once the grants say so, replaced away", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + const granted = pageUpdate(clocked, Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })) + expect(granted.model.silenceDialog.dialog.isOpen).toBe(false) + expect(granted.model.search.sheet).toBeUndefined() + expect(callsOf(granted.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ]) + }) + + test("the panel opens only on the Silences section", () => { + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.silenceDialog.dialog.isOpen).toBe(false) + expect( + pageUpdate(onRules.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) + }) + + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.search.sheet).toBeUndefined() + const ticked = pageUpdate(cancelled.model, Message.ReadClock({ nowMs: NOW + 30_000 })) + expect(ticked.model.silenceDialog.dialog.isOpen).toBe(false) + const landed = routeChanged(ticked.model, hubAt({ tab: "silences" }), context) + expect(landed.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(landed.commands)).not.toContain("DismissAlertsHubSheet") + }) + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { const dismissed = [ ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], @@ -297,5 +330,9 @@ describe("alerts hub, silence side panel", () => { expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), ) expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + // The refreshed list reads the clock again; the panel stays shut. + expect( + pageUpdate(saved.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 5f0c12fffb..75a499ae12 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,8 +52,6 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ - /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ - CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index c23d373893..fb584a2b40 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,7 +28,7 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsHubSearch, AlertsSection } from "../../../route/search" +import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" @@ -53,9 +53,6 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) -/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) - export const Model = Schema.Struct({ tab: AlertsTab, search: AlertsHubSearch, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index f47c40d86d..89fb77bfeb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -754,11 +754,12 @@ const silenceCommands = (step: ReturnType): Command FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) /** - * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens - * once the clock is read, as the form checks its end against it, and only for a viewer who may save. + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens on + * the Silences section once the clock is read, as the form checks its end against it, and only for + * a viewer who may save. */ const followSilenceSheet = (model: Model, context: PageContext): Requested => { - const isInUrl = model.search.sheet === SILENCE_SHEET + const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" const isOpen = model.silenceDialog.dialog.isOpen if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) @@ -771,19 +772,32 @@ const followSilenceSheet = (model: Model, context: PageContext): Requested => { return { model, commands: [] } } +/** The grants are in: a viewer who can't silence loses a linked panel's `?sheet=`, replaced away. */ +const grantsLoaded = (model: Model, context: PageContext): Requested => + model.search.sheet === SILENCE_SHEET && !canManage(model) + ? { + model: { ...model, search: { ...model.search, sheet: undefined } }, + commands: [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })], + } + : followSilenceSheet(model, context) + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { - const next = { ...model, silenceDialog: step.model } - // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes too. - const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes now, in the model too, so a + // clock tick before the URL change lands can't reopen it. + const hasClosed = + model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen && model.search.sheet === SILENCE_SHEET + const next = { + ...model, + silenceDialog: step.model, + search: hasClosed ? { ...model.search, sheet: undefined } : model.search, + } const commands = [ ...silenceCommands(step), - ...(hasClosed && model.search.sheet === SILENCE_SHEET - ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] - : []), + ...(hasClosed ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] : []), ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { @@ -1119,7 +1133,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? followSilenceSheet({ ...model, grants }, context) : { model }, + orgId === context.orgId ? grantsLoaded({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ @@ -1339,7 +1353,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.search.sheet === SILENCE_SHEET ? { model } : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, - CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 94d722f8fa..e9e8dd6843 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -16,7 +16,8 @@ import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import type { BreadcrumbEntry } from "../../../shell" -import { type AlertsTab, alertsHref, createHref } from "../shared/links" +import type { AlertsTab } from "../../../route/search" +import { alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f5ac406e4..b020209ac3 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -40,7 +40,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" -import { alertsHref } from "../shared/links" +import { alertsHref, ruleHref } from "../shared/links" import { sloHref, slosTabHref } from "../shared/slo-commands" import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" @@ -544,7 +544,7 @@ export const breadcrumbsOf = (model: Model) => ], onSome: ({ incident, rule }) => [ { label: "Alerts", href: "/alerts" }, - { label: rule?.name ?? incident.ruleName, href: `/alerts/${incident.ruleId}` }, + { label: rule?.name ?? incident.ruleName, href: ruleHref(incident.ruleId) }, { label: "Incident" }, ], }) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 2c10c34d2e..ddb939bb6e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -46,7 +46,7 @@ const subscriptions = Subscription.make, Message, R ), })) -/** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ +/** `/alerts/rules/$ruleId`: one instance per rule; the tab and window are search. */ export const page = definePage( "AlertRule", { Model, Message }, diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index 5c571dc3f1..f955de7adb 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -21,7 +21,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" -// `/alerts/$ruleId`. +// `/alerts/rules/$ruleId`. const Rows = (row: S) => Schema.NullOr(Schema.Array(row)) diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index f9f7c1bc38..d576df271c 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -27,7 +27,7 @@ import { ToggleRuleEnabled } from "./command" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -138,7 +138,7 @@ describe("alert rule", () => { test("the Overview and History tab links keep the time range", () => { const search = { timePreset: "7d" } - const href = (tab: string) => `/alerts/${RULE}?tab=${tab}&timePreset=7d` + const href = (tab: string) => `/alerts/rules/${RULE}?tab=${tab}&timePreset=7d` scene( { update: pageUpdate, view }, given(loaded(search)), @@ -169,9 +169,18 @@ describe("alert rule", () => { given(model), themeResolved(), expectView(text("Rule not found")).toExist(), + expectView(role("link", { name: "Back to rules" })).toHaveAttr("href", "/alerts/rules"), ) }) + test("the crumbs go through Rules to the rule", () => { + expect(breadcrumbsOf(loaded())).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "Payment p95 latency" }, + ]) + }) + test("a raw SQL rule slowed down by its timeouts shows its query time per check and why", () => { const check = ( minutesAgo: number, diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index 402a974f5f..f33fa6c523 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -200,7 +200,7 @@ export const view = Submodel.defineView((model, resourceNotFound(h, { title: "Rule not found", description: "This alert rule could not be found. It may have been deleted.", - back: { href: alertsHref({}), label: "Back to rules" }, + back: { href: alertsHref({}, "rules"), label: "Back to rules" }, }), ]) : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ @@ -229,6 +229,7 @@ export const breadcrumbsOf = (model: Model) => { const rule = model.rules === null ? null : ruleOf(model) return [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: model.rules === null ? "Loading…" : rule === null ? "Not found" : rule.name }, ] } diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index 4d86fc8328..9316199646 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,12 +1,7 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" -import type { - AlertRuleSearch, - AlertsCreateSearch, - AlertsHubSearch, - AlertsSection, -} from "../../../route/search" +import type { AlertRuleSearch, AlertsCreateSearch, AlertsHubSearch, AlertsTab } from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -20,9 +15,6 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export type AlertsTab = "inbox" | AlertsSection - /** A hub section's URL with the hub's filters; the Inbox by default. */ export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => hrefOf(alertsSectionRoute(tab, search)) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index 2259f78b67..74df831c09 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -5,8 +5,7 @@ import type { AlertRuleStateRow, } from "@maple/web-data/collections/alerts" import { alertsSectionRoute } from "../../../route" -import type { AlertsHubSearch } from "../../../route/search" -import type { AlertsTab } from "./links" +import type { AlertsHubSearch, AlertsTab } from "../../../route/search" import { isoMs } from "./time" /** A hub section and its filters, as the tests and labs spell where they stand. */ diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 8e1120611f..8a070ddf1c 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -30,7 +30,7 @@ type H = HtmlBuilder export const resourceHref = (link: AuditResourceLink): string => { switch (link.to) { case "/alerts/$ruleId": - return `/alerts/${encodeURIComponent(link.params.ruleId)}` + return `/alerts/rules/${encodeURIComponent(link.params.ruleId)}` case "/dashboards/$dashboardId": return `/dashboards/${encodeURIComponent(link.params.dashboardId)}` case "/errors/issues/$issueId": diff --git a/apps/web/src/page/settings/automation/view.ts b/apps/web/src/page/settings/automation/view.ts index e7072574c8..f00504f671 100644 --- a/apps/web/src/page/settings/automation/view.ts +++ b/apps/web/src/page/settings/automation/view.ts @@ -24,6 +24,7 @@ import { canManage, Message, type Model, type PolicyDraft } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option } from "effect" +import { routingTabHref } from "../../alerts/shared/escalation-chains" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string; isKnownMember: boolean }> @@ -124,7 +125,7 @@ const destinationsBlock = (h: H, model: Model, draft: PolicyDraft): Html => { "Escalation sends triaged issues to a destination. Add a Slack, email or webhook destination first.", ]), hstack(h, { wrap: true, className: "gap-x-4 gap-y-2" }, [ - linkButton(h, { size: "sm", variant: "outline", href: "/alerts?tab=routing" }, [ + linkButton(h, { size: "sm", variant: "outline", href: routingTabHref() }, [ "Add a destination", ]), docsLink(h, "destinations"), diff --git a/apps/web/src/page/settings/setup-health/derive.ts b/apps/web/src/page/settings/setup-health/derive.ts index c9e66ab507..01f5b37f9d 100644 --- a/apps/web/src/page/settings/setup-health/derive.ts +++ b/apps/web/src/page/settings/setup-health/derive.ts @@ -50,7 +50,7 @@ const INTEGRATION_FOR_CHECK = new Map([ const link = (label: string, href: string): SetupHealthAction => ({ kind: "link", label, href }) const settingsTab = (tab: "ingestion" | "data-platform") => hrefOf(AppRoute.Settings({ search: { tab } })) -const alertsTab = (tab: "rules" | "routing") => hrefOf(AppRoute.Alerts({ search: { tab } })) +const alertsTab = (section: "rules" | "routing") => hrefOf(AppRoute.AlertsSection({ section, search: {} })) const integrations = (integration?: string) => hrefOf(AppRoute.Integrations({ search: integration === undefined ? {} : { integration } })) diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 69069410ea..4d03b0a0b6 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -29,6 +29,7 @@ export const AppRoute = defineRouteUnion({ Unsubscribe: { search: Search.UnsubscribeSearch }, AgentSession: { sessionId: Schema.String, search: Search.AgentSessionSearch }, AlertRule: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, + AlertRuleLegacy: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, AlertsCreate: { search: Search.AlertsCreateSearch }, AlertsUptimeNew: { search: Search.AlertsUptimeNewSearch }, AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index a897eff99f..db4ae480eb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -7,6 +7,7 @@ import { type AlertsHubSearch, type AlertsSearch, AlertsSection, + type AlertsTab, type AnomaliesSearch, ErrorsSearch, } from "./search" @@ -69,7 +70,7 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) /** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ -const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => +const legacyAlertsSection = (tab: string): AlertsTab => tab === "oncall" ? "on-call" : tab === "settings" @@ -78,7 +79,7 @@ const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => /** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ export const alertsSectionRoute = ( - section: AlertsSection | "inbox", + section: AlertsTab, search: typeof AlertsHubSearch.Type, ): Extract => section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) @@ -120,6 +121,8 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** A rule moved under Rules; old links (notifications, the apps) keep their tab and window. */ + AlertRuleLegacy: ({ ruleId, search }) => redirect(AppRoute.AlertRule({ ruleId, search }), true), /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 91812635e3..e1e46c1249 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -83,7 +83,8 @@ describe("routes", () => { expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") - expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRuleLegacy") expect(parse("/alerts/nope")._tag).toBe("NotFound") }) @@ -102,6 +103,13 @@ describe("routes", () => { expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( Option.some({ url: "/alerts/uptime", isReplace: true }), ) + // A rule's old address (notifications, the apps) opens it under Rules, keeping its tab and window. + expect(redirectOf("/alerts/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d")).toEqual( + Option.some("/alerts/rules/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d"), + ) + expect(routeRedirect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001"))).toEqual( + Option.none(), + ) expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) }) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index e1710c59c2..02f882e357 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -71,11 +71,18 @@ const routers = { Route.mapTo(AppRoute.AgentSession), ), AlertRule: pipe( - path("alerts"), + path("alerts", "rules"), slash(schemaSegment("ruleId", AlertRuleId)), search(Search.AlertRuleSearch), Route.mapTo(AppRoute.AlertRule), ), + // A rule's page before it moved under Rules; notifications still link here, `routeRedirect` sends them on. + AlertRuleLegacy: pipe( + path("alerts"), + slash(schemaSegment("ruleId", AlertRuleId)), + search(Search.AlertRuleSearch), + Route.mapTo(AppRoute.AlertRuleLegacy), + ), AlertsCreate: pipe( path("alerts", "create"), search(Search.AlertsCreateSearch), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index c0999c525f..80ab3c0dd5 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -526,6 +526,10 @@ export const AlertsSection = Schema.Literals([ ]) export type AlertsSection = typeof AlertsSection.Type +/** A hub section: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) +export type AlertsTab = typeof AlertsTab.Type + const alertsHubSearchFields = { serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), diff --git a/apps/web/src/shell/alerts-inbox.ts b/apps/web/src/shell/alerts-inbox.ts new file mode 100644 index 0000000000..0860e5bd8f --- /dev/null +++ b/apps/web/src/shell/alerts-inbox.ts @@ -0,0 +1,23 @@ +import { Array as Arr, Stream } from "effect" +import { type OrgCollections, orgLive } from "../data/collections/org-collections" +import { liveQueryStream } from "../data/live-query" + +/** + * The Alerts › Inbox count: open incidents nobody has acknowledged, over the org's live + * `alert_incidents`. The alerts pages already read that collection, so reading it while one is open + * requests no other shape. + */ +export const alertsInboxCounts = ( + orgId: string, + toMessage: (count: number) => Message, +): Stream.Stream => + orgLive(orgId, (set) => + liveQueryStream( + (q) => q.from({ i: set.alertIncidents }), + (rows) => + toMessage( + Arr.filter(rows, (row) => row.status === "open" && (row.acknowledged_at ?? null) === null) + .length, + ), + ), + ) diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 6fa454d18f..aa21e01c2a 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -18,6 +18,7 @@ export { } from "./subscription" export { pinnedDashboardRows } from "./dashboards-shape" +export { alertsInboxCounts } from "./alerts-inbox" export { BUILT_COMMIT, versionChecks } from "./version" export { checklistTicked, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 966f002435..9b8d333801 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,6 +80,8 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + /** The Alerts › Inbox count: open incidents nobody has acknowledged. */ + UpdatedAlertsInboxCount: { orgId: Schema.String, count: Schema.Number }, /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ SucceededLoadOnCall: { orgId: Schema.String, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index e36edc6948..61bb54465e 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -78,6 +78,8 @@ export const Model = Schema.Struct({ /** `maple.infra-surfaces.`: each org's last settled surfaces, so the sidebar lays out once. */ rememberedSurfaces: Schema.Record(Schema.String, Schema.Array(NavSurface)), dashboards: DashboardsData.schema, + /** Read only while an alerts page is open; `null` until then. */ + alertsInboxCount: Schema.NullOr(Schema.Number), /** The org's schedules and who holds each now, for the footer's on-call pill. */ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ @@ -173,6 +175,7 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + alertsInboxCount: null, onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], onCallNowMs: 0, diff --git a/apps/web/src/shell/mounts.ts b/apps/web/src/shell/mounts.ts index 6c583acff7..4465f37236 100644 --- a/apps/web/src/shell/mounts.ts +++ b/apps/web/src/shell/mounts.ts @@ -1,4 +1,4 @@ -import { Effect, Queue, Schema, Stream } from "effect" +import { Array as Arr, Effect, Queue, Schema, Stream } from "effect" import { Dom, Mount } from "foldkit" import { Align, measurePosition, Side } from "@maple/views/ui/anchor" import { applyScrollOverflow } from "@maple/views/ui/scroll-area" @@ -57,17 +57,35 @@ export const MountNamespaceSearch = Mount.defineStream("MountNamespaceSearch", { }) /** The sidebar nav's `ScrollArea` overflow (its bottom fade), kept current on scroll and resize. */ -/** Scrolls the nav so the active row clears the pinned footer on a deep-linked load. */ +/** + * Scrolls the nav so the open section (its row and every sub-item) clears the pinned footer, or + * the active row alone when the section is taller than the nav. + */ const revealActiveRow = (viewport: HTMLElement): void => { const rows = viewport.querySelectorAll('[data-active="true"]') const row = rows.item(rows.length - 1) if (row === null) return - const top = row.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop - const bottom = top + row.offsetHeight - if (bottom > viewport.scrollTop + viewport.clientHeight) - viewport.scrollTop = bottom - viewport.clientHeight + 8 + const section = row.closest('[data-sidebar="menu-item"]') ?? row + // Past the scroll area's 1.5rem edge fades, so the last sub-item reads at full strength. + const margin = 28 + const target = section.offsetHeight + 2 * margin <= viewport.clientHeight ? section : row + const top = target.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop + const bottom = top + target.offsetHeight + if (top < viewport.scrollTop + margin) viewport.scrollTop = Math.max(0, top - margin) + else if (bottom > viewport.scrollTop + viewport.clientHeight - margin) + viewport.scrollTop = bottom - viewport.clientHeight + margin } +/** Which rows are active: a re-render that leaves them alone doesn't move the nav. */ +const activeKeyOf = (viewport: HTMLElement): string => + Arr.join( + Arr.map( + Arr.fromIterable(viewport.querySelectorAll('[data-active="true"]')), + (row) => row.getAttribute("href") ?? "", + ), + " ", + ) + export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow", { messages: [Message.MeasuredSidebarOverflow], execute: ({ element }) => @@ -81,12 +99,27 @@ export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow if (element.firstElementChild !== null) observer.observe(element.firstElementChild) element.addEventListener("scroll", measure, { passive: true }) revealActiveRow(element) + // A route change moves the active row (or opens a section): bring it into view too. + const seen = { key: activeKeyOf(element) } + const routed = new MutationObserver(() => { + const key = activeKeyOf(element) + if (key === seen.key) return + seen.key = key + revealActiveRow(element) + }) + routed.observe(element, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-active"], + }) measure() Queue.offerUnsafe(queue, Message.MeasuredSidebarOverflow()) - return { observer, measure } + return { observer, measure, routed } }), (handles) => Effect.sync(() => { + handles?.routed.disconnect() handles?.observer.disconnect() if (handles !== undefined) element.removeEventListener("scroll", handles.measure) }), diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 71f756559c..d41ccc49d9 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -36,6 +36,10 @@ export interface NavSubItem { readonly paletteTitle?: string /** Pages under another path that this row stands in for (schedules under On-call). */ readonly alsoActive?: ReadonlyArray + /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ + readonly count?: "alertsInbox" + /** Starts a group of rows: a thin rule above it, no heading. */ + readonly startsGroup?: boolean } export interface NavItem { @@ -143,6 +147,7 @@ const alertsItem: NavItem = { icon: "BellIcon", paletteTitle: "Alerts", alsoActive: ["/anomalies"], + count: "alertsInbox", }, { title: "Rules", @@ -150,17 +155,24 @@ const alertsItem: NavItem = { icon: "ChartLineIcon", paletteTitle: "Alert rules", alsoActive: ["/alerts/create"], + startsGroup: true, }, { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, - { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon", startsGroup: true }, { title: "Maintenance", href: "/alerts/maintenance", icon: "CalendarIcon", paletteTitle: "Maintenance windows", }, - { title: "On-call", href: "/alerts/on-call", icon: "UsersIcon", alsoActive: ["/alerts/schedules"] }, + { + title: "On-call", + href: "/alerts/on-call", + icon: "UsersIcon", + alsoActive: ["/alerts/schedules"], + startsGroup: true, + }, { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, ], } @@ -246,6 +258,9 @@ export const isNavItemActive = (currentPath: string, item: NavItem): boolean => Arr.some(item.alsoActive ?? [], (href) => isPathActive(currentPath, href)) || Arr.some(item.subItems ?? [], (sub) => matchSubItem(currentPath, sub) !== undefined) +/** The Alerts row is open here, so its Inbox count is shown (and worth reading). */ +export const isAlertsOpenAt = (currentPath: string): boolean => isNavItemActive(currentPath, alertsItem) + export interface NavPreview { readonly glyphs: ReadonlyArray /** Marks past the cap, drawn as `+N`. */ diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index d1f5daa5a0..fd4ae2e8c8 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -462,6 +462,34 @@ describe("alerts section", () => { measured, expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), ) + scene( + { update, view: shellAt("/alerts/rules/0199a001-0000-7000-8000-000000000001") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("the Inbox counts unacknowledged incidents for this org, and thin rules split the groups", () => { + const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) + scene( + { update, view: shellAt("/alerts/rules") }, + given(counted.model), + measured, + expect(text("3")).toExist(), + expect(role("separator")).toExist(), + ) + // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn({ alertsInboxCount: 0 })), + measured, + expect(text("0")).toBeAbsent(), + ) + const stray = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_other", count: 4 })) + expectVitest(stray.model.alertsInboxCount).toBeNull() + expectVitest(enter("org_other")(counted.model).model.alertsInboxCount).toBeNull() }) test("elsewhere the sections stay hidden", () => { diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..96d0d55382 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -65,6 +65,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + alertsInboxCount: () => null, onCall: () => OnCallData.Idle(), onCallNextReadAtMs: () => null, onCallBackoffMs: () => 0, @@ -550,6 +551,10 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => modifyFields(current, { sources: () => SourcesData.Failure({ error: "Failed" }) }), ), // A result for an org the shell has left is dropped. + UpdatedAlertsInboxCount: ({ orgId, count }) => + Option.contains(model.maybeOrgId, orgId) + ? { model: modifyFields(model, { alertsInboxCount: () => count }) } + : { model }, SucceededLoadDashboards: ({ orgId, dashboards }) => Option.contains(model.maybeOrgId, orgId) ? { diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 749a57a46f..4206e21fe9 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -10,11 +10,13 @@ import { sidebarGroupLabel, sidebarGroupToggle, sidebarMenu, + sidebarMenuBadge, sidebarMenuButton, sidebarMenuItem, sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, + sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -96,41 +98,75 @@ const collapsedSectionRow = (h: HtmlBuilder, model: Model, item: NavIte ), ]) -const subList = (h: HtmlBuilder, subItems: ReadonlyArray, currentPath: string): Html => { +/** The live count a sub-item shows, when it has one and it is above zero (an org change clears it). */ +const subCountOf = (model: Model, sub: NavSubItem): number | undefined => + sub.count === "alertsInbox" && model.alertsInboxCount !== null && model.alertsInboxCount > 0 + ? model.alertsInboxCount + : undefined + +// A rule between groups keeps the sub-list's lane line unbroken. +const groupRule = (h: HtmlBuilder): Html => + sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) + +const subList = ( + h: HtmlBuilder, + model: Model, + subItems: ReadonlyArray, + currentPath: string, +): Html => { const activeSubHref = activeSubHrefOf(subItems, currentPath) return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.map(subItems, (sub) => - sidebarMenuSubItem( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ], - ), - ), + Arr.flatMap(subItems, (sub, index) => { + const count = subCountOf(model, sub) + return [ + ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), + sidebarMenuSubItem( + h, + sub.href === activeSubHref + ? "border-sidebar-primary border-l-2 ps-2.5" + : "border-sidebar-border border-l-2 ps-2.5", + [ + sidebarMenuSubButton( + h, + { + className: + "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + h.span([], [sub.title]), + ], + ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1 sm:top-0.5", + ), + ]), + ], + ), + ] + }), ) } @@ -199,7 +235,7 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive ? [subList(h, model, subItems, currentPath)] : []), ]) } diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index da920252d9..134b8cdb59 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -1,5 +1,15 @@ import { isClerkAuthEnabled } from "./lib/services/common/auth-mode" -import { Clock, Duration, Effect, Option, Schedule, Schema, Stream } from "effect" +import { + Array as Arr, + Clock, + Duration, + Effect, + Option, + Schedule, + Schema, + Stream, + String as Str, +} from "effect" import { Dom, Subscription } from "foldkit" import { Message } from "./message" import type { Resources } from "./platform/resources" @@ -17,6 +27,8 @@ import { connectSubscriptions } from "./page/onboarding/quick-start-connect" import * as SourcesFlow from "./page/sources/new/embed" import { clerkSnapshots } from "./platform/clerk" import * as Shell from "./shell" +import { isAlertsOpenAt } from "./shell/nav" +import { hrefOf } from "./route" import { staleBuildRecovery } from "./shell/version" import { pollTicks } from "./data/poll" import { collectionLoadFailed } from "./page/alerts/shared/collection-load" @@ -78,6 +90,14 @@ const shellDashboardsOrgId = (model: Model): string | null => ? Option.getOrNull(model.shell.maybeOrgId) : null +/** The org whose Alerts › Inbox count the sidebar shows: while an alerts page has the row open. */ +const shellAlertsInboxOrgId = (model: Model): string | null => + model.session._tag === "SignedIn" && + rendersShell(model.route) && + isAlertsOpenAt(Arr.headNonEmpty(Str.split(hrefOf(model.route), "?"))) + ? Option.getOrNull(model.shell.maybeOrgId) + : null + const root = Subscription.make()((entry) => ({ /** `subscribeSelfHostedAuthChanges`: a renewal elsewhere ended or replaced the session. */ selfHostedSession: entry( @@ -180,6 +200,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The Alerts › Inbox count, over the `alert_incidents` collection the alerts pages already sync. */ + shellAlertsInbox: entry( + { orgId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ orgId: shellAlertsInboxOrgId(model) }), + dependenciesToStream: ({ orgId }) => + orgId === null + ? Stream.empty + : Shell.alertsInboxCounts(orgId, (count) => + Message.GotShellMessage({ + message: Shell.Message.UpdatedAlertsInboxCount({ orgId, count }), + }), + ), + }, + ), /** `useDashboardsRead` for the Pinned group: the org's live `dashboards` collection. */ shellDashboards: entry( { orgId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 754840a01d..ae98087bc1 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -39,7 +39,7 @@ const SAMPLE_PATHS = [ "/services/checkout", "/alerts", "/alerts/incidents/inc_1", - "/alerts/00000000-0000-4000-8000-000000000002", + "/alerts/rules/00000000-0000-4000-8000-000000000002", "/alerts/create", "/alerts/uptime/new", "/alerts/slos/new", diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index b632d9b440..fae9c9bff9 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -313,10 +313,15 @@ export const sidebarMenuItem = ( children, ) -export const sidebarMenuBadge = (h: HtmlBuilder, children: Children): Html => +/** ``; `className` places it beside a sub-item, which the peer offsets don't cover. */ +export const sidebarMenuBadge = ( + h: HtmlBuilder, + children: Children, + className?: string, +): Html => h.div( [ - h.Class(cn(...sidebarMenuBadgeStyles)), + h.Class(cn(...sidebarMenuBadgeStyles, className)), h.DataAttribute("sidebar", "menu-badge"), h.DataAttribute("slot", "sidebar-menu-badge"), ], From 5068806324fdffe87e4a0f033796f124da9f7cc0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:45:58 +0100 Subject: [PATCH 06/35] feat(alerts): live coverage preview in the schedule editor and detail page The schedule editor renders the draft with the server's renderOnCall (alerting-core/oncall) on every edit: a lane per layer, the final lane with No one gap bars, a gap summary with links that focus each gap, weekend shading, a now line and a 1/2/4 week range. The detail page draws the same timeline from the server's shifts. --- apps/web/package.json | 1 + .../src/page/alerts/schedule-form/command.ts | 10 +- .../src/page/alerts/schedule-form/message.ts | 8 + .../src/page/alerts/schedule-form/model.ts | 4 + .../src/page/alerts/schedule-form/preview.ts | 82 ++++++ .../schedule-form/schedule-form.scene.test.ts | 55 ++++ .../src/page/alerts/schedule-form/update.ts | 18 ++ .../web/src/page/alerts/schedule-form/view.ts | 17 +- apps/web/src/page/alerts/schedule/command.ts | 10 +- apps/web/src/page/alerts/schedule/message.ts | 5 + apps/web/src/page/alerts/schedule/model.ts | 3 + .../alerts/schedule/schedule.scene.test.ts | 24 +- apps/web/src/page/alerts/schedule/update.ts | 16 +- apps/web/src/page/alerts/schedule/view.ts | 210 ++++++--------- .../page/alerts/shared/coverage-preview.ts | 241 ++++++++++++++++++ .../alerts/shared/schedule-coverage.test.ts | 236 +++++++++++++++++ .../page/alerts/shared/schedule-coverage.ts | 227 +++++++++++++++++ bun.lock | 1 + packages/alerting-core/package.json | 3 +- packages/views/src/ui/shift-timeline.ts | 47 +++- 20 files changed, 1075 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/page/alerts/schedule-form/preview.ts create mode 100644 apps/web/src/page/alerts/shared/coverage-preview.ts create mode 100644 apps/web/src/page/alerts/shared/schedule-coverage.test.ts create mode 100644 apps/web/src/page/alerts/shared/schedule-coverage.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5e40e73a9d..cbc8133733 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index ffa4c6ff33..5637ab0cc6 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -1,7 +1,7 @@ import { OnCallScheduleId } from "@maple/domain/http" import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command } from "foldkit" +import { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" @@ -42,6 +42,14 @@ export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), }) +/** A gap's bar takes focus, which scrolls it into view. */ +export const FocusPreviewGap = Command.define("FocusScheduleFormGap", { + args: { selector: Schema.String }, + messages: [Message.CompletedFocusGap], + execute: ({ selector }) => + Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), +}) + export const SaveSchedule = Command.define("SaveOnCallSchedule", { args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, messages: [Message.CompletedSave], diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index 922636d3dc..fb69c903e0 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -7,6 +7,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { RotationKind } from "./form" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -46,6 +47,13 @@ export const Message = defineMessageUnion({ edge: Schema.Literals(["start", "end"]), value: Schema.String, }, + ClickedPreviewPrevious: {}, + ClickedPreviewNext: {}, + ClickedPreviewToday: {}, + PickedPreviewRange: { days: RangeDays }, + /** A gap in the summary: its bar on the timeline takes focus. */ + ClickedPreviewGap: { index: Schema.Number }, + CompletedFocusGap: {}, ClickedSave: {}, CompletedSave: { result: outcome(V2OnCallSchedule) }, }) diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index da4206ac16..f192a593d3 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -8,6 +8,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { Form } from "./form" // `/alerts/schedules/new`: create an on-call schedule, or edit one (`?scheduleId=`). @@ -35,6 +36,9 @@ export const Model = Schema.Struct({ saveFailure: Schema.NullOr(LoadFailure), grants: Schema.Array(Policy.PermissionName), isSessionLoaded: Schema.Boolean, + /** The coverage preview: weeks from today, and how many days it shows. */ + previewWeekOffset: Schema.Number, + previewRange: RangeDays, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts new file mode 100644 index 0000000000..9688614b37 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -0,0 +1,82 @@ +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { OnCallTimeZone } from "@maple/domain/http" +import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" +import { memberName } from "../shared/org-members" +import { draftCoverage, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { localInputMs } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { PREVIEW_SCOPE } from "./update" + +// The editor's live coverage preview: the draft rendered on every edit, no save needed. + +const isZone = Schema.is(OnCallTimeZone) + +const layerName = (name: string, index: number) => + Str.trim(name) === "" ? `Layer ${index + 1}` : Str.trim(name) + +/** Plain-language notes on the draft's layers, one per issue that changes the preview. */ +export const previewWarnings = (model: Pick): ReadonlyArray => + Arr.flatMap(model.form.layers, (layer, index) => { + const name = layerName(layer.name, index) + if (layer.participants.length === 0) return [`${name} has no people, so it covers nothing.`] + const startMs = + layer.startsAtMs === null + ? localInputMs(layer.startsAt, model.form.timeZone) + : Option.some(layer.startsAtMs) + return Option.exists(startMs, (ms) => ms < model.nowMs) + ? [`${name} starts in the past. Shifts are scheduled from now, keeping its rotation order.`] + : [] + }) + +const asPreview = (shift: { + userId: string + startMs: number + endMs: number + source: { kind: string } +}): PreviewShift => ({ + userId: shift.userId, + startMs: shift.startMs, + endMs: shift.endMs, + isOverride: shift.source.kind === "override", +}) + +export const schedulePreview = (h: HtmlBuilder, model: Model): Html => { + const timeZone = isZone(model.form.timeZone) ? model.form.timeZone : "UTC" + const days = Number(model.previewRange) + const range = previewRange(model.nowMs, timeZone, model.previewWeekOffset, days) + const coverage = draftCoverage(model.form, [], range.startMs, range.endMs, model.nowMs) + return coveragePreview(h, { + scope: PREVIEW_SCOPE, + title: "Coverage preview", + timeZone, + fromMs: range.startMs, + toMs: range.endMs, + nowMs: model.nowMs, + weekOffset: model.previewWeekOffset, + rangeDays: model.previewRange, + lanes: Arr.map(model.form.layers, (layer, index) => ({ + key: layer.key, + label: layerName(layer.name, index), + detail: `Layer ${index + 1}`, + shifts: Option.map(coverage.layers[index] ?? Option.none(), Arr.map(asPreview)), + emptyText: + layer.participants.length === 0 + ? "Add people to see shifts" + : Option.isNone(previewLayerOf(layer, timeZone)) + ? "Fix the rotation to see shifts" + : "Not active in this period", + })), + final: Arr.map(coverage.final, asPreview), + gaps: coverage.gaps, + nameOf: (userId) => memberName(model.members, userId), + onPrevious: Message.ClickedPreviewPrevious(), + onNext: Message.ClickedPreviewNext(), + onToday: Message.ClickedPreviewToday(), + onRange: (days) => Message.PickedPreviewRange({ days }), + onFocusGap: (index) => Message.ClickedPreviewGap({ index }), + warnings: previewWarnings(model), + note: `Times in ${timeZone}. Overrides are not shown here.`, + }) +} 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 index f290843e39..3d24459790 100644 --- 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 @@ -306,4 +306,59 @@ describe("schedule form", () => { "FetchScheduleFormSchedule", ) }) + + describe("coverage preview", () => { + test("a layer with nobody in it leaves the next 14 days uncovered and says why", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + expectView(text("Primary has no people, so it covers nothing.")).toExist(), + expectView(text(/^1 gap in the next 14 days, longest 13d 12h/u)).toExist(), + expectView(text("Add people to see shifts")).toExist(), + ) + }) + + test("adding a person updates the preview to covered, and a restriction opens gaps", () => { + const staffed = run(ready(), [addPerson("l0", 0)]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered for the next 14 days")).toExist(), + expectView(text(/Primary starts in the past\. Shifts are scheduled from now/u)).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + ) + // Weeknights only (18:00 to 08:00): days and weekends are gaps. + const nights = run(staffed, [Message.ClickedAddRestriction({ key: "l0" })]) + scene( + { update: pageUpdate, view }, + given(nights), + expectView( + text( + "10 gaps in the next 14 days, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)", + ), + ).toExist(), + expectView(role("button", { name: "Sat 14 Nov 08:00 to Mon 16 Nov 18:00" })).toExist(), + ) + const focus = pageUpdate(nights, Message.ClickedPreviewGap({ index: 4 })) + expect(names(focus.commands)).toEqual(["FocusScheduleFormGap"]) + }) + + test("the range switch and week steps change what the preview covers", () => { + const staffed = run(ready(), [addPerson("l0", 0), Message.PickedPreviewRange({ days: "7" })]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered for the next 7 days")).toExist(), + expectView(text("10 Nov to 16 Nov")).toExist(), + ) + const later = run(staffed, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(text("Covered in this period")).toExist(), + expectView(text("17 Nov to 23 Nov")).toExist(), + ) + expect(run(later, [Message.ClickedPreviewToday()]).previewWeekOffset).toBe(0) + }) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index f8ee3957de..b90a2260de 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -7,11 +7,13 @@ 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 { DEFAULT_RANGE_DAYS, gapBarId } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, FetchScheduleFormSession, + FocusPreviewGap, ReadScheduleFormDefaults, SaveSchedule, } from "./command" @@ -35,6 +37,9 @@ type FormRoute = Extract export const ZONE_PICKER_ID = "schedule-form-zone" +/** Prefixes the preview's gap bar ids. */ +export const PREVIEW_SCOPE = "schedule-preview" + export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` export const init = (route: FormRoute, context: PageContext): Return => { @@ -58,6 +63,8 @@ export const init = (route: FormRoute, context: PageContext): Return => { saveFailure: null, grants: [], isSessionLoaded: false, + previewWeekOffset: 0, + previewRange: DEFAULT_RANGE_DAYS, } const common = [ ReadScheduleFormDefaults(), @@ -301,6 +308,17 @@ export const update = (model: Model, message: Message, context: PageContext): Re editRestriction(model, key, restrictionKey, (restriction) => edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, ), + ClickedPreviewPrevious: () => ({ + model: { ...model, previewWeekOffset: model.previewWeekOffset - 1 }, + }), + ClickedPreviewNext: () => ({ model: { ...model, previewWeekOffset: model.previewWeekOffset + 1 } }), + ClickedPreviewToday: () => ({ model: { ...model, previewWeekOffset: 0 } }), + PickedPreviewRange: ({ days }) => ({ model: { ...model, previewRange: days } }), + ClickedPreviewGap: ({ index }) => ({ + model, + commands: [FocusPreviewGap({ selector: `#${gapBarId(PREVIEW_SCOPE, index)}` })], + }), + CompletedFocusGap: () => ({ model }), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 3dc01e80da..8dfa133e27 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -49,6 +49,7 @@ import { } from "./form" import { type LayerField, Message } from "./message" import { canManage, type Model, zoneItems } from "./model" +import { schedulePreview } from "./preview" import { participantMenuId } from "./update" import { stripClass } from "@maple/views/ui/rule" @@ -558,7 +559,21 @@ export const view = Submodel.defineView((model, 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), + // The form on the left and its live preview on the right; stacked under xl. + h.div( + [h.Class("grid gap-6 xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] xl:items-start")], + [ + formCard( + h, + [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], + FORM_WIDTH, + ), + h.div( + [h.Class("min-w-0 rounded-md border bg-card p-4 xl:sticky xl:top-0")], + [schedulePreview(h, model)], + ), + ], + ), ]), actionBar(h, model), ]) diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts index 9cceccf980..13f9457ab5 100644 --- a/apps/web/src/page/alerts/schedule/command.ts +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -1,7 +1,7 @@ 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 { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { createOverride, @@ -83,3 +83,11 @@ export const DeleteOverride = Command.define("DeleteOnCallOverride", { Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), ), }) + +/** A gap's bar takes focus, which scrolls it into view. */ +export const FocusScheduleGap = Command.define("FocusScheduleGap", { + args: { selector: Schema.String }, + messages: [Message.CompletedFocusGap], + execute: ({ selector }) => + Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), +}) diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts index cf1187acc6..7607bca282 100644 --- a/apps/web/src/page/alerts/schedule/message.ts +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -5,6 +5,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -26,6 +27,10 @@ export const Message = defineMessageUnion({ ClickedPreviousWeek: {}, ClickedNextWeek: {}, ClickedThisWeek: {}, + PickedRange: { days: RangeDays }, + /** A gap in the summary: its bar on the calendar takes focus. */ + ClickedGap: { index: Schema.Number }, + CompletedFocusGap: {}, /** A final shift's bar: an override over its whole range, for someone else to cover. */ ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, ClickedNewOverride: {}, diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index 545d5434ef..68afb4905e 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -5,6 +5,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" // `/alerts/schedules/$scheduleId`: who is on call, the calendar of shifts, and the overrides. @@ -30,6 +31,8 @@ export const Model = Schema.Struct({ scheduleAskedAtMs: Schema.NullOr(Schema.Number), /** Weeks from this one the calendar shows: 0 is the week starting today. */ weekOffset: Schema.Number, + /** How many days the calendar shows from its week's first day. */ + rangeDays: RangeDays, shifts: KeyedSchema(V2OnCallShifts, LoadFailure), overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 4866bbee22..38fad37c6b 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -110,7 +110,7 @@ describe("on-call schedule page", () => { { update: pageUpdate, view }, given(model), expectView(text("Sun 25")).toExist(), - expectView(text("22 Oct to 28 Oct")).toExist(), + expectView(text("22 Oct to 4 Nov")).toExist(), ) }) @@ -122,7 +122,7 @@ describe("on-call schedule page", () => { scene( { update: pageUpdate, view }, given(next.model), - expectView(text("17 Nov to 23 Nov")).toExist(), + expectView(text("17 Nov to 30 Nov")).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), ) scene( @@ -456,4 +456,24 @@ describe("on-call schedule page", () => { expectView(text("Couldn't load members, try reloading the page")).toExist(), ) }) + + test("the calendar summarises coverage like the editor, with a range switch that reads the new range", () => { + const model = loaded(ONCALL_NOW) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Covered for the next 14 days")).toExist(), + expectView(role("group", { name: "Range" })).toExist(), + ) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + expect(names(month.commands)).toEqual(["FetchOnCallShifts"]) + expect(Option.map(rangeOf(month.model), (r) => Math.round((r.toMs - r.fromMs) / 86_400_000))).toEqual( + Option.some(28), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(month.model, ONCALL_NOW)), + expectView(text("10 Nov to 7 Dec")).toExist(), + ) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 47b0e8f77d..713a3bfc58 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -8,6 +8,7 @@ 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 { DEFAULT_RANGE_DAYS, gapBarId } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -18,6 +19,7 @@ import { FetchScheduleMembers, FetchScheduleSession, FetchShifts, + FocusScheduleGap, ReadScheduleClock, } from "./command" import { Message } from "./message" @@ -30,9 +32,12 @@ 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. */ +/** The calendar steps a week of the schedule zone's days at a time. */ export const CALENDAR_DAYS = 7 +/** Prefixes the calendar's gap bar ids. */ +export const CALENDAR_SCOPE = "schedule-calendar" + export const scheduleOf = (model: Model): Option.Option => AsyncData.getData(model.schedule.data) @@ -46,7 +51,7 @@ export const rangeOf = (model: Model): Option.Option { schedule: Keyed.idle(), scheduleAskedAtMs: null, weekOffset: 0, + rangeDays: DEFAULT_RANGE_DAYS, shifts: Keyed.idle(), overrides: Keyed.idle(), members: null, @@ -344,6 +350,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), ClickedThisWeek: () => moveWeek(model, context, 0), + PickedRange: ({ days }) => requestShifts({ ...model, rangeDays: days }, context, "Load"), + ClickedGap: ({ index }) => ({ + model, + commands: [FocusScheduleGap({ selector: `#${gapBarId(CALENDAR_SCOPE, index)}` })], + }), + CompletedFocusGap: () => ({ model }), // 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 }, diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index 90a45a91eb..d8467c71bf 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -15,7 +15,6 @@ 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" @@ -24,11 +23,14 @@ 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 { coveragePreview, type PreviewShift } from "../shared/coverage-preview" import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/oncall-schedules" +import { gapsOf } from "../shared/schedule-coverage" import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" import { + CALENDAR_SCOPE, DELETE_OVERRIDE_DIALOG_ID, OVERRIDE_DIALOG_ID, overrideIssue, @@ -58,145 +60,95 @@ const onCallLine = (model: Model, schedule: V2OnCallSchedule): string => `${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 period", - }, - ...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 period", - })), - ), -] - -/** "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)}` -} +const previewShiftOf = (shift: V2OnCallShift): PreviewShift => ({ + userId: shift.user_id, + startMs: isoMs(shift.starts_at), + endMs: isoMs(shift.ends_at), + isOverride: shift.source.kind === "override", +}) -/** The calendar's header: the week, prev/this week/next, and which zone the times read in. */ +/** The calendar: the same coverage timeline the editor previews, from the server's shifts. */ 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 zoneNote = + viewerZone === schedule.time_zone + ? `Times in ${schedule.time_zone}.` + : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` + const note = `${zoneNote}${canManage(model) ? " Pick a shift in the final row to cover it with an override." : ""}` + const preview = ( + fromMs: number, + toMs: number, + shifts: Option.Option, + placeholder?: Html, + ): Html => { + const final = Option.match(shifts, { + onNone: () => [], + onSome: (s) => Arr.map(s.final, previewShiftOf), + }) + return coveragePreview(h, { + scope: CALENDAR_SCOPE, + title: `Shifts for ${schedule.name}`, + timeZone: schedule.time_zone, + fromMs, + toMs, + nowMs, + weekOffset: model.weekOffset, + rangeDays: model.rangeDays, + lanes: Arr.map(schedule.layers, (layer, index) => ({ + key: `layer-${index}`, + label: layer.name, + detail: `Layer ${index + 1}`, + shifts: Option.map(shifts, (s) => Arr.map(s.layers[index] ?? [], previewShiftOf)), + emptyText: "Not active in this period", + })), + final, + gaps: gapsOf(final, Math.max(fromMs, nowMs), toMs), + nameOf: (userId) => memberName(model.members, userId), + onPrevious: Message.ClickedPreviousWeek(), + onNext: Message.ClickedNextWeek(), + onToday: Message.ClickedThisWeek(), + onRange: (days) => Message.PickedRange({ days }), + onFocusGap: (index) => Message.ClickedGap({ index }), + // Only a final shift that has not ended opens an override, and only for someone who may add one. + onFinalShift: (shift) => + canManage(model) && shift.endMs > nowMs + ? Option.some(Message.ClickedShift({ startMs: shift.startMs, endMs: shift.endMs })) + : Option.none(), + note, + placeholder, + }) + } + const loading = (fromMs: number, toMs: number) => + preview(fromMs, toMs, Option.none(), skeleton(h, "h-40 w-full")) + // Shifts kept from another range (a read still in flight) draw as the skeleton, not blank rows. + const loaded = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) + ? preview(fromMs, toMs, Option.some(shifts)) + : loading(fromMs, toMs) 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"), + onIdle: () => loading(fromMs, toMs), + onLoading: () => loading(fromMs, toMs), 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")], + preview( + fromMs, + toMs, + Option.none(), + errorState(h, { + failure, + title: "Couldn't load the shifts", + onRetry: Message.ClickedRetryShifts(), + }), ), - 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")], - ), - ]), + onRefreshing: (shifts) => loaded(fromMs, toMs, shifts), + onStale: ({ data }) => loaded(fromMs, toMs, data), + onSuccess: (shifts) => loaded(fromMs, toMs, shifts), }), - 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, - ], - ) + }) + return h.section([h.Class("space-y-3")], [body]) } const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V2OnCallOverride): Html => { diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts new file mode 100644 index 0000000000..78950de51d --- /dev/null +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -0,0 +1,241 @@ +import { Array as Arr, Option, Schema } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" +import { hstack, vstack } from "@maple/views/ui/stack" +import { heading } from "@maple/views/ui/heading" +import { typography } from "@maple/views/ui/typography" +import { formatShiftEdge } from "./oncall-schedules" +import { gapRangeText, gapSummary, type Interval } from "./schedule-coverage" + +// The coverage timeline both the schedule editor (from the draft) and the detail page (from the +// server's shifts) draw: a lane per layer, the final lane with its "No one" gaps, and the controls. + +export const RangeDays = Schema.Literals(["7", "14", "28"]) +export type RangeDays = typeof RangeDays.Type +export const DEFAULT_RANGE_DAYS: RangeDays = "14" + +const RANGE_OPTIONS: ReadonlyArray> = [ + { value: "7", label: "1 week" }, + { value: "14", label: "2 weeks" }, + { value: "28", label: "4 weeks" }, +] + +/** The element id of a gap's bar, so its summary link can scroll to and focus it. */ +export const gapBarId = (scope: string, index: number): string => `${scope}-gap-${index}` + +export type PreviewShift = Readonly<{ userId: string; startMs: number; endMs: number; isOverride: boolean }> + +export type PreviewLane = Readonly<{ + key: string + label: string + detail: string + /** None while the layer cannot be drawn; `emptyText` says why. */ + shifts: Option.Option> + emptyText: string +}> + +export type CoveragePreviewOptions = Readonly<{ + /** Prefixes the gap bars' ids, unique on the page. */ + scope: string + title: string + timeZone: string + fromMs: number + toMs: number + nowMs: number + weekOffset: number + rangeDays: RangeDays + lanes: ReadonlyArray + final: ReadonlyArray + gaps: ReadonlyArray + nameOf: (userId: string) => string + onPrevious: M + onNext: M + onToday: M + onRange: (days: RangeDays) => M + onFocusGap: (index: number) => M + /** A final shift's bar action (the detail page's "cover with an override"). */ + onFinalShift?: (shift: PreviewShift) => Option.Option + /** Plain-language notes over the timeline. */ + warnings?: ReadonlyArray + /** A line under the header, after the zone note. */ + note?: string + /** Drawn in place of the summary and timeline while their data loads; the header stays. */ + placeholder?: Html | undefined +}> + +const rangeText = (startMs: number, endMs: number, timeZone: string) => + `${formatShiftEdge(startMs, timeZone)} to ${formatShiftEdge(endMs, timeZone)}` + +/** The lanes as timeline rows: the final row on top, then the layers top first, as they stack. */ +export const previewRows = (options: CoveragePreviewOptions): ReadonlyArray> => { + const shiftBar = (shift: PreviewShift, key: string, isFinal: boolean): TimelineBar => { + const name = options.nameOf(shift.userId) + const bar: TimelineBar = { + key, + startMs: shift.startMs, + endMs: shift.endMs, + label: name, + title: `${shift.isOverride ? "Override: " : ""}${name}, ${rangeText(shift.startMs, shift.endMs, options.timeZone)}`, + tone: shift.isOverride ? "info" : isFinal ? "primary" : "neutral", + } + const click = + isFinal && options.onFinalShift !== undefined ? options.onFinalShift(shift) : Option.none() + return Option.match(click, { onNone: () => bar, onSome: (onClick) => ({ ...bar, onClick }) }) + } + const gapBars = Arr.map(options.gaps, (gap, index): TimelineBar => ({ + key: `gap-${gap.startMs}`, + id: gapBarId(options.scope, index), + startMs: gap.startMs, + endMs: gap.endMs, + label: "No one", + title: `No one on call, ${gapRangeText(gap, options.timeZone)}`, + tone: "warning", + })) + return [ + { + key: "final", + label: "Final schedule", + detail: "Who gets paged", + bars: [ + ...Arr.map(options.final, (shift, index) => shiftBar(shift, `final-${index}`, true)), + ...gapBars, + ], + emptyText: "Nobody on call in this period", + }, + ...Arr.reverse( + Arr.map(options.lanes, (lane): TimelineRow => ({ + key: lane.key, + label: lane.label, + detail: lane.detail, + bars: Option.match(lane.shifts, { + onNone: () => [], + onSome: (shifts) => + Arr.map(shifts, (shift, at) => shiftBar(shift, `${lane.key}-${at}`, false)), + }), + emptyText: lane.emptyText, + })), + ), + ] +} + +/** "10 Nov to 23 Nov": the first and last day shown. */ +const periodLabel = (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)}` +} + +const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const days = Number(options.rangeDays) + const summary = gapSummary(options.gaps, options.timeZone, options.weekOffset, days) + const isCovered = options.gaps.length === 0 + return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, [ + hstack(h, { gap: "1.5" }, [ + h.span( + [h.AriaHidden(true), h.Class(isCovered ? "text-primary" : "text-warning-foreground")], + [icon(h, isCovered ? "CircleCheckIcon" : "AlertWarningIcon")], + ), + typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [ + summary, + ]), + ]), + ...(isCovered + ? [] + : [ + hstack( + h, + { gap: "1", wrap: true }, + Arr.map(options.gaps, (gap, index) => + button( + h, + { + size: "xs", + variant: "outline", + attributes: [ + h.Key(`gap-link-${gap.startMs}`), + h.OnClick(options.onFocusGap(index)), + ], + }, + [gapRangeText(gap, options.timeZone)], + ), + ), + ), + ]), + ]) +} + +export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + h.section( + [h.Class("min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], + [ + hstack(h, { gap: "2", justify: "between", wrap: true }, [ + vstack(h, { gap: "0" }, [ + heading(h, { level: 2, variant: "section" }, [options.title]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + periodLabel(options.fromMs, options.toMs, options.timeZone), + ]), + ]), + hstack(h, { gap: "1", wrap: true }, [ + segmentedSelect(h, { + options: RANGE_OPTIONS, + value: options.rangeDays, + onChange: options.onRange, + size: "xs", + ariaLabel: "Range", + }), + iconButton(h, { label: "Previous week", attributes: [h.OnClick(options.onPrevious)] }, [ + icon(h, "ChevronLeftIcon"), + ]), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: options.weekOffset === 0, + attributes: [h.OnClick(options.onToday)], + }, + ["Today"], + ), + iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ + icon(h, "ChevronRightIcon"), + ]), + ]), + ]), + ...(options.note === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), + ...Arr.map(options.warnings ?? [], (warning) => + hstack(h, { gap: "1.5", attributes: [h.Key(warning)] }, [ + h.span( + [h.AriaHidden(true), h.Class("text-warning-foreground")], + [icon(h, "CircleInfoIcon")], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [warning]), + ]), + ), + ...(options.placeholder === undefined + ? [gapSummaryView(h, options), timelineView(h, options)] + : [options.placeholder]), + ], + ) + +const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + shiftTimeline(h, { + fromMs: options.fromMs, + toMs: options.toMs, + timeZone: options.timeZone, + nowMs: options.nowMs, + zoneLabel: options.timeZone, + rows: previewRows(options), + ariaLabel: options.title, + nowLabel: `Now, ${formatShiftEdge(options.nowMs, options.timeZone)}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + { tone: "warning", label: "No one" }, + ], + }) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts new file mode 100644 index 0000000000..cc374d6fe7 --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -0,0 +1,236 @@ +import { onCallAt } from "@maple/alerting-core/oncall" +import { OnCallOverride, UserId } from "@maple/domain/http" +import { Array as Arr, Option, Schema } from "effect" +import { describe, expect, it } from "vitest" +import { emptyLayer, type Form, type LayerDraft } from "../schedule-form/form" +import { + draftConfigOf, + draftCoverage, + durationText, + gapsOf, + gapSummary, + previewRange, + type Interval, +} from "./schedule-coverage" + +const HOUR = 3_600_000 +const at = (iso: string) => Date.parse(iso) +const people = Schema.decodeUnknownSync(Schema.Array(UserId)) +const decodeOverrides = Schema.decodeUnknownSync(Schema.Array(OnCallOverride)) + +const layer = (patch: Partial): LayerDraft => ({ + ...emptyLayer(0, 0, at("2026-11-02T08:00:00Z"), "Europe/Berlin"), + ...patch, +}) +const formOf = (layers: ReadonlyArray, timeZone = "Europe/Berlin"): Form => ({ + name: "Ops", + description: "", + timeZone, + layers, + nextKey: layers.length, +}) + +/** Every `stepMs` across the range, checks the preview's final lane against the server's `onCallAt`. */ +const expectMatchesServer = ( + form: Form, + overrides: ReadonlyArray, + range: Interval, + stepMs: number, +) => { + const config = Option.getOrThrow(draftConfigOf(form)).config + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + const count = Math.floor((range.endMs - range.startMs) / stepMs) + Arr.forEach(Arr.range(0, count - 1), (i) => { + const ms = range.startMs + i * stepMs + 1 + const server = onCallAt(config, overrides, ms) + const preview = Arr.findFirst(coverage.final, (s) => s.startMs <= ms && ms < s.endMs) + const inGap = Arr.some(coverage.gaps, (g) => g.startMs <= ms && ms < g.endMs) + expect(Option.getOrNull(Option.map(preview, (s) => s.userId))).toBe( + server === null ? null : server.userId, + ) + expect(inGap).toBe(server === null) + }) +} + +describe("gapsOf", () => { + it("returns the whole range when nothing covers it", () => { + expect(gapsOf([], 0, 10)).toEqual([{ startMs: 0, endMs: 10 }]) + }) + it("returns no gaps when shifts cover the range end to end", () => { + expect( + gapsOf( + [ + { startMs: 5, endMs: 12 }, + { startMs: -2, endMs: 5 }, + ], + 0, + 10, + ), + ).toEqual([]) + }) + it("finds holes between, before and after shifts", () => { + expect( + gapsOf( + [ + { startMs: 2, endMs: 4 }, + { startMs: 6, endMs: 8 }, + ], + 0, + 10, + ), + ).toEqual([ + { startMs: 0, endMs: 2 }, + { startMs: 4, endMs: 6 }, + { startMs: 8, endMs: 10 }, + ]) + }) +}) + +describe("draftCoverage", () => { + const range = { startMs: at("2026-11-09T00:00:00Z"), endMs: at("2026-11-23T00:00:00Z") } + + it("cycles members at the daily handoff time", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben", "chen"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-11-09T09:00", + }), + ]) + const coverage = draftCoverage(form, [], at("2026-11-09T08:00:00Z"), at("2026-11-12T08:00:00Z"), 0) + expect(Arr.map(coverage.final, (s) => s.userId)).toEqual(["ana", "ben", "chen"]) + // 09:00 Berlin is 08:00 UTC in November. + expect(Arr.map(coverage.final, (s) => new Date(s.startMs).toISOString())).toEqual([ + "2026-11-09T08:00:00.000Z", + "2026-11-10T08:00:00.000Z", + "2026-11-11T08:00:00.000Z", + ]) + expect(coverage.gaps).toEqual([]) + }) + + it("layers a business-hours layer over a weekly one and applies overrides, matching onCallAt", () => { + const form = formOf([ + layer({ participants: people(["ana", "ben"]), rotation: "weekly", startsAt: "2026-11-02T09:00" }), + layer({ + key: "l1", + participants: people(["dev"]), + rotation: "daily", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }), + ]) + const overrides = decodeOverrides([ + { + id: "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a60", + userId: "eve", + startsAt: at("2026-11-12T10:00:00Z"), + endsAt: at("2026-11-13T02:00:00Z"), + createdAt: 1, + }, + ]) + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + expect(coverage.gaps).toEqual([]) + expect(Arr.some(coverage.final, (s) => s.userId === "eve")).toBe(true) + expectMatchesServer(form, overrides, range, HOUR / 2) + }) + + it("reports restriction holes as gaps that match onCallAt being null", () => { + const form = formOf([ + layer({ + participants: people(["ana"]), + rotation: "weekly", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "08:00", + endTime: "20:00", + }, + ], + }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + // Nights plus the two weekends. + expect(coverage.gaps.length).toBeGreaterThan(8) + expectMatchesServer(form, [], range, HOUR / 2) + }) + + it("hands off at local 09:00 across the October DST change", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-10-23T09:00", + }), + ]) + const dst = { startMs: at("2026-10-22T22:00:00Z"), endMs: at("2026-10-28T23:00:00Z") } + const coverage = draftCoverage(form, [], dst.startMs, dst.endMs, dst.startMs) + const sunday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-25T07:00:00Z")) + // Sat 09:00 CEST (07:00Z) to Sun 09:00 CET (08:00Z) is 25 hours. + const saturday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-24T07:00:00Z")) + expect(Option.map(saturday, (s) => s.endMs - s.startMs)).toEqual(Option.some(25 * HOUR)) + expect(Option.isNone(sunday)).toBe(true) + expectMatchesServer(form, [], dst, HOUR / 4) + }) + + it("treats a layer with no members as not previewable and the schedule as all gap", () => { + const form = formOf([layer({ participants: [] })]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Option.isNone(coverage.layers[0] ?? Option.some([]))).toBe(true) + expect(coverage.gaps).toEqual([{ startMs: range.startMs, endMs: range.endMs }]) + }) + + it("keeps lanes aligned with the draft when a middle layer cannot preview", () => { + const form = formOf([ + layer({ participants: people(["ana"]), startsAt: "2026-11-02T09:00" }), + layer({ key: "l1", participants: [] }), + layer({ key: "l2", participants: people(["ben"]), rotation: "custom", lengthHours: "nope" }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Arr.map(coverage.layers, Option.isSome)).toEqual([true, false, false]) + }) + + it("counts gaps only from now, and a future start leaves a gap before it", () => { + const form = formOf([layer({ participants: people(["ana"]), startsAt: "2026-11-12T09:00" })]) + const nowMs = at("2026-11-10T12:00:00Z") + const coverage = draftCoverage(form, [], range.startMs, range.endMs, nowMs) + expect(coverage.gaps).toEqual([{ startMs: nowMs, endMs: at("2026-11-12T08:00:00Z") }]) + }) +}) + +describe("previewRange", () => { + it("spans whole local days, so a DST week is an hour longer", () => { + const week = previewRange(at("2026-10-21T10:00:00Z"), "Europe/Berlin", 0, 7) + expect(week.startMs).toBe(at("2026-10-20T22:00:00Z")) + expect(week.endMs - week.startMs).toBe(7 * 24 * HOUR + HOUR) + }) +}) + +describe("gapSummary", () => { + it("says covered when there are no gaps", () => { + expect(gapSummary([], "Europe/Berlin", 0, 14)).toBe("Covered for the next 14 days") + }) + it("counts gaps and names the longest in the schedule's zone", () => { + const gaps = [ + { startMs: at("2026-11-19T23:00:00Z"), endMs: at("2026-11-20T01:00:00Z") }, + { startMs: at("2026-11-20T23:00:00Z"), endMs: at("2026-11-21T15:00:00Z") }, + ] + expect(gapSummary(gaps, "Europe/Berlin", 0, 14)).toBe( + "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", + ) + }) + it("formats durations", () => { + expect(durationText(90 * 60_000)).toBe("1h 30m") + expect(durationText(76 * HOUR)).toBe("3d 4h") + }) +}) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts new file mode 100644 index 0000000000..554ae22547 --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -0,0 +1,227 @@ +import { renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallLayer, OnCallLocalTime, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +import type { OnCallOverride } from "@maple/domain/http" +import { zonedDateParts } from "@maple/query-engine/datetime" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Order, Result, Schema, String as Str } from "effect" +import { localInputMs, type Form, type LayerDraft } from "../schedule-form/form" + +// Coverage preview: the draft rendered with the server's own `renderOnCall`, so the preview +// and paging agree, plus the gaps where nobody is on call. + +export interface Interval { + readonly startMs: number + readonly endMs: number +} + +const byStart = Order.mapInput(Order.Number, (interval: Interval) => interval.startMs) + +/** The stretches of [fromMs, toMs) that no shift covers. */ +export const gapsOf = ( + shifts: ReadonlyArray, + fromMs: number, + toMs: number, +): ReadonlyArray => { + if (toMs <= fromMs) return [] + const sorted = Arr.sort(shifts, byStart) + const { gaps, cursor } = Arr.reduce( + sorted, + { gaps: Arr.empty(), cursor: fromMs }, + ({ gaps, cursor }, shift) => { + if (shift.endMs <= cursor || shift.startMs >= toMs) return { gaps, cursor } + const next = shift.startMs > cursor ? [...gaps, { startMs: cursor, endMs: shift.startMs }] : gaps + return { gaps: next, cursor: Math.max(cursor, shift.endMs) } + }, + ) + return cursor < toMs ? [...gaps, { startMs: cursor, endMs: toMs }] : gaps +} + +const isLocalTime = Schema.is(OnCallLocalTime) +const isZone = Schema.is(OnCallTimeZone) +const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) +const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) + +const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => + layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) + +/** A draft layer as the engine's layer, while it has people and a valid rotation; bad restrictions drop out. */ +export const previewLayerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => + decodeLayer({ + // The name never changes who is on call, so a blank one still previews. + name: Str.isEmpty(Str.trim(layer.name)) ? "Layer" : Str.slice(0, 100)(Str.trim(layer.name)), + participants: layer.participants, + rotation: + layer.rotation === "custom" + ? { kind: "custom", lengthHours: Number(Str.trim(layer.lengthHours)) } + : layer.rotation === "daily" + ? { kind: "daily", handoffTime: layer.handoffTime } + : { + kind: "weekly", + handoffWeekday: layer.handoffWeekday, + handoffTime: layer.handoffTime, + }, + startsAt, + restrictions: Arr.map( + Arr.filter( + layer.restrictions, + (r) => r.weekdays.length > 0 && isLocalTime(r.startTime) && isLocalTime(r.endTime), + ), + ({ weekdays, startTime, endTime }) => ({ weekdays, startTime, endTime }), + ), + }), + ) + +export interface DraftConfig { + readonly config: OnCallScheduleConfig + /** The draft layer each config layer came from. */ + readonly draftIndexes: ReadonlyArray +} + +/** The draft as an engine config, keeping only the layers that can preview; none while no layer can. */ +export const draftConfigOf = (form: Pick): Option.Option => { + if (!isZone(form.timeZone)) return Option.none() + const usable = Arr.filterMap(form.layers, (layer, index) => + Option.match(previewLayerOf(layer, form.timeZone), { + onNone: () => Result.failVoid, + onSome: (engineLayer) => Result.succeed({ engineLayer, index }), + }), + ) + return Option.map( + decodeConfig({ timeZone: form.timeZone, layers: Arr.map(usable, (u) => u.engineLayer) }), + (config) => ({ config, draftIndexes: Arr.map(usable, (u) => u.index) }), + ) +} + +export interface Coverage { + /** Per draft layer: its shifts, or none while it cannot preview (no people, bad times). */ + readonly layers: ReadonlyArray>> + readonly final: ReadonlyArray + /** Gaps in the final schedule from `max(fromMs, nowMs)`: the past is not scheduled. */ + readonly gaps: ReadonlyArray +} + +/** The draft's layers, final schedule and gaps over [fromMs, toMs). */ +export const draftCoverage = ( + form: Pick, + overrides: ReadonlyArray, + fromMs: number, + toMs: number, + nowMs: number, +): Coverage => { + const gapFrom = Math.max(fromMs, nowMs) + return Option.match(draftConfigOf(form), { + onNone: () => ({ + layers: Arr.map(form.layers, () => Option.none()), + final: [], + gaps: gapsOf([], gapFrom, toMs), + }), + onSome: ({ config, draftIndexes }) => { + const render = renderOnCall(config, overrides, fromMs, toMs) + return { + layers: Arr.map(form.layers, (_, draftIndex) => + Option.flatMap( + Arr.findFirstIndex(draftIndexes, (i) => i === draftIndex), + (configIndex) => Option.fromNullishOr(render.layers[configIndex]), + ), + ), + final: render.final, + gaps: gapsOf(render.final, gapFrom, toMs), + } + }, + }) +} + +// RANGE: whole wall-clock days in the schedule's zone, so DST days are 23 or 25 hours. + +export const RANGE_DAYS = [7, 14, 28] as const +export type RangeDays = (typeof RANGE_DAYS)[number] + +/** `days` wall-clock days from the start of today, `weekOffset` weeks on. */ +export const previewRange = ( + nowMs: number, + timeZone: string, + weekOffset: number, + days: number, +): Interval => ({ + startMs: zonedDayStart(nowMs, timeZone, weekOffset * 7), + endMs: zonedDayStart(nowMs, timeZone, weekOffset * 7 + days), +}) + +// COPY + +const formatters = new Map() +const formatterOf = (timeZone: string, kind: "day" | "time"): Intl.DateTimeFormat => { + const key = `${timeZone}|${kind}` + const cached = formatters.get(key) + if (cached !== undefined) return cached + const formatter = new Intl.DateTimeFormat( + "en-GB", + kind === "day" + ? { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + } + : { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }, + ) + formatters.set(key, formatter) + return formatter +} + +const sameDay = (a: number, b: number, timeZone: string) => { + const pa = zonedDateParts(a, timeZone) + const pb = zonedDateParts(b, timeZone) + return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day +} + +const dropComma = Str.replace(",", "") + +/** "Sat 21 Nov 00:00 to 16:00", or both ends in full across days. */ +export const gapRangeText = (gap: Interval, timeZone: string): string => { + const start = dropComma(formatterOf(timeZone, "day").format(gap.startMs)) + const end = sameDay(gap.startMs, gap.endMs, timeZone) + ? formatterOf(timeZone, "time").format(gap.endMs) + : dropComma(formatterOf(timeZone, "day").format(gap.endMs)) + return `${start} to ${end}` +} + +/** "16h", "1h 30m", "3d 4h": a gap's length, rounded to minutes. */ +export const durationText = (ms: number): string => { + const minutes = Math.round(ms / 60_000) + const days = Math.floor(minutes / 1440) + const hours = Math.floor((minutes % 1440) / 60) + const mins = minutes % 60 + const parts = [ + ...(days > 0 ? [`${days}d`] : []), + ...(hours > 0 ? [`${hours}h`] : []), + ...(mins > 0 && days === 0 ? [`${mins}m`] : []), + ] + return parts.length === 0 ? "0m" : Arr.join(parts, " ") +} + +/** The longest gap; the earliest among equals. */ +export const longestGap = (gaps: ReadonlyArray): Option.Option => + Arr.reduce(gaps, Option.none(), (best, gap) => + Option.match(best, { + onNone: () => Option.some(gap), + onSome: (b) => (gap.endMs - gap.startMs > b.endMs - b.startMs ? Option.some(gap) : best), + }), + ) + +/** "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", or "Covered for the next 14 days". */ +export const gapSummary = ( + gaps: ReadonlyArray, + timeZone: string, + weekOffset: number, + days: number, +): string => + Option.match(longestGap(gaps), { + onNone: () => (weekOffset === 0 ? `Covered for the next ${days} days` : "Covered in this period"), + onSome: (longest) => + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${weekOffset === 0 ? `in the next ${days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)} (${gapRangeText(longest, timeZone)})`, + }) diff --git a/bun.lock b/bun.lock index b093e6bd9c..a879a71fbe 100644 --- a/bun.lock +++ b/bun.lock @@ -323,6 +323,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/packages/alerting-core/package.json b/packages/alerting-core/package.json index 2069779090..a14c20056e 100644 --- a/packages/alerting-core/package.json +++ b/packages/alerting-core/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./hysteresis": "./src/hysteresis.ts" + "./hysteresis": "./src/hysteresis.ts", + "./oncall": "./src/oncall.ts" }, "scripts": { "test": "vitest run", diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 6fe6520d72..928eed657e 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -12,8 +12,8 @@ import { typography } from "./typography" // 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" +/** A bar's look: a layer's shift, the effective shift, an override over it, or a gap nobody covers. */ +export type TimelineBarTone = "neutral" | "primary" | "info" | "warning" export type TimelineBar = Readonly<{ key: string @@ -24,6 +24,8 @@ export type TimelineBar = Readonly<{ title: string tone: TimelineBarTone onClick?: M + /** The bar's element id, so a summary elsewhere can scroll to and focus it. */ + id?: string }> export type TimelineRow = Readonly<{ @@ -43,6 +45,7 @@ export type TimelineDay = Readonly<{ dayOfMonth: number leftPct: number widthPct: number + isWeekend: boolean }> const weekdayFormatters = new Map() @@ -53,6 +56,8 @@ const weekdayOf = (ms: number, timeZone: string): string => { return formatter.format(ms) } +const WEEKEND = new Set(["Sat", "Sun"]) + /** 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) @@ -90,6 +95,7 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re startMs, endMs, weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), + isWeekend: WEEKEND.has(weekdayOf(Math.max(startMs, fromMs), timeZone)), dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, ...place, }, @@ -104,6 +110,8 @@ const BAR_TONE = { // 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", + // A gap reads as missing cover: a dashed warning edge over a faint wash. + warning: "border-dashed bg-warning-a2 border-warning text-warning-foreground", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ @@ -151,7 +159,12 @@ const barView = ( 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)] + const attributes = [ + h.Key(bar.key), + style, + ...(bar.id === undefined ? [] : [h.Id(bar.id)]), + ...tooltipTrigger(h, bar.title), + ] return [ bar.onClick === undefined ? h.div( @@ -200,6 +213,8 @@ export const shiftTimeline = ( ariaLabel: string /** Read out in place of the now line, which is drawn only ("Now, Tue 10 Nov, 11:30"). */ nowLabel?: string + /** Names the axis's zone in the label column's header ("Europe/Berlin"). */ + zoneLabel?: string /** A key under the timeline for the bar tones it uses. */ legend?: ReadonlyArray> }>, @@ -213,8 +228,14 @@ export const shiftTimeline = ( h.div( [ h.Key(`rule-${day.startMs}`), - h.Class("absolute inset-y-0 border-l border-border-subtle"), - h.Style({ left: `${day.leftPct}%` }), + ...(day.isWeekend ? [h.DataAttribute("weekend", "true")] : []), + h.Class( + cn( + "absolute inset-y-0 border-l border-border-subtle", + day.isWeekend && "bg-surface-sunken", + ), + ), + h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [], ), @@ -238,7 +259,21 @@ export const shiftTimeline = ( 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"])]), + h.div( + [h.Class(cn(LABEL_COLUMN, "flex items-center")), h.Role("columnheader")], + [ + h.span([h.Class("sr-only")], ["Lane"]), + ...(options.zoneLabel === undefined + ? [] + : [ + typography( + h, + { size: "2xs", tone: "muted", truncate: true, className: "block" }, + [options.zoneLabel], + ), + ]), + ], + ), // 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")], From 5fd8fbc8b66080901565e8a6c2573ef9fe49ec28 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:46:38 +0100 Subject: [PATCH 07/35] feat(alerts): oncall lab states for a covered and a gappy schedule draft --- apps/web/src/lab/oncall-lab.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 5f7dd1883f..d4cd0ac5e9 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -54,6 +54,8 @@ export const LabState = Schema.Literals([ "schedules", "strip", "editor-new", + "editor-covered", + "editor-gaps", "editor-edit", "editor-invalid", "editor-error", @@ -186,6 +188,12 @@ const hubFor = (state: LabState): Step => { const formStep = (model: FormModel.Model, message: FormMessage.Message) => FormUpdate.update(model, message, CONTEXT) +const addPerson = (index: number) => + FormMessage.Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index, item: { kind: "Item", value: memberId(index) } }, + }) + const formFor = (state: LabState): Step => { const isEdit = state === "editor-edit" || state === "editor-error" const start = FormUpdate.init( @@ -252,7 +260,17 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : [] + : state === "editor-covered" || state === "editor-gaps" + ? [ + FormMessage.Message.ChangedName({ value: "Checkout primary" }), + addPerson(0), + addPerson(1), + // Weeknights only: the days and weekends show as gaps. + ...(state === "editor-gaps" + ? [FormMessage.Message.ClickedAddRestriction({ key: "l0" })] + : []), + ] + : [] return fold(start, [...base, ...extra], formStep) } From 56b163134a9983451df32fd6304534e1d7ff3024 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:52:32 +0100 Subject: [PATCH 08/35] fix(alerts): preview beside the form from 2xl, cap the gap links at six --- .../web/src/page/alerts/schedule-form/view.ts | 6 +++--- .../page/alerts/shared/coverage-preview.ts | 20 ++++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 8dfa133e27..e9cdb6e022 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -559,9 +559,9 @@ export const view = Submodel.defineView((model, return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) return dashboardPage(h, { ...header, fill: true }, [ formScrollArea(h, [ - // The form on the left and its live preview on the right; stacked under xl. + // The form on the left and its live preview on the right; under 2xl the preview follows the form. h.div( - [h.Class("grid gap-6 xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] xl:items-start")], + [h.Class("grid gap-6 2xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] 2xl:items-start")], [ formCard( h, @@ -569,7 +569,7 @@ export const view = Submodel.defineView((model, FORM_WIDTH, ), h.div( - [h.Class("min-w-0 rounded-md border bg-card p-4 xl:sticky xl:top-0")], + [h.Class("min-w-0 rounded-md border bg-card p-4 2xl:sticky 2xl:top-0")], [schedulePreview(h, model)], ), ], diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 78950de51d..6b10165e54 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -24,6 +24,9 @@ const RANGE_OPTIONS: ReadonlyArray { value: "28", label: "4 weeks" }, ] +/** The summary links this many gaps; the rest are on the timeline. */ +const MAX_GAP_LINKS = 6 + /** The element id of a gap's bar, so its summary link can scroll to and focus it. */ export const gapBarId = (scope: string, index: number): string => `${scope}-gap-${index}` @@ -145,10 +148,8 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions ...(isCovered ? [] : [ - hstack( - h, - { gap: "1", wrap: true }, - Arr.map(options.gaps, (gap, index) => + hstack(h, { gap: "1", wrap: true }, [ + ...Arr.map(Arr.take(options.gaps, MAX_GAP_LINKS), (gap, index) => button( h, { @@ -162,7 +163,16 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions [gapRangeText(gap, options.timeZone)], ), ), - ), + ...(options.gaps.length > MAX_GAP_LINKS + ? [ + typography( + h, + { size: "xs", tone: "muted", attributes: [h.Key("more")] }, + [`and ${options.gaps.length - MAX_GAP_LINKS} more on the timeline`], + ), + ] + : []), + ]), ]), ]) } From ef301707e6e647cadfdd939c6c15e5e91e4c2a1f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:55:35 +0100 Subject: [PATCH 09/35] test(shell): lab on-call fixture no longer expires on 16 Nov 2026 --- apps/web/src/lab/shell-states-lab.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 6d017f71c7..9e78acc414 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -37,8 +37,8 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ -const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) +/** Mon 16 Nov 2099, 08:00 UTC (09:00 CET): far enough out that the fixture shift never ends. */ +const HANDOFF_MS = Date.UTC(2099, 10, 16, 8) const LAB_USER = "user_lab" From 7b0906572d4067f378cba125d31de80516b3b082 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:01:42 +0100 Subject: [PATCH 10/35] feat(ui): shift timeline gap tone, labels that leave when they cannot fit, narrow day headers A gap is a hollow, hatched bar in the error tone instead of a warning wash. A bar hides its label under 3.75rem and shows initials when two characters fit. Layer rows can leave the tab order. Day headers stack and thin out their dates as days narrow, and the label column sizes to the timeline's own width. --- packages/ui/src/styles/tokens.css | 9 ++ .../views/src/ui/shift-timeline.scene.test.ts | 26 +++++ packages/views/src/ui/shift-timeline.test.ts | 11 ++ packages/views/src/ui/shift-timeline.ts | 107 ++++++++++++++---- 4 files changed, 134 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 580c9a01f6..6666780c0c 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -825,3 +825,12 @@ @utility loading-delay { animation: loading-delay 0s 300ms both; } + +/* A hatch for something missing (an on-call gap): the error tone's lines over no fill. */ +@utility hatch-error { + background-image: repeating-linear-gradient( + 135deg, + color-mix(in oklab, var(--severity-error) 32%, transparent) 0 1px, + transparent 1px 6px + ); +} diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 0181893586..23536c2692 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -33,11 +33,21 @@ const view = (model: Model, h: HtmlBuilder): Html => tone: "primary", onClick: Message.ClickedBar(), }, + { + key: "gap", + id: "gap-0", + startMs: FROM + HOUR, + endMs: FROM + 3 * HOUR, + label: "No one", + title: "No one on call, Mon 9 Nov 01:00 to 03:00", + tone: "gap", + }, ], }, { key: "layer", label: "Primary", + isTabStop: false, bars: [ { key: "b", @@ -98,4 +108,20 @@ describe("shift timeline", () => { expectView(role("img", { name: "Cy, next week" })).not.toExist(), ) }) + + test("a gap is a named, focusable bar a summary can reach by id; a layer's bars leave the tab order", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "id", + "gap-0", + ), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "tabIndex", + "0", + ), + expectView(role("img", { name: "Ben, Mon" })).toHaveAttr("tabIndex", "-1"), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index a34045cf4f..dbc5c86208 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -55,4 +55,15 @@ describe("shift timeline layout", () => { "2026-11-11T05:00:00.000Z", ) }) + + test("Saturdays and Sundays are marked as the weekend, on the zone's own days", () => { + const from = Date.parse("2026-11-12T23:00:00.000Z") // Fri 13 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 4 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => [day.weekday, day.isWeekend])).toEqual([ + ["Fri", false], + ["Sat", true], + ["Sun", true], + ["Mon", false], + ]) + }) }) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 928eed657e..58656c254c 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -1,6 +1,6 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" import { cn } from "@maple/ui/lib/utils" -import { Array as Arr, Option, Result } from "effect" +import { Array as Arr, Option, Result, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { pressable } from "./pressable" import { hstack, vstack } from "./stack" @@ -13,13 +13,15 @@ import { typography } from "./typography" // 23 or 25 hours wide. /** A bar's look: a layer's shift, the effective shift, an override over it, or a gap nobody covers. */ -export type TimelineBarTone = "neutral" | "primary" | "info" | "warning" +export type TimelineBarTone = "neutral" | "primary" | "info" | "gap" export type TimelineBar = Readonly<{ key: string startMs: number endMs: number label: string + /** Drawn when the label cannot fit but two characters can (a person's initials). */ + shortLabel?: string /** The tooltip and accessible name: who, and from when to when. */ title: string tone: TimelineBarTone @@ -33,6 +35,8 @@ export type TimelineRow = Readonly<{ label: string detail?: string bars: ReadonlyArray> + /** False keeps the row's read-only bars out of the tab order (a layer under the final row). */ + isTabStop?: boolean /** What an empty row says, for a row with no bars in the range. */ emptyText?: string }> @@ -110,8 +114,8 @@ const BAR_TONE = { // 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", - // A gap reads as missing cover: a dashed warning edge over a faint wash. - warning: "border-dashed bg-warning-a2 border-warning text-warning-foreground", + // A gap reads as missing cover: no fill, a dashed error edge and the error tone's hatch. + gap: "hatch-error border-dashed border-severity-error text-severity-error", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ @@ -119,7 +123,8 @@ const MIN_BAR_PX = 8 const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" -const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" +// A container, so the label can leave when the bar is too narrow to hold it. +const BAR_CLASS = "@container absolute inset-y-1 overflow-hidden rounded-sm border 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 = ( @@ -145,6 +150,7 @@ const barView = ( toMs: number, bar: TimelineBar, limitPct: number, + isTabStop: boolean, ): ReadonlyArray => Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { onNone: () => [], @@ -156,9 +162,29 @@ const barView = ( 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, - ]) + // The label shows from 3.75rem of bar (3rem of text), the short one from two characters' + // room; the name stays in the tooltip. + const text = [ + typography( + h, + { size: "xs", truncate: true, className: "hidden px-1.5 leading-6 @min-[3.75rem]:block" }, + [bar.label], + ), + ...(bar.shortLabel === undefined + ? [] + : [ + typography( + h, + { + size: "xs", + className: + "hidden text-center leading-6 @min-[1.25rem]:block @min-[3.75rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [bar.shortLabel], + ), + ]), + ] const attributes = [ h.Key(bar.key), style, @@ -173,10 +199,10 @@ const barView = ( 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.Tabindex(isTabStop ? 0 : -1), h.AriaLabel(bar.title), ], - [text], + text, ) : pressable( h, @@ -190,13 +216,14 @@ const barView = ( ), attributes, }, - [text], + text, ), ] }, }) -const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 sm:w-40" +// Sized by the timeline's own width, so it stays narrow in a side panel on a wide screen. +const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 @2xl:w-40" /** * The timeline: a header of days, then one row per lane (label column, bars track), ruled by day, @@ -277,16 +304,17 @@ export const shiftTimeline = ( // 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) => + Arr.map(days, (day, index) => 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", + "@container absolute inset-y-0 overflow-hidden border-l border-border-subtle", ), h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [ + // "Tue 10" where a day is wide enough, else the weekday's letter over the date. typography( h, { @@ -294,10 +322,39 @@ export const shiftTimeline = ( tone: "muted", numeric: true, truncate: true, - className: "block", + className: "hidden px-1.5 py-1.5 @min-[3.5rem]:block", }, [`${day.weekday} ${day.dayOfMonth}`], ), + vstack( + h, + { + gap: "0", + align: "center", + justify: "center", + className: "h-full @min-[3.5rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [ + typography(h, { size: "2xs", tone: "faint", className: "leading-3" }, [ + Str.slice(0, 1)(day.weekday), + ]), + typography( + h, + { + size: "2xs", + tone: "muted", + numeric: true, + // Days too narrow for two digits date only the first day and each Monday. + className: + index === 0 || day.weekday === "Mon" + ? "leading-3" + : "invisible leading-3 @min-[1.25rem]:visible", + }, + [String(day.dayOfMonth)], + ), + ], + ), ], ), ), @@ -338,7 +395,14 @@ export const shiftTimeline = ( ), ] : Arr.flatMap(row.bars, (bar) => - barView(h, fromMs, toMs, bar, barRoomPct(fromMs, toMs, row.bars, bar)), + barView( + h, + fromMs, + toMs, + bar, + barRoomPct(fromMs, toMs, row.bars, bar), + row.isTabStop ?? true, + ), ), ), ], @@ -350,10 +414,15 @@ export const shiftTimeline = ( ]), ) const frame = h.div( - [h.Class("overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], + [h.Class("@container 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)], + [ + // Fits a frame from 30rem; a narrower one (a phone) scrolls a 45rem track sideways. + h.Class("relative min-w-[45rem] @min-[30rem]:min-w-0"), + h.Role("table"), + h.AriaLabel(options.ariaLabel), + ], [ ...(options.nowLabel === undefined || Option.isNone(now) ? [] @@ -365,7 +434,7 @@ export const shiftTimeline = ( h.div( [ h.Class( - "pointer-events-none absolute inset-y-0 right-0 left-32 sm:left-40", + "pointer-events-none absolute inset-y-0 right-0 left-32 @2xl:left-40", ), ], [rules, ...nowLine], From b551d1454500bcfa0dcddba32e2bdc4be9a069a6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:01:47 +0100 Subject: [PATCH 11/35] fix(alerts): coverage preview review follow-ups - The detail calendar draws a skeleton until the shifts for the picked range land, instead of old shifts read as one long gap. - A wholly past range says its gaps are not checked; a draft cannot step back from today. - A layer with a half-typed window does not preview as always active. - The preview redraws only when the zone, layers, members, minute or range change. - An uncovered schedule says nobody is on call; a gap cut off by the range's end has no end time. - The start note shows only for a new schedule started on an earlier day and names who is on call now. - The editor is two columns from 46rem of room, the preview held in view while the form scrolls. - One shared focus command for gap links. --- apps/web/src/lab/oncall-lab.ts | 17 +- .../src/page/alerts/schedule-form/command.ts | 10 +- .../web/src/page/alerts/schedule-form/form.ts | 4 +- .../schedule-form/preview.scene.test.ts | 71 +++++ .../src/page/alerts/schedule-form/preview.ts | 123 +++++--- .../schedule-form/schedule-form.scene.test.ts | 70 +++- .../src/page/alerts/schedule-form/update.ts | 12 +- .../web/src/page/alerts/schedule-form/view.ts | 48 ++- apps/web/src/page/alerts/schedule/command.ts | 10 +- .../alerts/schedule/schedule.scene.test.ts | 38 ++- apps/web/src/page/alerts/schedule/update.ts | 9 +- apps/web/src/page/alerts/schedule/view.ts | 19 +- .../page/alerts/shared/coverage-preview.ts | 298 +++++++++++------- .../alerts/shared/schedule-coverage.test.ts | 59 +++- .../page/alerts/shared/schedule-coverage.ts | 126 +++++--- apps/web/src/page/kit/form-section.ts | 32 +- packages/views/src/ui/INDEX.md | 2 +- 17 files changed, 685 insertions(+), 263 deletions(-) create mode 100644 apps/web/src/page/alerts/schedule-form/preview.scene.test.ts diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index d4cd0ac5e9..4cda5eb6a2 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -56,6 +56,7 @@ export const LabState = Schema.Literals([ "editor-new", "editor-covered", "editor-gaps", + "editor-early", "editor-edit", "editor-invalid", "editor-error", @@ -260,7 +261,7 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : state === "editor-covered" || state === "editor-gaps" + : state === "editor-covered" || state === "editor-gaps" || state === "editor-early" ? [ FormMessage.Message.ChangedName({ value: "Checkout primary" }), addPerson(0), @@ -269,6 +270,20 @@ const formFor = (state: LabState): Step => ...(state === "editor-gaps" ? [FormMessage.Message.ClickedAddRestriction({ key: "l0" })] : []), + // A start two days back: the rotation is already under way. + ...(state === "editor-early" + ? [ + FormMessage.Message.PickedRotation({ + key: "l0", + rotation: "daily", + }), + FormMessage.Message.ChangedLayerText({ + key: "l0", + field: "startsAt", + value: "2026-11-08T09:00", + }), + ] + : []), ] : [] return fold(start, [...base, ...extra], formStep) diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index 5637ab0cc6..ffa4c6ff33 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -1,7 +1,7 @@ import { OnCallScheduleId } from "@maple/domain/http" import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command, Dom } from "foldkit" +import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" @@ -42,14 +42,6 @@ export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), }) -/** A gap's bar takes focus, which scrolls it into view. */ -export const FocusPreviewGap = Command.define("FocusScheduleFormGap", { - args: { selector: Schema.String }, - messages: [Message.CompletedFocusGap], - execute: ({ selector }) => - Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), -}) - export const SaveSchedule = Command.define("SaveOnCallSchedule", { args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, messages: [Message.CompletedSave], diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index dbb603b9ee..2fad2e32ec 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -199,7 +199,7 @@ export type LayerErrors = Readonly<{ restrictions: ReadonlyArray> }> -const restrictionError = (restriction: RestrictionDraft): Option.Option => +export const restrictionError = (restriction: RestrictionDraft): Option.Option => restriction.weekdays.length === 0 ? Option.some("Pick at least one day") : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) @@ -207,7 +207,7 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option : 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 => +export 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 => { diff --git a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts new file mode 100644 index 0000000000..df5ffd7c15 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -0,0 +1,71 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Option, Result, Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + find, + given, + role, + scene, + tap, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { memberId, ONCALL_NOW, oncallMembers } from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// The preview renders the whole schedule, so it must not redo that work on edits it does not read. + +const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const staffed = (): Model => + Arr.reduce( + [ + Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: ["UTC", "Europe/Berlin"], + }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value: memberId(0) } }, + }), + ], + init({ _tag: "AlertsScheduleNew", search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + +test("typing the name keeps the drawn preview; a new range draws it again", () => { + // The preview's rendered node: the same object while nothing it reads has changed. + const drawn: Array = [] + const keep = tap((simulation) => { + drawn.push(Option.getOrNull(find(simulation.html, '[data-slot="coverage-preview"]'))) + }) + scene( + { update: pageUpdate, view }, + given(staffed()), + expectView(text("Covered for the next 14 days")).toExist(), + keep, + type(role("textbox", { name: "Name" }), "Checkout primary"), + type(role("textbox", { name: "Description (optional)" }), "Who swaps with whom"), + keep, + click(role("button", { name: "4 weeks" })), + expectView(text("Covered for the next 28 days")).toExist(), + keep, + ) + expect(drawn[0]).not.toBeNull() + expect(drawn[1]).toBe(drawn[0]) + expect(drawn[2]).not.toBe(drawn[0]) +}) diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts index 9688614b37..3147b1949a 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -1,62 +1,92 @@ -import { Array as Arr, Option, Schema, String as Str } from "effect" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { OnCallShift } from "@maple/alerting-core/oncall" import { OnCallTimeZone } from "@maple/domain/http" -import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" -import { memberName } from "../shared/org-members" -import { draftCoverage, previewLayerOf, previewRange } from "../shared/schedule-coverage" -import { localInputMs } from "./form" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" +import { + coveragePreview, + type PreviewNote, + type PreviewShift, + type RangeDays, +} from "../shared/coverage-preview" +import { memberName, type OrgMember } from "../shared/org-members" +import { draftCoverage, layerOnCallAt, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { layerStartMs, type LayerDraft } from "./form" import { Message } from "./message" import type { Model } from "./model" import { PREVIEW_SCOPE } from "./update" -// The editor's live coverage preview: the draft rendered on every edit, no save needed. +// The editor's live coverage preview: the draft rendered as it is edited, no save needed. const isZone = Schema.is(OnCallTimeZone) const layerName = (name: string, index: number) => Str.trim(name) === "" ? `Layer ${index + 1}` : Str.trim(name) -/** Plain-language notes on the draft's layers, one per issue that changes the preview. */ -export const previewWarnings = (model: Pick): ReadonlyArray => - Arr.flatMap(model.form.layers, (layer, index) => { +/** What the preview reads off the Model; it redraws only when one of these changes. */ +export type PreviewInputs = Readonly<{ + timeZone: string + layers: ReadonlyArray + members: ReadonlyArray | null + nowMs: number + /** A new schedule: its starts are the user's own, so an early one is worth explaining. */ + isNew: boolean +}> + +/** Plain-language notes on the draft's layers, one per thing the preview cannot say by itself. */ +export const previewNotes = (inputs: PreviewInputs): ReadonlyArray => { + const todayMs = isZone(inputs.timeZone) ? zonedDayStart(inputs.nowMs, inputs.timeZone) : inputs.nowMs + return Arr.flatMap(inputs.layers, (layer, index): ReadonlyArray => { const name = layerName(layer.name, index) - if (layer.participants.length === 0) return [`${name} has no people, so it covers nothing.`] - const startMs = - layer.startsAtMs === null - ? localInputMs(layer.startsAt, model.form.timeZone) - : Option.some(layer.startsAtMs) - return Option.exists(startMs, (ms) => ms < model.nowMs) - ? [`${name} starts in the past. Shifts are scheduled from now, keeping its rotation order.`] - : [] + if (layer.participants.length === 0) + return [{ tone: "warn", text: `${name} has no people, so it covers nothing.` }] + // A saved schedule's start is always behind it, and a new layer starts today by default. + const startedEarlier = + inputs.isNew && Option.exists(layerStartMs(layer, inputs.timeZone), (ms) => ms < todayMs) + if (!startedEarlier) return [] + const under = `${name} started before today, so the rotation is already under way` + return [ + { + tone: "info", + text: Option.match(layerOnCallAt(layer, inputs.timeZone, inputs.nowMs), { + onNone: () => `${under}.`, + onSome: (userId) => `${under}: ${memberName(inputs.members, userId)} is on call now.`, + }), + }, + ] }) +} -const asPreview = (shift: { - userId: string - startMs: number - endMs: number - source: { kind: string } -}): PreviewShift => ({ +const asPreview = (shift: OnCallShift): PreviewShift => ({ userId: shift.userId, startMs: shift.startMs, endMs: shift.endMs, isOverride: shift.source.kind === "override", }) -export const schedulePreview = (h: HtmlBuilder, model: Model): Html => { - const timeZone = isZone(model.form.timeZone) ? model.form.timeZone : "UTC" - const days = Number(model.previewRange) - const range = previewRange(model.nowMs, timeZone, model.previewWeekOffset, days) - const coverage = draftCoverage(model.form, [], range.startMs, range.endMs, model.nowMs) +const render = ( + h: HtmlBuilder, + zone: string, + layers: ReadonlyArray, + members: ReadonlyArray | null, + nowMs: number, + weekOffset: number, + rangeDays: RangeDays, + isNew: boolean, +): Html => { + const timeZone = isZone(zone) ? zone : "UTC" + const range = previewRange(nowMs, timeZone, weekOffset, Number(rangeDays)) + const coverage = draftCoverage({ timeZone: zone, layers }, [], range.startMs, range.endMs, nowMs) return coveragePreview(h, { scope: PREVIEW_SCOPE, title: "Coverage preview", timeZone, fromMs: range.startMs, toMs: range.endMs, - nowMs: model.nowMs, - weekOffset: model.previewWeekOffset, - rangeDays: model.previewRange, - lanes: Arr.map(model.form.layers, (layer, index) => ({ + nowMs, + weekOffset, + rangeDays, + lanes: Arr.map(layers, (layer, index) => ({ key: layer.key, label: layerName(layer.name, index), detail: `Layer ${index + 1}`, @@ -65,18 +95,37 @@ export const schedulePreview = (h: HtmlBuilder, model: Model): Html => layer.participants.length === 0 ? "Add people to see shifts" : Option.isNone(previewLayerOf(layer, timeZone)) - ? "Fix the rotation to see shifts" + ? "Fix this layer to see shifts" : "Not active in this period", })), final: Arr.map(coverage.final, asPreview), gaps: coverage.gaps, - nameOf: (userId) => memberName(model.members, userId), + nameOf: (userId) => memberName(members, userId), onPrevious: Message.ClickedPreviewPrevious(), onNext: Message.ClickedPreviewNext(), onToday: Message.ClickedPreviewToday(), onRange: (days) => Message.PickedPreviewRange({ days }), onFocusGap: (index) => Message.ClickedPreviewGap({ index }), - warnings: previewWarnings(model), - note: `Times in ${timeZone}. Overrides are not shown here.`, + // A draft has no past to look back on. + isPreviousDisabled: weekOffset <= 0, + notes: previewNotes({ timeZone: zone, layers, members, nowMs, isNew }), + note: isNew ? undefined : "Overrides are not shown here.", }) } + +const slot = createLazy() + +const MINUTE_MS = 60_000 + +/** Redrawn only when the zone, the layers, the members, the minute or the range change, not on every key. */ +export const schedulePreview = (h: HtmlBuilder, model: Model): Html => + slot(render, [ + h, + model.form.timeZone, + model.form.layers, + model.members, + Math.floor(model.nowMs / MINUTE_MS) * MINUTE_MS, + model.previewWeekOffset, + model.previewRange, + model.editingId === null, + ]) 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 index 3d24459790..fed1c5e6fe 100644 --- 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 @@ -49,6 +49,8 @@ const ready = (search: { scheduleId?: string } = {}) => { ]) } +const memberNameOf = (index: number) => oncallMembers[index]?.name ?? "" + const addPerson = (key: string, index: number) => Message.GotParticipantMenuMessage({ key, @@ -313,8 +315,11 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(ready()), expectView(text("Primary has no people, so it covers nothing.")).toExist(), - expectView(text(/^1 gap in the next 14 days, longest 13d 12h/u)).toExist(), + expectView(text("Nobody is on call for the next 14 days")).toExist(), expectView(text("Add people to see shifts")).toExist(), + // One gap that is the whole range has no link of its own. + expectView(role("button", { name: /^From /u })).not.toExist(), + expectView(role("img", { name: "No one on call from Tue 10 Nov 11:30" })).toExist(), ) }) @@ -324,7 +329,8 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(staffed), expectView(text("Covered for the next 14 days")).toExist(), - expectView(text(/Primary starts in the past\. Shifts are scheduled from now/u)).toExist(), + // A new layer starts today at 09:00, which is not worth a note even after 09:00. + expectView(text(/started before today/u)).not.toExist(), expectView(role("rowheader", { name: /Final schedule/u })).toExist(), ) // Weeknights only (18:00 to 08:00): days and weekends are gaps. @@ -340,7 +346,65 @@ describe("schedule form", () => { expectView(role("button", { name: "Sat 14 Nov 08:00 to Mon 16 Nov 18:00" })).toExist(), ) const focus = pageUpdate(nights, Message.ClickedPreviewGap({ index: 4 })) - expect(names(focus.commands)).toEqual(["FocusScheduleFormGap"]) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) + + test("a window with no days stops the layer previewing instead of reading as always active", () => { + const nights = run(ready(), [addPerson("l0", 0), Message.ClickedAddRestriction({ key: "l0" })]) + const noDays = run( + nights, + Arr.map(["mon", "tue", "wed", "thu", "fri"] as const, (weekday) => + Message.ToggledRestrictionDay({ key: "l0", restrictionKey: "r1", weekday }), + ), + ) + scene( + { update: pageUpdate, view }, + given(noDays), + expectView(text("Fix this layer to see shifts")).toExist(), + expectView(text("Nobody is on call for the next 14 days")).toExist(), + expectView(text("Covered for the next 14 days")).not.toExist(), + ) + }) + + test("a new schedule started on an earlier day says the rotation is under way and who has it", () => { + // Two people, daily at 09:00 from Sun 8 Nov: by Tue 10 Nov 11:30 it is the first one's turn again. + const early = run(ready(), [ + addPerson("l0", 0), + addPerson("l0", 1), + Message.PickedRotation({ key: "l0", rotation: "daily" }), + Message.ChangedLayerText({ key: "l0", field: "startsAt", value: "2026-11-08T09:00" }), + ]) + scene( + { update: pageUpdate, view }, + given(early), + expectView( + text( + `Primary started before today, so the rotation is already under way: ${memberNameOf(0)} is on call now.`, + ), + ).toExist(), + ) + // A saved schedule always started in the past: no note for it. + scene( + { update: pageUpdate, view }, + given(ready({ scheduleId: schedulePublicId(1) })), + expectView(text(/started before today/u)).not.toExist(), + ) + }) + + test("a draft has no past: Previous is off from today", () => { + const model = ready() + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Previous week" })).toBeDisabled(), + ) + expect(pageUpdate(model, Message.ClickedPreviewPrevious()).model.previewWeekOffset).toBe(0) + const later = run(model, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(role("button", { name: "Previous week" })).not.toBeDisabled(), + ) }) test("the range switch and week steps change what the preview covers", () => { diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index b90a2260de..0a4af206fa 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -7,13 +7,12 @@ 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 { DEFAULT_RANGE_DAYS, gapBarId } from "../shared/coverage-preview" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, FetchScheduleFormSession, - FocusPreviewGap, ReadScheduleFormDefaults, SaveSchedule, } from "./command" @@ -308,15 +307,20 @@ export const update = (model: Model, message: Message, context: PageContext): Re editRestriction(model, key, restrictionKey, (restriction) => edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, ), + // A draft has no past: the preview never steps back from today. ClickedPreviewPrevious: () => ({ - model: { ...model, previewWeekOffset: model.previewWeekOffset - 1 }, + model: { ...model, previewWeekOffset: Math.max(0, model.previewWeekOffset - 1) }, }), ClickedPreviewNext: () => ({ model: { ...model, previewWeekOffset: model.previewWeekOffset + 1 } }), ClickedPreviewToday: () => ({ model: { ...model, previewWeekOffset: 0 } }), PickedPreviewRange: ({ days }) => ({ model: { ...model, previewRange: days } }), ClickedPreviewGap: ({ index }) => ({ model, - commands: [FocusPreviewGap({ selector: `#${gapBarId(PREVIEW_SCOPE, index)}` })], + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: PREVIEW_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], }), CompletedFocusGap: () => ({ model }), ClickedSave: () => save(model), diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index e9cdb6e022..05e63085ab 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -558,23 +558,37 @@ export const view = Submodel.defineView((model, 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, [ - // The form on the left and its live preview on the right; under 2xl the preview follows the form. - h.div( - [h.Class("grid gap-6 2xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] 2xl:items-start")], - [ - formCard( - h, - [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], - FORM_WIDTH, - ), - h.div( - [h.Class("min-w-0 rounded-md border bg-card p-4 2xl:sticky 2xl:top-0")], - [schedulePreview(h, model)], - ), - ], - ), - ]), + formScrollArea( + h, + [ + // From 46rem of room (a 1024px screen beside the sidebar) the form keeps a narrow column and + // the preview takes the rest, held in view while the form scrolls. Under that it follows the form. + h.div( + [ + h.Class( + "grid gap-4 @min-[46rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[46rem]/scroll:items-start", + ), + ], + [ + formCard( + h, + [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], + FORM_WIDTH, + ), + h.div( + [ + h.Class( + "min-w-0 rounded-md border bg-card p-4 @min-[46rem]/scroll:sticky @min-[46rem]/scroll:top-0 @min-[46rem]/scroll:max-h-[100cqh] @min-[46rem]/scroll:overflow-y-auto", + ), + ], + [schedulePreview(h, model)], + ), + ], + ), + ], + // A size container: the columns read its width and the preview's height limit its height. + "@container-[size]/scroll", + ), actionBar(h, model), ]) }) diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts index 13f9457ab5..9cceccf980 100644 --- a/apps/web/src/page/alerts/schedule/command.ts +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -1,7 +1,7 @@ import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" import { V2OnCallOverrideCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command, Dom } from "foldkit" +import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { createOverride, @@ -83,11 +83,3 @@ export const DeleteOverride = Command.define("DeleteOnCallOverride", { Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), ), }) - -/** A gap's bar takes focus, which scrolls it into view. */ -export const FocusScheduleGap = Command.define("FocusScheduleGap", { - args: { selector: Schema.String }, - messages: [Message.CompletedFocusGap], - execute: ({ selector }) => - Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), -}) diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 38fad37c6b..9507a948a6 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -92,9 +92,7 @@ describe("on-call schedule page", () => { 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.", - ), + text("Your time zone 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(), @@ -394,7 +392,7 @@ describe("on-call schedule page", () => { 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(), + expectView(text("Your time zone is UTC.")).toExist(), ) }) @@ -476,4 +474,36 @@ describe("on-call schedule page", () => { expectView(text("10 Nov to 7 Dec")).toExist(), ) }) + + test("a wider range draws a skeleton, not the old shifts as one long gap, until its shifts land", () => { + const model = loaded(ONCALL_NOW) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + scene( + { update: pageUpdate, view }, + given(month.model), + expectView(text("10 Nov to 7 Dec")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gap/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a range that has wholly passed says its gaps are not checked, not that it was covered", () => { + const before = pageUpdate( + pageUpdate(loaded(ONCALL_NOW), Message.ClickedPreviousWeek()).model, + Message.ClickedPreviousWeek(), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(before.model, ONCALL_NOW)), + expectView(text("27 Oct to 9 Nov")).toExist(), + expectView(text("Past shifts. Gaps are only checked from now.")).toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a gap in the summary focuses its bar on the calendar", () => { + const focus = pageUpdate(loaded(ONCALL_NOW), Message.ClickedGap({ index: 0 })) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 713a3bfc58..51d897df50 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -8,7 +8,7 @@ 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 { DEFAULT_RANGE_DAYS, gapBarId } from "../shared/coverage-preview" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -19,7 +19,6 @@ import { FetchScheduleMembers, FetchScheduleSession, FetchShifts, - FocusScheduleGap, ReadScheduleClock, } from "./command" import { Message } from "./message" @@ -353,7 +352,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re PickedRange: ({ days }) => requestShifts({ ...model, rangeDays: days }, context, "Load"), ClickedGap: ({ index }) => ({ model, - commands: [FocusScheduleGap({ selector: `#${gapBarId(CALENDAR_SCOPE, index)}` })], + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: CALENDAR_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], }), CompletedFocusGap: () => ({ model }), // An ended shift has nothing left to cover; a running one is covered from now to its end. diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index d8467c71bf..b2bd2e7796 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -71,11 +71,12 @@ const previewShiftOf = (shift: V2OnCallShift): PreviewShift => ({ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { const range = rangeOf(model) const nowMs = model.nowMs ?? 0 - const zoneNote = - viewerZone === schedule.time_zone - ? `Times in ${schedule.time_zone}.` - : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` - const note = `${zoneNote}${canManage(model) ? " Pick a shift in the final row to cover it with an override." : ""}` + // The axis names the schedule's zone; the note only says when the viewer's differs. + const noteParts = [ + ...(viewerZone === schedule.time_zone ? [] : [`Your time zone is ${viewerZone}.`]), + ...(canManage(model) ? ["Pick a shift in the final row to cover it with an override."] : []), + ] + const note = noteParts.length === 0 ? undefined : Arr.join(noteParts, " ") const preview = ( fromMs: number, toMs: number, @@ -121,9 +122,13 @@ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZ } const loading = (fromMs: number, toMs: number) => preview(fromMs, toMs, Option.none(), skeleton(h, "h-40 w-full")) - // Shifts kept from another range (a read still in flight) draw as the skeleton, not blank rows. + // Shifts kept from another range (a read in flight, either end moved) draw as the skeleton: a + // shorter read on a longer axis would show its missing days as one long gap. const loaded = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => - Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) + Option.exists( + readRangeOf(model), + (read) => isoMs(shifts.from) === read.fromMs && isoMs(shifts.to) === read.toMs, + ) ? preview(fromMs, toMs, Option.some(shifts)) : loading(fromMs, toMs) const body = Option.match(range, { diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 6b10165e54..d873cd7292 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -1,15 +1,18 @@ -import { Array as Arr, Option, Schema } from "effect" +import { initialsFrom } from "@maple/ui/lib/initials" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" +import { Command, Dom } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" import { button } from "@maple/views/ui/button" +import { heading } from "@maple/views/ui/heading" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" import { hstack, vstack } from "@maple/views/ui/stack" -import { heading } from "@maple/views/ui/heading" import { typography } from "@maple/views/ui/typography" import { formatShiftEdge } from "./oncall-schedules" -import { gapRangeText, gapSummary, type Interval } from "./schedule-coverage" +import { gapRangeText, gapSummary, isUncovered, type Interval } from "./schedule-coverage" // The coverage timeline both the schedule editor (from the draft) and the detail page (from the // server's shifts) draw: a lane per layer, the final lane with its "No one" gaps, and the controls. @@ -24,12 +27,26 @@ const RANGE_OPTIONS: ReadonlyArray { value: "28", label: "4 weeks" }, ] -/** The summary links this many gaps; the rest are on the timeline. */ -const MAX_GAP_LINKS = 6 +/** The summary links this many gaps in a narrow preview and in a wide one; the rest are on the timeline. */ +const NARROW_GAP_LINKS = 3 +const WIDE_GAP_LINKS = 6 /** The element id of a gap's bar, so its summary link can scroll to and focus it. */ export const gapBarId = (scope: string, index: number): string => `${scope}-gap-${index}` +export const GapFocusMessage = defineMessageUnion({ CompletedFocusGap: {} }) + +/** A gap's bar takes focus, which scrolls it into view; a page lifts the result into its own message. */ +export const FocusGap = Command.define("FocusCoverageGap", { + args: { scope: Schema.String, index: Schema.Number }, + messages: [GapFocusMessage.CompletedFocusGap], + execute: ({ scope, index }) => + Dom.focus(`#${gapBarId(scope, index)}`).pipe( + Effect.ignore, + Effect.as(GapFocusMessage.CompletedFocusGap()), + ), +}) + export type PreviewShift = Readonly<{ userId: string; startMs: number; endMs: number; isOverride: boolean }> export type PreviewLane = Readonly<{ @@ -41,6 +58,9 @@ export type PreviewLane = Readonly<{ emptyText: string }> +/** A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. */ +export type PreviewNote = Readonly<{ tone: "info" | "warn"; text: string }> + export type CoveragePreviewOptions = Readonly<{ /** Prefixes the gap bars' ids, unique on the page. */ scope: string @@ -60,12 +80,13 @@ export type CoveragePreviewOptions = Readonly<{ onToday: M onRange: (days: RangeDays) => M onFocusGap: (index: number) => M + /** A draft has no past: its preview cannot step back from today. */ + isPreviousDisabled?: boolean /** A final shift's bar action (the detail page's "cover with an override"). */ onFinalShift?: (shift: PreviewShift) => Option.Option - /** Plain-language notes over the timeline. */ - warnings?: ReadonlyArray - /** A line under the header, after the zone note. */ - note?: string + notes?: ReadonlyArray + /** A line under the header. The axis names the zone, so this does not. */ + note?: string | undefined /** Drawn in place of the summary and timeline while their data loads; the header stays. */ placeholder?: Html | undefined }> @@ -82,6 +103,7 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra startMs: shift.startMs, endMs: shift.endMs, label: name, + shortLabel: initialsFrom(name), title: `${shift.isOverride ? "Override: " : ""}${name}, ${rangeText(shift.startMs, shift.endMs, options.timeZone)}`, tone: shift.isOverride ? "info" : isFinal ? "primary" : "neutral", } @@ -89,15 +111,19 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra isFinal && options.onFinalShift !== undefined ? options.onFinalShift(shift) : Option.none() return Option.match(click, { onNone: () => bar, onSome: (onClick) => ({ ...bar, onClick }) }) } - const gapBars = Arr.map(options.gaps, (gap, index): TimelineBar => ({ - key: `gap-${gap.startMs}`, - id: gapBarId(options.scope, index), - startMs: gap.startMs, - endMs: gap.endMs, - label: "No one", - title: `No one on call, ${gapRangeText(gap, options.timeZone)}`, - tone: "warning", - })) + const gapBars = Arr.map(options.gaps, (gap, index): TimelineBar => { + const range = gapRangeText(gap, options.timeZone, options.toMs) + return { + key: `gap-${gap.startMs}`, + id: gapBarId(options.scope, index), + startMs: gap.startMs, + endMs: gap.endMs, + label: "No one", + // An open gap reads "No one on call from Sat 21 Nov 08:00". + title: Str.startsWith("from ")(range) ? `No one on call ${range}` : `No one on call, ${range}`, + tone: "gap", + } + }) return [ { key: "final", @@ -114,6 +140,8 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra key: lane.key, label: lane.label, detail: lane.detail, + // The final row above already carries every shift that pages; these stay out of the tab order. + isTabStop: false, bars: Option.match(lane.shifts, { onNone: () => [], onSome: (shifts) => @@ -131,106 +159,128 @@ const periodLabel = (fromMs: number, toMs: number, timeZone: string): string => return `${day.format(fromMs)} to ${day.format(toMs - 1)}` } -const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { - const days = Number(options.rangeDays) - const summary = gapSummary(options.gaps, options.timeZone, options.weekOffset, days) - const isCovered = options.gaps.length === 0 - return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, [ - hstack(h, { gap: "1.5" }, [ - h.span( - [h.AriaHidden(true), h.Class(isCovered ? "text-primary" : "text-warning-foreground")], - [icon(h, isCovered ? "CircleCheckIcon" : "AlertWarningIcon")], +const summaryLine = ( + h: HtmlBuilder, + iconName: "CircleCheckIcon" | "AlertWarningIcon" | "ClockIcon", + iconClass: string, + text: string, +): Html => + hstack(h, { gap: "1.5", align: "start" }, [ + h.span([h.AriaHidden(true), h.Class(`mt-0.5 shrink-0 ${iconClass}`)], [icon(h, iconName)]), + typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [text]), + ]) + +const gapLinks = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const count = options.gaps.length + const more = (shown: number, className: string): ReadonlyArray => + count > shown + ? [ + typography( + h, + { size: "xs", tone: "muted", className, attributes: [h.Key(`more-${shown}`)] }, + [`and ${count - shown} more on the timeline`], + ), + ] + : [] + return hstack(h, { gap: "1", wrap: true }, [ + ...Arr.map(Arr.take(options.gaps, WIDE_GAP_LINKS), (gap, index) => + button( + h, + { + size: "xs", + variant: "outline", + // A narrow preview (a phone) links fewer gaps. + className: index < NARROW_GAP_LINKS ? undefined : "hidden @min-[30rem]:inline-flex", + attributes: [h.Key(`gap-link-${gap.startMs}`), h.OnClick(options.onFocusGap(index))], + }, + [Str.capitalize(gapRangeText(gap, options.timeZone, options.toMs))], ), - typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [ - summary, - ]), - ]), - ...(isCovered - ? [] - : [ - hstack(h, { gap: "1", wrap: true }, [ - ...Arr.map(Arr.take(options.gaps, MAX_GAP_LINKS), (gap, index) => - button( - h, - { - size: "xs", - variant: "outline", - attributes: [ - h.Key(`gap-link-${gap.startMs}`), - h.OnClick(options.onFocusGap(index)), - ], - }, - [gapRangeText(gap, options.timeZone)], - ), - ), - ...(options.gaps.length > MAX_GAP_LINKS - ? [ - typography( - h, - { size: "xs", tone: "muted", attributes: [h.Key("more")] }, - [`and ${options.gaps.length - MAX_GAP_LINKS} more on the timeline`], - ), - ] - : []), - ]), - ]), + ), + ...more(NARROW_GAP_LINKS, "@min-[30rem]:hidden"), + ...more(WIDE_GAP_LINKS, "hidden @min-[30rem]:inline"), ]) } -export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => - h.section( - [h.Class("min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], - [ - hstack(h, { gap: "2", justify: "between", wrap: true }, [ - vstack(h, { gap: "0" }, [ - heading(h, { level: 2, variant: "section" }, [options.title]), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - periodLabel(options.fromMs, options.toMs, options.timeZone), - ]), - ]), - hstack(h, { gap: "1", wrap: true }, [ - segmentedSelect(h, { - options: RANGE_OPTIONS, - value: options.rangeDays, - onChange: options.onRange, - size: "xs", - ariaLabel: "Range", - }), - iconButton(h, { label: "Previous week", attributes: [h.OnClick(options.onPrevious)] }, [ - icon(h, "ChevronLeftIcon"), - ]), - button( +const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const scope = { + timeZone: options.timeZone, + weekOffset: options.weekOffset, + days: Number(options.rangeDays), + checkedFromMs: Math.max(options.fromMs, options.nowMs), + checkedToMs: options.toMs, + } + const summary = gapSummary(options.gaps, scope) + const alarm = summaryLine(h, "AlertWarningIcon", "text-severity-error", summary) + const body = + // A range that has wholly passed has nothing left to check. + options.toMs <= options.nowMs + ? [ + summaryLine( h, - { - size: "sm", - variant: "outline", - isDisabled: options.weekOffset === 0, - attributes: [h.OnClick(options.onToday)], - }, - ["Today"], + "ClockIcon", + "text-muted-foreground", + "Past shifts. Gaps are only checked from now.", ), - iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ - icon(h, "ChevronRightIcon"), - ]), - ]), - ]), - ...(options.note === undefined - ? [] - : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), - ...Arr.map(options.warnings ?? [], (warning) => - hstack(h, { gap: "1.5", attributes: [h.Key(warning)] }, [ - h.span( - [h.AriaHidden(true), h.Class("text-warning-foreground")], - [icon(h, "CircleInfoIcon")], - ), - typography(h, { size: "xs", tone: "muted", as: "p" }, [warning]), - ]), + ] + : options.gaps.length === 0 + ? [summaryLine(h, "CircleCheckIcon", "text-severity-info", summary)] + : // One gap that is the whole range needs no link to find it. + isUncovered(options.gaps, scope) + ? [alarm] + : [alarm, gapLinks(h, options)] + return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, body) +} + +const noteView = (h: HtmlBuilder, note: PreviewNote): Html => + hstack(h, { gap: "1.5", align: "start", attributes: [h.Key(note.text)] }, [ + h.span( + [ + h.AriaHidden(true), + h.Class( + `mt-0.5 shrink-0 ${note.tone === "warn" ? "text-severity-warn" : "text-muted-foreground"}`, + ), + ], + [icon(h, note.tone === "warn" ? "CircleWarningIcon" : "CircleInfoIcon", { size: 14 })], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [note.text]), + ]) + +const controls = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + // The range switch takes its own row in a narrow preview; the week steps never split. + vstack(h, { gap: "2", className: "@min-[30rem]:flex-row @min-[30rem]:items-center" }, [ + segmentedSelect(h, { + options: RANGE_OPTIONS, + value: options.rangeDays, + onChange: options.onRange, + size: "xs", + ariaLabel: "Range", + className: "w-fit", + }), + hstack(h, { gap: "1", className: "shrink-0" }, [ + iconButton( + h, + { + label: "Previous week", + isDisabled: options.isPreviousDisabled ?? false, + attributes: [h.OnClick(options.onPrevious)], + }, + [icon(h, "ChevronLeftIcon")], ), - ...(options.placeholder === undefined - ? [gapSummaryView(h, options), timelineView(h, options)] - : [options.placeholder]), - ], - ) + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: options.weekOffset === 0, + attributes: [h.OnClick(options.onToday)], + }, + ["Today"], + ), + iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ + icon(h, "ChevronRightIcon"), + ]), + ]), + ]) const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => shiftTimeline(h, { @@ -246,6 +296,30 @@ const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): { tone: "primary", label: "On call" }, { tone: "info", label: "Override" }, { tone: "neutral", label: "Layer shift" }, - { tone: "warning", label: "No one" }, + { tone: "gap", label: "No one" }, ], }) + +export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + h.section( + // A container: the controls and gap links lay out by the preview's width, not the screen's. + [h.Class("@container min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], + [ + hstack(h, { gap: "2", justify: "between", align: "start", wrap: true }, [ + vstack(h, { gap: "0" }, [ + heading(h, { level: 2, variant: "section" }, [options.title]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + periodLabel(options.fromMs, options.toMs, options.timeZone), + ]), + ]), + controls(h, options), + ]), + ...(options.note === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), + ...Arr.map(options.notes ?? [], (note) => noteView(h, note)), + ...(options.placeholder === undefined + ? [gapSummaryView(h, options), timelineView(h, options)] + : [options.placeholder]), + ], + ) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index cc374d6fe7..adcc07d41b 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -7,8 +7,11 @@ import { draftConfigOf, draftCoverage, durationText, + gapRangeText, gapsOf, gapSummary, + layerOnCallAt, + previewLayerOf, previewRange, type Interval, } from "./schedule-coverage" @@ -217,20 +220,72 @@ describe("previewRange", () => { }) describe("gapSummary", () => { + const scope = { + timeZone: "Europe/Berlin", + weekOffset: 0, + days: 14, + checkedFromMs: at("2026-11-10T10:30:00Z"), + checkedToMs: at("2026-11-23T23:00:00Z"), + } it("says covered when there are no gaps", () => { - expect(gapSummary([], "Europe/Berlin", 0, 14)).toBe("Covered for the next 14 days") + expect(gapSummary([], scope)).toBe("Covered for the next 14 days") + expect(gapSummary([], { ...scope, weekOffset: 2 })).toBe("Covered in this period") }) it("counts gaps and names the longest in the schedule's zone", () => { const gaps = [ { startMs: at("2026-11-19T23:00:00Z"), endMs: at("2026-11-20T01:00:00Z") }, { startMs: at("2026-11-20T23:00:00Z"), endMs: at("2026-11-21T15:00:00Z") }, ] - expect(gapSummary(gaps, "Europe/Berlin", 0, 14)).toBe( + expect(gapSummary(gaps, scope)).toBe( "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", ) }) + it("says nobody is on call when the one gap is everything checked", () => { + const whole = [{ startMs: scope.checkedFromMs, endMs: scope.checkedToMs }] + expect(gapSummary(whole, scope)).toBe("Nobody is on call for the next 14 days") + expect(gapSummary(whole, { ...scope, weekOffset: 1 })).toBe("Nobody is on call in this period") + }) + it("gives a gap cut off by the range's end no end time", () => { + const closed = { startMs: at("2026-11-12T08:00:00Z"), endMs: at("2026-11-12T10:00:00Z") } + const open = { startMs: at("2026-11-21T07:00:00Z"), endMs: scope.checkedToMs } + expect(gapSummary([closed, open], scope)).toBe( + "2 gaps in the next 14 days, longest 2d 16h or longer (from Sat 21 Nov 08:00)", + ) + expect(gapRangeText(closed, "Europe/Berlin", scope.checkedToMs)).toBe("Thu 12 Nov 09:00 to 11:00") + expect(gapRangeText(open, "Europe/Berlin", scope.checkedToMs)).toBe("from Sat 21 Nov 08:00") + }) it("formats durations", () => { expect(durationText(90 * 60_000)).toBe("1h 30m") expect(durationText(76 * HOUR)).toBe("3d 4h") }) }) + +describe("a layer being edited", () => { + const window = { key: "r0", weekdays: ["mon" as const], startTime: "18:00", endTime: "08:00" } + const staffed = layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + startsAt: "2026-11-09T09:00", + }) + + it("does not preview while one of its windows is half typed, rather than reading as always active", () => { + const halfTyped = { ...staffed, restrictions: [window, { ...window, key: "r1", endTime: "" }] } + const noDays = { ...staffed, restrictions: [{ ...window, weekdays: [] }] } + expect(Option.isNone(previewLayerOf(halfTyped, "Europe/Berlin"))).toBe(true) + expect(Option.isNone(previewLayerOf(noDays, "Europe/Berlin"))).toBe(true) + const from = at("2026-11-09T00:00:00Z") + const to = at("2026-11-16T00:00:00Z") + const coverage = draftCoverage(formOf([halfTyped]), [], from, to, from) + expect(coverage.final).toEqual([]) + expect(coverage.gaps).toEqual([{ startMs: from, endMs: to }]) + }) + + it("names who the layer alone has on call at an instant, and nobody outside its windows", () => { + // Ana holds Mon 9 Nov 09:00, Ben Tue 10 Nov 09:00. + expect(layerOnCallAt(staffed, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual( + Option.some("ben"), + ) + const nights = { ...staffed, restrictions: [window] } + expect(layerOnCallAt(nights, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual(Option.none()) + }) +}) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts index 554ae22547..2a85dbf5ea 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -1,10 +1,11 @@ -import { renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" -import { OnCallLayer, OnCallLocalTime, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +import { onCallAt, renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallLayer, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" import type { OnCallOverride } from "@maple/domain/http" +import type { UserId } from "@maple/domain/primitives" import { zonedDateParts } from "@maple/query-engine/datetime" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import { Array as Arr, Option, Order, Result, Schema, String as Str } from "effect" -import { localInputMs, type Form, type LayerDraft } from "../schedule-form/form" +import { layerStartMs, restrictionError, type Form, type LayerDraft } from "../schedule-form/form" // Coverage preview: the draft rendered with the server's own `renderOnCall`, so the preview // and paging agree, plus the gaps where nobody is on call. @@ -36,40 +37,49 @@ export const gapsOf = ( return cursor < toMs ? [...gaps, { startMs: cursor, endMs: toMs }] : gaps } -const isLocalTime = Schema.is(OnCallLocalTime) const isZone = Schema.is(OnCallTimeZone) const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) -const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => - layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) - -/** A draft layer as the engine's layer, while it has people and a valid rotation; bad restrictions drop out. */ +/** + * A draft layer as the engine's layer, once it has people, a valid rotation and valid windows. + * A half-typed window is none, not "always active": the engine reads no windows as 24/7. + */ export const previewLayerOf = (layer: LayerDraft, timeZone: string): Option.Option => - Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => - decodeLayer({ - // The name never changes who is on call, so a blank one still previews. - name: Str.isEmpty(Str.trim(layer.name)) ? "Layer" : Str.slice(0, 100)(Str.trim(layer.name)), - participants: layer.participants, - rotation: - layer.rotation === "custom" - ? { kind: "custom", lengthHours: Number(Str.trim(layer.lengthHours)) } - : layer.rotation === "daily" - ? { kind: "daily", handoffTime: layer.handoffTime } - : { - kind: "weekly", - handoffWeekday: layer.handoffWeekday, - handoffTime: layer.handoffTime, - }, - startsAt, - restrictions: Arr.map( - Arr.filter( - layer.restrictions, - (r) => r.weekdays.length > 0 && isLocalTime(r.startTime) && isLocalTime(r.endTime), - ), - ({ weekdays, startTime, endTime }) => ({ weekdays, startTime, endTime }), - ), - }), + Arr.some(layer.restrictions, (restriction) => Option.isSome(restrictionError(restriction))) + ? Option.none() + : Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => + decodeLayer(engineLayerOf(layer, startsAt)), + ) + +const engineLayerOf = (layer: LayerDraft, startsAt: number) => ({ + // The name never changes who is on call, so a blank one still previews. + name: Str.isEmpty(Str.trim(layer.name)) ? "Layer" : Str.slice(0, 100)(Str.trim(layer.name)), + participants: layer.participants, + rotation: + layer.rotation === "custom" + ? { kind: "custom", lengthHours: Number(Str.trim(layer.lengthHours)) } + : layer.rotation === "daily" + ? { kind: "daily", handoffTime: layer.handoffTime } + : { + kind: "weekly", + handoffWeekday: layer.handoffWeekday, + handoffTime: layer.handoffTime, + }, + startsAt, + restrictions: Arr.map(layer.restrictions, ({ weekdays, startTime, endTime }) => ({ + weekdays, + startTime, + endTime, + })), +}) + +/** Who the layer alone has on call at `atMs`; none in one of its gaps, or while it cannot preview. */ +export const layerOnCallAt = (layer: LayerDraft, timeZone: string, atMs: number): Option.Option => + previewLayerOf(layer, timeZone).pipe( + Option.flatMap((engineLayer) => decodeConfig({ timeZone, layers: [engineLayer] })), + Option.flatMap((config) => Option.fromNullishOr(onCallAt(config, [], atMs))), + Option.map((shift) => shift.userId), ) export interface DraftConfig { @@ -134,9 +144,6 @@ export const draftCoverage = ( // RANGE: whole wall-clock days in the schedule's zone, so DST days are 23 or 25 hours. -export const RANGE_DAYS = [7, 14, 28] as const -export type RangeDays = (typeof RANGE_DAYS)[number] - /** `days` wall-clock days from the start of today, `weekOffset` weeks on. */ export const previewRange = ( nowMs: number, @@ -181,9 +188,13 @@ const sameDay = (a: number, b: number, timeZone: string) => { const dropComma = Str.replace(",", "") -/** "Sat 21 Nov 00:00 to 16:00", or both ends in full across days. */ -export const gapRangeText = (gap: Interval, timeZone: string): string => { +/** A gap that runs to the end of what was checked: its real end is not known. */ +export const isOpenGap = (gap: Interval, checkedToMs: number): boolean => gap.endMs >= checkedToMs + +/** "Sat 21 Nov 00:00 to 16:00", both ends in full across days, or "from Sat 21 Nov 00:00" when open. */ +export const gapRangeText = (gap: Interval, timeZone: string, checkedToMs: number): string => { const start = dropComma(formatterOf(timeZone, "day").format(gap.startMs)) + if (isOpenGap(gap, checkedToMs)) return `from ${start}` const end = sameDay(gap.startMs, gap.endMs, timeZone) ? formatterOf(timeZone, "time").format(gap.endMs) : dropComma(formatterOf(timeZone, "day").format(gap.endMs)) @@ -213,15 +224,38 @@ export const longestGap = (gaps: ReadonlyArray): Option.Option + +/** One gap spanning everything checked: nobody is on call at all. */ +export const isUncovered = ( gaps: ReadonlyArray, - timeZone: string, - weekOffset: number, - days: number, -): string => - Option.match(longestGap(gaps), { - onNone: () => (weekOffset === 0 ? `Covered for the next ${days} days` : "Covered in this period"), + scope: Pick, +): boolean => { + const only = gaps.length === 1 ? gaps[0] : undefined + return only !== undefined && only.startMs <= scope.checkedFromMs && only.endMs >= scope.checkedToMs +} + +/** + * "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", "Covered for the next + * 14 days", or "Nobody is on call for the next 14 days" when the one gap is everything checked. + */ +export const gapSummary = (gaps: ReadonlyArray, scope: GapScope): string => { + const isFromToday = scope.weekOffset === 0 + if (isUncovered(gaps, scope)) + return isFromToday + ? `Nobody is on call for the next ${scope.days} days` + : "Nobody is on call in this period" + return Option.match(longestGap(gaps), { + onNone: () => (isFromToday ? `Covered for the next ${scope.days} days` : "Covered in this period"), onSome: (longest) => - `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${weekOffset === 0 ? `in the next ${days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)} (${gapRangeText(longest, timeZone)})`, + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${isFromToday ? `in the next ${scope.days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, }) +} diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 7405a6aac5..6a876a4117 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -12,7 +12,8 @@ import { dashboardPage } from "./dashboard-page" /** * `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. Rows sit in a `divide-y` card. + * and its controls on the right; the two stack in a card under 36rem wide (a phone, or a form + * beside a preview). Rows sit in a `divide-y` `formCard`, whose width they read. */ export const formSection = ( h: HtmlBuilder, @@ -29,12 +30,24 @@ export const formSection = ( [ h.AriaLabel(options.title), h.Class( - cn("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]", options.className), + cn( + "grid gap-x-8 gap-y-2 px-4 py-4 @min-[36rem]/form:grid-cols-[136px_minmax(0,1fr)]", + options.className, + ), ), ], [ h.div( - [h.Class(cn("flex flex-col gap-0.5", options.isCompact === true ? "sm:pt-1.5" : "sm:pt-2"))], + [ + h.Class( + cn( + "flex flex-col gap-0.5", + options.isCompact === true + ? "@min-[36rem]/form:pt-1.5" + : "@min-[36rem]/form:pt-2", + ), + ), + ], [ heading(h, { level: 3, variant: "subsection" }, [options.title]), ...(options.hint === undefined @@ -47,15 +60,22 @@ 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 => +export const formScrollArea = ( + h: HtmlBuilder, + children: ReadonlyArray, + className?: string, +): Html => h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class(cn("min-h-0 flex-1 overflow-auto p-4", className)), + ], children, ) /** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => - dividedList(h, { as: "div", className: cn("bg-card", className) }, children) + dividedList(h, { as: "div", className: cn("@container/form bg-card", className) }, children) /** * `FormActionBar`: pinned under a form's scroller, outside it, so it never floats over the form: diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..48a083523b 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -190,7 +190,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`, `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/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 in a card under 36rem wide (a phone,... - `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` `keyFor`, `setupBlocks`, `serverKey`, `promptKey`, `frameworkIconStrip`, `frameworkPicker`, `setupAgentPrompt`, `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. From f9d010fc46c278ef136e3e561fcb662ff917c012 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:57 +0100 Subject: [PATCH 12/35] fix(views): side panel dims the page lightly, and comes in a compact size - A `scrim-panel` token (12% black, 24% in dark) with no blur sits beside a side panel, so the page it edits stays readable. Dialogs and other sheets keep `scrim`. - `sidePanelSheet` takes `size`: `compact` (28rem) for a few short fields, `form` (42rem, the default) or `wide`. --- docs/design-rules.md | 3 ++- packages/ui/src/components/ui/sheet.styles.ts | 4 ++++ packages/ui/src/styles/tokens.css | 5 +++++ packages/views/src/ui/dialog-view.ts | 13 ++++++++++- packages/views/src/ui/sheet.ts | 22 +++++++++++++++---- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 0ae8ecd8ea..7b109f0724 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -89,7 +89,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi `surface-strong`. - A see-through surface is a token, never `bg-background/NN` or `bg-card/NN` (`maple/surface-translucency`): `surface-well` is an inset well on a card, `surface-float` (with - `backdrop-blur-sm`) a label over a chart, map or media, `scrim` the layer behind a dialog. + `backdrop-blur-sm`) a label over a chart, map or media, `scrim` the layer behind a dialog, + `scrim-panel` the lighter, unblurred one beside a side panel (the page stays readable). - Layers come from the z scale, never `z-[…]` (`maple/no-arbitrary-z`): `z-1`..`z-10` inside a surface, `z-50` dialogs and sheets, `z-55` popovers and tooltips, `z-60` toasts and nested lists. A popup takes a popup radius (`maple/popup-radius`). diff --git a/packages/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index c05a45b746..e09ce45e39 100644 --- a/packages/ui/src/components/ui/sheet.styles.ts +++ b/packages/ui/src/components/ui/sheet.styles.ts @@ -1,6 +1,10 @@ export const sheetBackdropStyles = "fixed inset-0 z-50 bg-scrim backdrop-blur-sm transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0" +/** Behind a side panel: a lighter dim and no blur, so the page it edits stays readable. */ +export const sheetBackdropPanelStyles = + "fixed inset-0 z-50 bg-scrim-panel transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0" + export const sheetViewportStyles = "fixed inset-0 z-50 grid" export const sheetViewportBottomStyles = "grid grid-rows-[1fr_auto] pt-12" diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 580c9a01f6..e71375759d 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -222,6 +222,8 @@ --on-fill: oklch(1 0 0); /* The dim behind a dialog, sheet or command palette. */ --scrim: oklch(0 0 0 / 32%); + /* The dim beside a side panel: light enough to keep reading the page the panel belongs to. */ + --scrim-panel: oklch(0 0 0 / 12%); /* Media frames (the replay player): dark in both themes, the stage, fullscreen and its play scrim. */ --media: oklch(0.205 0 0); --media-backdrop: oklch(0 0 0); @@ -370,6 +372,8 @@ --warning-foreground: var(--color-amber-400); --control-highlight: 0 -1px oklch(1 0 0 / 6%); --surface-recessed: oklch(0 0 0 / 25%); + /* A dark page needs more black to read as set back; still far lighter than the dialog's. */ + --scrim-panel: oklch(0 0 0 / 24%); --border-raised: oklch(1 0 0 / 10%); } @@ -572,6 +576,7 @@ --color-media-scrim: var(--media-scrim); --color-on-fill: var(--on-fill); --color-scrim: var(--scrim); + --color-scrim-panel: var(--scrim-panel); --color-surface-recessed: var(--surface-recessed); --color-border-raised: var(--border-raised); /* Service-map namespace boxes: one hue per namespace, set on the box as `--namespace-hue`. diff --git a/packages/views/src/ui/dialog-view.ts b/packages/views/src/ui/dialog-view.ts index bfc6bbfadc..fc5610b4c1 100644 --- a/packages/views/src/ui/dialog-view.ts +++ b/packages/views/src/ui/dialog-view.ts @@ -30,6 +30,9 @@ import { focusGuard, isMounted, stateAttributes } from "./overlay/transition" export type SheetSide = "right" | "left" | "top" | "bottom" +/** A sheet's dim: `dialog` blurs and darkens the page, `panel` only dims it lightly. */ +export type SheetScrim = "dialog" | "panel" + type Parts = Readonly<{ backdrop: string viewport: string @@ -51,6 +54,7 @@ const partsOf = ( className: string | undefined, isBottomStick = true, size: SheetStyles.SheetSize = "default", + scrim: SheetScrim = "dialog", ): Parts => { switch (kind) { case "dialog": @@ -89,7 +93,11 @@ const partsOf = ( } case "sheet": return { - backdrop: cn(SheetStyles.sheetBackdropStyles), + backdrop: cn( + scrim === "panel" + ? SheetStyles.sheetBackdropPanelStyles + : SheetStyles.sheetBackdropStyles, + ), viewport: cn( SheetStyles.sheetViewportStyles, side === "bottom" && SheetStyles.sheetViewportBottomStyles, @@ -137,6 +145,8 @@ export type ViewInputs = Readonly<{ /** A left or right sheet's width (`SheetSize`): pick by content, never a `max-w-*` class. */ size?: SheetStyles.SheetSize hasTrigger?: boolean + /** A sheet's dim (`dialog` by default); see `SheetScrim`. */ + scrim?: SheetScrim /** `showCloseButton={false}`: the content renders its own close. */ showCloseButton?: boolean /** `closeProps={{ className }}`: classes merged onto the close button's own. */ @@ -166,6 +176,7 @@ export const view = Submodel.defineView((model, inpu inputs.className, inputs.isBottomStickOnMobile, inputs.size, + inputs.scrim, ) const slot = slotPrefix(model.kind) const isVisible = isMounted(model.isOpen, model.animation) diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 966070641b..a0b121df0a 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -134,12 +134,24 @@ export const leftSheet = ( // SIDE PANEL SHEET +/** + * How wide a side panel is, by what it holds: `compact` (28rem) a short form of a few fields, + * `form` (42rem) a full create or edit form, `wide` (48rem) reading content. + */ +export type SidePanelSize = "compact" | "form" | "wide" + +const SIDE_PANEL_SIZE = { + compact: "default", + form: "form", + wide: "wide", +} satisfies Record + export type SidePanelSheetOptions = Readonly<{ sheet: Dialog.Model title: string description?: string - /** `form` (672px) by default; `wide` for reading content. */ - size?: SheetSize + /** `form` by default; pick `compact` when the form is a handful of short fields. */ + size?: SidePanelSize /** The scrolling body. */ body: ReadonlyArray /** @@ -156,7 +168,8 @@ const REDUCED_MOTION = /** * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a - * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). + * sticky footer. Its dim is light and unblurred, so the page it belongs to stays readable. Escape, + * the backdrop and the close button close it (`isDismissed`). */ export const sidePanelSheet = ( h: HtmlBuilder, @@ -169,7 +182,8 @@ export const sidePanelSheet = ( view: DialogView.view, viewInputs: { side: "right", - size: options.size ?? "form", + size: SIDE_PANEL_SIZE[options.size ?? "form"], + scrim: "panel", className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, From f1179ea918e3ea4b55e5bf1c436e87dce2f02d07 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:57 +0100 Subject: [PATCH 13/35] fix(shell): one-row on-call pill, calmer sidebar states - The on-call pill is one 32px row of the footer list: a status dot and "On call until Mon 09:00" in the viewer's zone (the date instead of the weekday once the handoff is over six days out), or a muted "Not on call". The zone, date and schedules are its tooltip and accessible name. On the collapsed rail it is a calendar glyph badged with the dot. - Group chevrons show faintly at rest; the toggle is 28px tall. - A folded group holding the current page shows that page's parent row alone, highlighted, without its sub-items. - Hover is a neutral wash. When a sub-item is the current page only it is tinted; its parent keeps the medium weight on a plain background. - Sub-item rows are 28px on desktop and 36px in the mobile sheet. - /lab/shell gains Services and Logs pages, a far handoff and both panel sizes. --- apps/web/src/lab/entries/shell.ts | 26 ++- .../src/lab/shell-states-lab.scene.test.ts | 22 +++ apps/web/src/lab/shell-states-lab.ts | 181 ++++++++++++++---- apps/web/src/shell/nav-groups.scene.test.ts | 19 ++ apps/web/src/shell/oncall.scene.test.ts | 49 +++-- apps/web/src/shell/oncall.ts | 49 +++-- apps/web/src/shell/view-nav.ts | 19 +- apps/web/src/shell/view-sidebar.ts | 125 ++++++------ apps/web/src/shell/view.ts | 4 +- .../ui/src/components/ui/sidebar.styles.ts | 8 +- packages/views/src/ui/sidebar.ts | 19 +- 11 files changed, 369 insertions(+), 152 deletions(-) diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts index 8af4756284..ab51c1c6c3 100644 --- a/apps/web/src/lab/entries/shell.ts +++ b/apps/web/src/lab/entries/shell.ts @@ -1,12 +1,13 @@ -import { Effect, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import { Dom, Subscription } from "foldkit" +import * as TooltipHost from "@maple/views/ui/tooltip-host" import type { Resources } from "../../platform/resources" import * as Shell from "../../shell" import { labShellSubscriptions } from "../app-shell" import { defineLabWithFlags } from "../define" -import { Flags, init, Message, Model, toShell, update, view } from "../shell-states-lab" +import { Flags, init, Message, Model, toShell, toTooltip, update, view } from "../shell-states-lab" -// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held right sheet. +// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held side panel. const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window @@ -32,6 +33,25 @@ export const lab = defineLabWithFlags("Shell states", { labShellSubscriptions(toShell), Subscription.make()((entry) => ({ url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }), + // The app's tooltip, which the open sidebar's on-call row uses. + tooltipTriggers: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(TooltipHost.triggerEvents, toTooltip), + }, + ), + tooltipWhileEngaged: entry( + { isEngaged: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isEngaged: TooltipHost.isEngaged(model.tooltip) }), + dependenciesToStream: ({ isEngaged }) => + Stream.when( + Stream.map(TooltipHost.whileEngaged, toTooltip), + Effect.sync(() => isEngaged), + ), + }, + ), })), ), }) diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 445addbe8f..7499d93bff 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -20,6 +20,7 @@ import { init, Message, type Model, + COMPACT_SHEET_ID, DismissSheetUrl, PushSheetUrl, SHEET_ID, @@ -94,6 +95,27 @@ describe("URL-held right sheet", () => { ) }) + test("the compact panel has its own ?sheet= id and content", () => { + const snooze = role("dialog", { name: "Snooze rule" }) + scene( + { update, view }, + given(at("/lab/shell")), + measured, + click(role("button", { name: "Snooze rule" })), + Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: COMPACT_SHEET_ID })), + Command.resolve(PushSheetUrl, Message.CompletedNavigate()), + Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}` })), + ...enter(1), + expect(snooze).toContainText("Hours"), + keydown(portal, "Escape"), + Command.expectHas( + DismissSheetUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}`, id: COMPACT_SHEET_ID }), + ), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), + ...leave(2), + ) + }) + test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => { const open = at(`/lab/shell?sheet=${SHEET_ID}`) const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 9e78acc414..49d780bef8 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -14,8 +14,9 @@ import { type SheetStep, sidePanelSheet, } from "@maple/views/ui/sheet" -import { vstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" +import * as TooltipHost from "@maple/views/ui/tooltip-host" import { typography } from "@maple/views/ui/typography" import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" @@ -25,36 +26,63 @@ import { init as initShell, Model as ShellModel, OnCallData } from "../shell/mod import type { OnCallSnapshot } from "../shell/oncall" import { foldLabShell, labShellView } from "./shell-frame" -// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the -// right sheet held in `?sheet=` (Back closes it, the link reopens it). +// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the side +// panel held in `?sheet=` (Back closes it, the link reopens it) in both of its sizes. +/** The `form` panel: also the Dialog's id. */ export const SHEET_ID = "new-alert-rule" +/** The `compact` panel. */ +export const COMPACT_SHEET_ID = "snooze-rule" const LAB_PATH = "/lab/shell" -export const OnCallFixture = Schema.Literals(["onCall", "notOnCall", "none"]) +export const OnCallFixture = Schema.Literals(["onCall", "onCallFar", "notOnCall", "none"]) export type OnCallFixture = typeof OnCallFixture.Type -export const StandOn = Schema.Literals(["lab", "alerts"]) +export const StandOn = Schema.Literals(["lab", "alerts", "services", "logs"]) export type StandOn = typeof StandOn.Type +const STAND_ON_PATH = { + lab: LAB_PATH, + alerts: "/alerts", + services: "/services", + logs: "/logs", +} satisfies Record + +/** Which panel the URL names. */ +export const Panel = Schema.Literals(["rule", "snooze"]) +export type Panel = typeof Panel.Type + +const PANEL_ID = { rule: SHEET_ID, snooze: COMPACT_SHEET_ID } satisfies Record + /** Mon 16 Nov 2099, 08:00 UTC (09:00 CET): far enough out that the fixture shift never ends. */ const HANDOFF_MS = Date.UTC(2099, 10, 16, 8) +const DAY_MS = 24 * 60 * 60_000 +/** The lab's clock: three days before the handoff, so the near shift reads by its weekday. */ +const LAB_NOW_MS = HANDOFF_MS - 3 * DAY_MS const LAB_USER = "user_lab" +const shiftOf = (scheduleName: string, userId: string, endsAtMs: number) => ({ + scheduleName, + userId, + endsAtMs, +}) + +const SHIFTS = { + onCall: [shiftOf("Checkout primary", LAB_USER, HANDOFF_MS)], + // Over six days out, and a second schedule held at once. + onCallFar: [ + shiftOf("Checkout primary", LAB_USER, HANDOFF_MS + 7 * DAY_MS), + shiftOf("Payments", LAB_USER, HANDOFF_MS + 14 * DAY_MS), + ], + notOnCall: [shiftOf("Checkout primary", "user_other", HANDOFF_MS)], + none: [], +} satisfies Record + export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ userId: LAB_USER, viewerTimeZone: "Europe/Berlin", - shifts: - fixture === "none" - ? [] - : [ - { - scheduleName: "Checkout primary", - userId: fixture === "onCall" ? LAB_USER : "user_other", - endsAtMs: HANDOFF_MS, - }, - ], + shifts: SHIFTS[fixture], }) export const Flags = Schema.Struct({ @@ -72,6 +100,9 @@ export type Flags = typeof Flags.Type export const Model = Schema.Struct({ shell: ShellModel, sheet: Dialog.Model, + /** The panel the sheet shows; kept through its exit transition. */ + panel: Panel, + tooltip: TooltipHost.Model, href: Schema.String, onCall: OnCallFixture, standOn: StandOn, @@ -81,10 +112,11 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotShellMessage: { message: ShellMessage }, GotSheetMessage: { message: Dialog.Message }, + GotTooltipMessage: { message: TooltipHost.Message }, /** The URL changed (a push, a replace, Back or Forward). */ ChangedUrl: { href: Schema.String }, - ClickedNewRule: {}, - ClickedCreateRule: {}, + ClickedOpenPanel: { panel: Panel }, + ClickedSubmitPanel: {}, ClickedOnCallFixture: { fixture: OnCallFixture }, ClickedStandOn: { standOn: StandOn }, CompletedNavigate: {}, @@ -110,31 +142,45 @@ export const DismissSheetUrl = Command.define("DismissSheetUrl", { type Return = Update.Return export const toShell = (message: ShellMessage): Message => Message.GotShellMessage({ message }) +export const toTooltip = (message: TooltipHost.Message): Message => Message.GotTooltipMessage({ message }) const toSheet = (message: Dialog.Message): Message => Message.GotSheetMessage({ message }) const foldShell = foldLabShell(toShell) -const isSheetInUrl = (href: string): boolean => Option.contains(sheetParamOf(href), SHEET_ID) +/** The panel `?sheet=` names, if it names one of the lab's. */ +const panelInUrl = (href: string): Option.Option => + Option.flatMap(sheetParamOf(href), (id) => + id === SHEET_ID + ? Option.some("rule") + : id === COMPACT_SHEET_ID + ? Option.some("snooze") + : Option.none(), + ) const sheetStep = (model: Model, step: SheetStep): Return => ({ model: { ...model, sheet: step.model }, commands: [ ...mapSheetCommands(step, toSheet), - ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: SHEET_ID })] : []), + ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: PANEL_ID[model.panel] })] : []), ], }) +/** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, onCall: OnCallData.Success({ data: onCallSnapshotOf(fixture) }), + onCallNowMs: LAB_NOW_MS, }) /** No org: the shell sends nothing, so the fixtures are all it shows. */ export const init = (flags: Flags): Return => { - const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), isSheetInUrl(flags.href)) + const open = panelInUrl(flags.href) + const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), Option.isSome(open)) return { model: { shell: withOnCall(initShell(flags.shell), "onCall"), sheet: sheet.model, + panel: Option.getOrElse(open, (): Panel => "rule"), + tooltip: TooltipHost.init("tooltip"), href: flags.href, onCall: "onCall", standOn: "lab", @@ -147,15 +193,31 @@ export const update = (model: Model, message: Message): Return => Message.match(message, { GotShellMessage: ({ message: inner }) => foldShell(model, inner), GotSheetMessage: ({ message: inner }) => sheetStep(model, Dialog.update(model.sheet, inner)), + GotTooltipMessage: ({ message: inner }) => { + const next = TooltipHost.update(model.tooltip, inner) + return { + model: { ...model, tooltip: next.model }, + commands: Command.mapMessages(next.commands ?? [], toTooltip), + } + }, ChangedUrl: ({ href }) => { - const step = followUrl(model.sheet, isSheetInUrl(href)) - return { model: { ...model, href, sheet: step.model }, commands: mapSheetCommands(step, toSheet) } + const open = panelInUrl(href) + const step = followUrl(model.sheet, Option.isSome(open)) + return { + model: { + ...model, + href, + sheet: step.model, + panel: Option.getOrElse(open, () => model.panel), + }, + commands: mapSheetCommands(step, toSheet), + } }, - ClickedNewRule: () => ({ + ClickedOpenPanel: ({ panel }) => ({ model, - commands: [PushSheetUrl({ href: model.href, id: SHEET_ID })], + commands: [PushSheetUrl({ href: model.href, id: PANEL_ID[panel] })], }), - ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), + ClickedSubmitPanel: () => sheetStep(model, Dialog.close(model.sheet)), ClickedOnCallFixture: ({ fixture }) => ({ model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, }), @@ -171,7 +233,8 @@ const control = (h: HtmlBuilder, title: string, body: Html): Html => const field = (h: HtmlBuilder, id: string, text: string, control: Html): Html => vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) -const sheet = (h: HtmlBuilder, model: Model): Html => +/** The `form` size: a full create form. */ +const rulePanel = (h: HtmlBuilder, model: Model): Html => sidePanelSheet(h, { sheet: model.sheet, title: "New alert rule", @@ -207,7 +270,37 @@ const sheet = (h: HtmlBuilder, model: Model): Html => ], footer: (close) => [ button(h, { variant: "outline", attributes: close }, ["Cancel"]), - button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), + button(h, { attributes: [h.OnClick(Message.ClickedSubmitPanel())] }, ["Create rule"]), + ], + }) + +/** The `compact` size: two short fields. */ +const snoozePanel = (h: HtmlBuilder, model: Model): Html => + sidePanelSheet(h, { + sheet: model.sheet, + size: "compact", + title: "Snooze rule", + description: "The rule keeps checking but pages no one until the snooze ends.", + toParentMessage: toSheet, + body: [ + vstack(h, { gap: "5" }, [ + field( + h, + "snooze-hours", + "Hours", + input(h, { id: "snooze-hours", attributes: [h.Value("4")] }), + ), + field( + h, + "snooze-reason", + "Reason", + input(h, { id: "snooze-reason", attributes: [h.Placeholder("Deploying checkout")] }), + ), + ]), + ], + footer: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), + button(h, { attributes: [h.OnClick(Message.ClickedSubmitPanel())] }, ["Snooze"]), ], }) @@ -216,8 +309,20 @@ const page = (h: HtmlBuilder, model: Model): Html => h, { title: "Shell states", - description: "Fold a sidebar group, switch the on-call fixture, or open the sheet.", - actions: button(h, { attributes: [h.OnClick(Message.ClickedNewRule())] }, ["New alert rule"]), + description: "Fold a sidebar group, switch the on-call fixture, or open a side panel.", + actions: hstack(h, { gap: "2" }, [ + button( + h, + { + variant: "outline", + attributes: [h.OnClick(Message.ClickedOpenPanel({ panel: "snooze" }))], + }, + ["Snooze rule"], + ), + button(h, { attributes: [h.OnClick(Message.ClickedOpenPanel({ panel: "rule" }))] }, [ + "New alert rule", + ]), + ]), }, [ vstack(h, { gap: "6" }, [ @@ -230,6 +335,7 @@ const page = (h: HtmlBuilder, model: Model): Html => onChange: (fixture) => Message.ClickedOnCallFixture({ fixture }), options: [ { value: "onCall", label: "On call" }, + { value: "onCallFar", label: "On call, next week" }, { value: "notOnCall", label: "Not on call" }, { value: "none", label: "No schedules" }, ], @@ -245,20 +351,25 @@ const page = (h: HtmlBuilder, model: Model): Html => options: [ { value: "lab", label: "Lab (no group)" }, { value: "alerts", label: "Alerts (Triage)" }, + { value: "services", label: "Services (Monitor)" }, + { value: "logs", label: "Logs (Analyze)" }, ], }), ), ]), - sheet(h, model), + model.panel === "snooze" ? snoozePanel(h, model) : rulePanel(h, model), ], ) export const view = (model: Model, h: HtmlBuilder): Html => h.div( [h.Id("app")], - labShellView(h, model.shell, toShell, { - currentPath: model.standOn === "alerts" ? "/alerts" : LAB_PATH, - breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], - page: () => page(h, model), - }), + [ + TooltipHost.view(h, model.tooltip, toTooltip), + ...labShellView(h, model.shell, toShell, { + currentPath: STAND_ON_PATH[model.standOn], + breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], + page: () => page(h, model), + }), + ], ) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 77503cb45c..a85dab0187 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -81,6 +81,25 @@ describe("sidebar groups", () => { ) }) + test("a folded group on a sub-item's page shows the parent row alone, highlighted", () => { + const explore = role("link", { name: "Explore" }) + const logs = role("link", { name: "Logs" }) + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + // Open: the tint is the sub-item's, the parent only keeps its weight. + expect(logs).toHaveAttr("data-active", "true"), + expect(explore).toHaveAttr("data-active", "child"), + click(analyze), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(explore).toHaveAttr("data-active", "true"), + expect(logs).toBeAbsent(), + expect(role("link", { name: "Traces" })).toBeAbsent(), + expect(dashboards).toBeAbsent(), + ) + }) + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 5ce01cc3bf..801ee39f55 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -79,35 +79,51 @@ const view = (() => { })() const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) -const footer = selector('[data-slot="sidebar-footer"]') describe("on-call pill", () => { - test("on call: until the handoff on the viewer's clock with its zone, the schedule, a link to schedules", () => { + test("on call: one row with the weekday and time, the rest in its tooltip and name", () => { + const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( { update, view }, given(withOnCall([shift()])), measured, - expect(footer).toContainText("You're on call"), - expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), - expect(footer).toContainText("Checkout primary"), - expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveText("On call until Mon 09:00"), + expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveAttr("data-tooltip", "On call until Mon 09:00 CET, 16 Nov · Checkout primary"), ) }) - test("not on call: says so with a way to the schedules", () => { + test("a handoff more than six days out reads by its date, and other schedules are counted", () => { + const week = 7 * 24 * 60 * 60_000 + const pill = role("link", { name: "On call until 23 Nov, 09:00 CET · Checkout primary +1 more" }) + scene( + { update, view }, + given( + withOnCall([ + shift({ endsAtMs: HANDOFF + week }), + shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 2 * week }), + ]), + ), + measured, + expect(pill).toHaveText("On call until 23 Nov, 09:00"), + ) + }) + + test("not on call: says so, linking to the schedules", () => { + const pill = role("link", { name: "Not on call" }) scene( { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), - expect(footer).toContainText("My schedules"), + expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).not.toHaveAttr("data-tooltip"), ) }) test("an org without schedules, a first read that failed, or a shift that ended show no pill", () => { const absent = [ - expect(text("My schedules")).toBeAbsent(), - expect(text("You're on call")).toBeAbsent(), + expect(text("Not on call")).toBeAbsent(), + expect(text("On call until Mon 09:00")).toBeAbsent(), ] scene({ update, view }, given(withOnCall([])), measured, ...absent) scene( @@ -124,13 +140,14 @@ describe("on-call pill", () => { ) }) - test("on the collapsed rail the pill is a dot named by the sentence, which its tooltip shows", () => { - const pill = role("link", { name: "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)" }) + test("on the collapsed rail the pill is a badged glyph named by the detail, which its tooltip shows", () => { + const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( { update, view }, given(withOnCall([shift()], { isSidebarOpen: false })), measured, - expect(pill).toExist(), + expect(pill).toHaveText(""), + expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), hover(pill), Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), @@ -143,7 +160,7 @@ describe("on-call pill", () => { Animation.Message.EndedAnimation({ generation: 1 }), ), expect(selector('[data-slot="tooltip-popup"]')).toHaveText( - "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)", + "On call until Mon 09:00 CET, 16 Nov · Checkout primary", ), ) }) @@ -236,7 +253,7 @@ describe("onCallStatusOf", () => { _tag: "OnCall", scheduleName: "Checkout primary", otherSchedules: 1, - until: "Mon 16 Nov, 09:00 CET", + until: { short: "Mon 09:00", rest: "CET, 16 Nov" }, }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 72664980f1..e29cee4d4d 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -29,13 +29,23 @@ export type OnCallStatus = scheduleName: string /** Other schedules the user also holds now. */ otherSchedules: number - until: string + /** The handoff as the pill's row says it, and what that leaves for the tooltip. */ + until: OnCallUntil | null }> +/** + * A handoff on the viewer's clock. `short` is "Mon 09:00", or "23 Nov, 09:00" once it is more than + * six days out (a weekday alone would be ambiguous); `rest` is what `short` leaves out: the zone, + * and the date when `short` has none. + */ +export type OnCallUntil = Readonly<{ short: string; rest: string }> + const untilFormatters = new Map() -/** "Mon 16 Nov, 09:00 CET" on the viewer's clock, naming the zone; built from parts, as ICU builds differ in the joiner. */ -export const formatUntil = (ms: number, timeZone: string): string => { +const SIX_DAYS_MS = 6 * 24 * 60 * 60_000 + +/** Built from parts, as ICU builds differ in how they join a date and a time. */ +export const formatUntil = (ms: number, timeZone: string, nowMs: number): OnCallUntil => { const cached = untilFormatters.get(timeZone) const formatter = cached ?? @@ -56,7 +66,11 @@ export const formatUntil = (ms: number, timeZone: string): string => { Arr.findFirst(parts, (candidate) => candidate.type === type), { onNone: () => "", onSome: (found) => found.value }, ) - return `${part("weekday")} ${part("day")} ${part("month")}, ${part("hour")}:${part("minute")} ${part("timeZoneName")}` + const time = `${part("hour")}:${part("minute")}` + const date = `${part("day")} ${part("month")}` + return ms - nowMs > SIX_DAYS_MS + ? { short: `${date}, ${time}`, rest: part("timeZoneName") } + : { short: `${part("weekday")} ${time}`, rest: `${part("timeZoneName")}, ${date}` } } const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) @@ -82,9 +96,7 @@ export const onCallStatusOf = (snapshot: OnCallSnapshot, nowMs: number): OnCallS scheduleName: shift.scheduleName, otherSchedules: live.length - 1, until: - shift.endsAtMs === null - ? "further notice" - : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone), + shift.endsAtMs === null ? null : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone, nowMs), }), }) } @@ -145,23 +157,34 @@ export const isNoSchedulesFresh = (checkedAtMs: number | undefined, nowMs: numbe // COPY -/** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ +/** The schedules the user holds: "Checkout primary", "Checkout primary +1 more". */ export const scheduleLabel = (status: Extract): string => - status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` + status.otherSchedules === 0 + ? status.scheduleName + : `${status.scheduleName} +${status.otherSchedules} more` -/** The pill's full sentence: the collapsed rail's tooltip and accessible name. */ -export const onCallSentence = (status: OnCallStatus): string => { +/** The pill's one row: "On call until Mon 09:00", "Not on call". */ +export const onCallLabel = (status: OnCallStatus): string => { switch (status._tag) { case "OnCall": - return `You're on call until ${status.until} (${scheduleLabel(status)})` + return status.until === null ? "On call" : `On call until ${status.until.short}` case "NotOnCall": - return "You're not on call" + return "Not on call" case "NoSchedules": case "Unknown": return "" } } +/** + * The pill's tooltip and accessible name: the row's words first (a spoken "click On call until + * Mon 09:00" still finds it), then the zone, the date and the schedules. + */ +export const onCallDetail = (status: OnCallStatus): string => + status._tag === "OnCall" + ? `${onCallLabel(status)}${status.until === null ? "" : ` ${status.until.rest}`} · ${scheduleLabel(status)}` + : onCallLabel(status) + /** The pill shows for these. */ export const isPillShown = (status: OnCallStatus): boolean => status._tag === "OnCall" || status._tag === "NotOnCall" diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 749a57a46f..90d8ca7ff4 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -134,13 +134,17 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c ) } -/** `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. */ +/** + * `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. In a + * folded group the current page's row stands alone, highlighted, without its children. + */ const navRow = ( h: HtmlBuilder, model: Model, item: NavItem, currentPath: string, tip: RailTip, + isGroupFolded = false, ): Html => { const isActive = isNavItemActive(currentPath, item) const isCollapsed = !model.isSidebarOpen && !model.isMobile @@ -150,7 +154,9 @@ const navRow = ( : partitionSubItems(item.subItems, surfacesOf(model), currentPath).shown if (isCollapsed && subItems !== undefined && subItems.length > 0) return collapsedSectionRow(h, model, item, isActive) - const isOpen = subItems !== undefined && subItems.length > 0 && isActive + const isOpen = subItems !== undefined && subItems.length > 0 && isActive && !isGroupFolded + // The tint belongs to the sub-item that is the current page, when one is. + const hasActiveChild = isOpen && activeSubHrefOf(subItems, currentPath) !== undefined const preview = isOpen || subItems === undefined ? undefined : navPreview(item, subItems) return sidebarMenuItem(h, undefined, [ sidebarMenuButton( @@ -158,6 +164,7 @@ const navRow = ( { rail: isOpen ? "lane" : "active", isActive, + hasActiveChild, link: { href: item.href, currentPath }, hasTooltip: true, attributes: tip(item.title), @@ -199,7 +206,7 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && !isGroupFolded ? [subList(h, subItems, currentPath)] : []), ]) } @@ -281,7 +288,7 @@ export type NavGroupFold = "unlabelled" | "rail" | "expanded" | "collapsed" /** * How a group's rows show. The collapsed rail hides labels, so every row shows there; elsewhere a - * labelled group folds to the rows of the current page, if it holds it. + * labelled group folds to the current page's row (without its sub-items), if it holds it. */ export const navGroupFold = (model: Model, group: NavGroup): NavGroupFold => { if (group.label === undefined) return "unlabelled" @@ -326,7 +333,9 @@ export const navGroupSections = ( sidebarMenu( h, undefined, - Arr.map(rows, (item) => navRow(h, model, item, currentPath, tip)), + Arr.map(rows, (item) => + navRow(h, model, item, currentPath, tip, fold === "collapsed"), + ), ), ], [h.Id(navGroupContentId(group))], diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 403da11b4e..b2e4ef78f6 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -26,8 +26,9 @@ import { typography } from "@maple/views/ui/typography" import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" +import { tooltipTrigger } from "@maple/views/ui/tooltip-host" import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" -import { isPillShown, type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" +import { isPillShown, onCallDetail, onCallLabel, type OnCallStatus, onCallStatusOf } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -255,8 +256,9 @@ export const shellOnCallStatus = (model: Model): OnCallStatus => }) /** - * "You're on call until …" with the schedule, or "You're not on call" with a way to the schedules; - * a dot with the sentence as its tooltip on the collapsed rail. + * One 32px footer row: a status dot and "On call until Mon 09:00" (or a muted "Not on call"), + * linking to the schedules. The zone, the date and the schedule names are its tooltip and + * accessible name. On the collapsed rail it is a calendar glyph, badged with the dot. */ const onCallPill = ( h: HtmlBuilder, @@ -264,72 +266,48 @@ const onCallPill = ( status: OnCallStatus, tip: RailTip, isRail: boolean, -): ReadonlyArray => - !isPillShown(status) - ? [] - : [ - sidebarMenu(h, undefined, [ - sidebarMenuItem(h, undefined, [ - sidebarMenuButton( - h, - { - size: "lg", - className: - "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", - link: { href: oncallTabHref(), currentPath }, - hasTooltip: true, - // The rail hides the lines, so the link names itself there. - attributes: [ - ...tip(onCallSentence(status)), - ...(isRail ? [h.AriaLabel(onCallSentence(status))] : []), - ], - }, - [ - hstack( - h, - { align: "center", justify: "center", className: "size-4 shrink-0" }, - [ - statusDot(h, { - tone: status._tag === "OnCall" ? "ok" : "neutral", - size: "lg", - }), - ], - ), - // Three short lines: the full sentence does not fit the rail's width. - identityLabel( - h, - status._tag === "OnCall" - ? [ - typography( - h, - { size: "xs", weight: "medium", truncate: true }, - ["You're on call"], - ), - typography(h, { size: "xs", truncate: true }, [ - `until ${status.until}`, - ]), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - scheduleLabel(status), - ]), - ] - : [ - typography( - h, - { size: "xs", weight: "medium", truncate: true }, - ["You're not on call"], - ), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - "My schedules", - ]), - ], - // The rail shows the dot; the tooltip says the sentence. - "group-data-[collapsible=icon]:hidden", - ), - ], - ), - ]), - ]), - ] +): ReadonlyArray => { + if (!isPillShown(status)) return [] + const label = onCallLabel(status) + const detail = onCallDetail(status) + const isOnCall = status._tag === "OnCall" + const dot = (className?: string) => + statusDot(h, { tone: isOnCall ? "ok" : "neutral", size: "lg", className }) + const glyphBox = (className: string, children: ReadonlyArray) => + hstack(h, { align: "center", justify: "center", className }, children) + return [ + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + className: isOnCall ? undefined : "text-muted-foreground", + link: { href: oncallTabHref(), currentPath }, + hasTooltip: true, + attributes: [ + // The rail's tooltip names the glyph; the open sidebar's adds what the row leaves out. + ...(isRail + ? tip(detail) + : detail === label + ? [] + : tooltipTrigger(h, detail, { side: "right" })), + ...(isRail || detail !== label ? [h.AriaLabel(detail)] : []), + ], + }, + isRail + ? [ + glyphBox("relative size-4 shrink-0", [ + icon(h, "CalendarIcon", { className: "size-4" }), + // On call only: a grey badge on a grey glyph says nothing. + ...(isOnCall + ? [dot("absolute -top-0.5 -right-0.5 ring-2 ring-sidebar")] + : []), + ]), + ] + : [glyphBox("size-4 shrink-0", [dot()]), h.span([], [label])], + ), + ]), + ] +} /** The user avatar: the Clerk image over the initials on a warm tile keyed by the name. */ export const userAvatar = ( @@ -425,8 +403,15 @@ export const shellSidebarFooter = ( ], [], ), - ...onCallPill(h, currentPath, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), sidebarMenu(h, undefined, [ + // A row of the footer's list, so it keeps the list's 4px rhythm. + ...onCallPill( + h, + currentPath, + shellOnCallStatus(model), + tip, + !model.isSidebarOpen && !model.isMobile, + ), footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. footerNavRow( diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index ae898eb3eb..74ecf10a8c 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -30,7 +30,7 @@ import { shellSidebarFooter, shellSidebarHeader, } from "./view-sidebar" -import { isPillShown, onCallSentence } from "./oncall" +import { isPillShown, onCallDetail } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -67,7 +67,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), - ...(isPillShown(onCall) ? [onCallSentence(onCall)] : []), + ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), "Sources", "Settings", ] diff --git a/packages/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index 189d81c8c8..e7c9e4dc91 100644 --- a/packages/ui/src/components/ui/sidebar.styles.ts +++ b/packages/ui/src/components/ui/sidebar.styles.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority" export const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -14,8 +14,9 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { + // Hover is a neutral wash; the accent tint is the selected row's alone. default: - "hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", + "hover:bg-surface-sunken active:bg-surface-sunken hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", outline: "bg-background ring-1 ring-sidebar-border hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground hover:ring-sidebar-accent active:ring-sidebar-accent", }, @@ -154,7 +155,8 @@ export const sidebarMenuSubStyles = [ export const sidebarMenuSubItemStyles = "group/menu-sub-item relative" export const sidebarMenuSubButtonStyles = [ - "flex h-8 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 sm:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + // 36px in the mobile sheet (below `md`, a touch target), 28px on the desktop rail. + "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ] as const diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 528a4fd6cd..42bdbeb995 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -229,7 +229,7 @@ export type SidebarGroupToggleOptions = Readonly<{ onToggle: Message }> -/** A group label that collapses its rows: a chevron button with `aria-expanded`. */ +/** A group label that collapses its rows: a 28px button with `aria-expanded` and a faint chevron. */ export const sidebarGroupToggle = ( h: HtmlBuilder, options: SidebarGroupToggleOptions, @@ -240,7 +240,7 @@ export const sidebarGroupToggle = ( h.Class( cn( ...sidebarGroupLabelStyles, - "group/toggle h-6 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", + "group/toggle h-7 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", ), ), h.DataAttribute("sidebar", "group-label"), @@ -253,9 +253,10 @@ export const sidebarGroupToggle = ( h.span([h.Class("flex-1 truncate")], [options.label]), icon(h, "ChevronDownIcon", { size: 12, + // Faint at rest so folding is discoverable, full tone on hover and focus. className: cn( - "opacity-0 transition-[opacity,rotate] duration-150 group-hover/toggle:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none", - options.isExpanded ? "" : "-rotate-90 opacity-100", + "text-foreground-faint transition-[rotate] duration-150 group-hover/toggle:text-sidebar-foreground group-focus-visible/toggle:text-sidebar-foreground motion-reduce:transition-none", + options.isExpanded ? "" : "-rotate-90", ), }), ], @@ -364,6 +365,11 @@ export type SidebarMenuButtonOptions = Readonly<{ size?: "default" | "lg" | "sm" className?: string isActive?: boolean + /** + * A sub-item of this row is the current page: the row keeps its medium weight and leaves the + * tint to that sub-item (`data-active="child"`). + */ + hasActiveChild?: boolean /** Rendered as a router link instead of a button. */ link?: LinkTarget /** `render={}`: a plain anchor, no router active state (apps/local-ui's hash links). */ @@ -400,7 +406,10 @@ export const sidebarMenuButton = ( ) const attributes: Array | ChildAttribute> = [ h.Class(linkActive ? `${className} active` : className), - h.DataAttribute("active", String(options.isActive ?? false)), + h.DataAttribute( + "active", + options.hasActiveChild === true ? "child" : String(options.isActive ?? false), + ), ...(options.hasTooltip === true ? [h.DataAttribute("base-ui-tooltip-trigger", "")] : []), h.DataAttribute("sidebar", "menu-button"), h.DataAttribute("size", size), From 3c3e99600c1d707754fb64de22094e8feae31d8e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:23:06 +0100 Subject: [PATCH 14/35] fix(alerts): one primary action per section, chips and viewer time in the silence panel - Each section's header has one primary action: New rule (split) on the Inbox and Rules, New monitor, New SLO, New silence, New window, New schedule and Add destination on their own sections. A section whose empty state already offers it shows none until it has rows. - The Alerts sub-list sets its groups apart with a 4px gap instead of rules, and its rows take the kit's height again. - The silence panel's Services, Environments and Tags are chips inputs that suggest what the org's rules name (and the services Uptime listed); a typed name can still be added. Its labels share one style. - A picked end is on the viewer's clock and the field names the zone ("Ends, CET"); a scheduled row names its day once. The lab reads the viewer's zone instead of UTC. - The duration choice uses the larger segmented size. --- apps/web/src/lab/alerts-hub-lab.ts | 114 ++++++--- apps/web/src/lab/registry.ts | 2 +- apps/web/src/page/alerts/hub/derive.ts | 30 ++- .../alerts/hub/hub-silences.scene.test.ts | 82 ++++++- .../page/alerts/hub/hub-slos.scene.test.ts | 5 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 25 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 42 ++++ apps/web/src/page/alerts/hub/view-oncall.ts | 7 - apps/web/src/page/alerts/hub/view-silences.ts | 4 +- apps/web/src/page/alerts/hub/view.ts | 114 ++++----- .../src/page/alerts/shared/matcher-fields.ts | 25 ++ .../src/page/alerts/shared/silences.test.ts | 29 ++- apps/web/src/page/alerts/shared/silences.ts | 14 ++ .../page/alerts/silence-dialog/dialog.test.ts | 109 ++++++++- .../src/page/alerts/silence-dialog/dialog.ts | 79 +++++- .../src/page/alerts/silence-dialog/view.ts | 227 ++++++++++++------ apps/web/src/page/kit/timezone-zones.ts | 31 +++ apps/web/src/shell/nav.ts | 2 +- apps/web/src/shell/shell.scene.test.ts | 3 +- apps/web/src/shell/view-nav.ts | 98 ++++---- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/segmented-select.ts | 3 +- 22 files changed, 765 insertions(+), 282 deletions(-) diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index e87756b96b..a1a60d4719 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -6,6 +6,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 { zonedDateParts } from "@maple/query-engine/datetime" +import * as Combobox from "@maple/views/ui/combobox" import * as HubMessage from "../page/alerts/hub/message" import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" @@ -15,11 +17,13 @@ import { alertsHref } from "../page/alerts/shared/links" import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" // `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. The shell -// stands on the section's own path, and `?sheet=` is played here rather than pushed. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom, +// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here +// rather than pushed, and times read on the viewer's own clock, as in the app. export const LabState = Schema.Literals([ "inbox", @@ -29,20 +33,22 @@ export const LabState = Schema.Literals([ "silences", "silence-dialog", "silence-dialog-custom", + "silence-dialog-filled", ]) export type LabState = typeof LabState.Type -export const Flags = Schema.Struct({ state: LabState }) +export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String }) export type Flags = typeof Flags.Type -export const flags: Effect.Effect = Effect.sync(() => ({ +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ state: Option.getOrElse( Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), (): LabState => "inbox", ), + timeZone, })) -export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model }) +export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } }) @@ -50,11 +56,8 @@ export type Message = typeof Message.Type type Return = Update.Return -const CONTEXT: PageContext = { - orgId: Schema.decodeSync(OrgId)(ORG), - pinnedNamespace: null, - timeZone: "UTC", -} +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) const GRANTS = Policy.grantsOf(["org:engineer"]) /** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ @@ -131,7 +134,10 @@ const SILENCES = [ silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, { services: ["checkout"], }), - silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }), + // Tomorrow: its row names the day. + silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, { + tags: ["db"], + }), // Cancelled before it started: the API collapses its window to one instant. silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, { environments: ["staging"], @@ -148,23 +154,24 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const routeTo = (model: HubModel.Model, sheet: string | undefined) => - HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context) /** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ -const step = (model: HubModel.Model, message: HubMessage.Message) => { - const next = HubUpdate.update(model, message, CONTEXT) +const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => { + const next = HubUpdate.update(model, message, context) const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) if (asked._tag === "None") return next const routed = routeTo( next.model, asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + context, ) return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } } -const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), CONTEXT).model +const hubFor = (state: LabState, context: PageContext): HubModel.Model => { + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -184,38 +191,71 @@ const hubFor = (state: LabState): HubModel.Model => { result: Result.succeed(SILENCES), }), ] - return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model) + return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model) +} + +const pad = (value: number): string => Str.padStart(2, "0")(String(value)) + +/** `ms` as a `datetime-local` value on the viewer's clock. */ +const localInput = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` } +const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message }) +const chip = (field: SilenceDialog.ScopeField, value: string) => + dialog( + SilenceDialog.Message.GotScopeComboMessage({ + field, + message: Combobox.Message.ClickedItem({ value }), + }), + ) + +/** What each silence-panel state does once the panel is open. */ +const panelSteps = (state: LabState, timeZone: string): ReadonlyArray => + state === "silence-dialog-custom" + ? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))] + : state === "silence-dialog-filled" + ? [ + chip("services", "checkout"), + chip("services", "payment-svc"), + chip("environments", "production"), + dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + dialog( + SilenceDialog.Message.ChangedUntil({ + value: localInput(NOW + 26 * 60 * MINUTE, timeZone), + }), + ), + dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ] + : [] + const toLab = ( next: Readonly<{ commands?: ReadonlyArray> }>, ) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message })) export const init = (flags: Flags): Return => { - const hub = hubFor(flags.state) - if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom") - return { model: { state: flags.state, hub } } + const context = contextOf(flags.timeZone) + const hub = hubFor(flags.state, context) + const lab = { state: flags.state, timeZone: flags.timeZone } + if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } } // The dialog opens through its own commands, so they run here rather than in the fold. - const opened = step(hub, HubMessage.Message.ClickedNewSilence()) - const picked = - flags.state === "silence-dialog-custom" - ? step( - opened.model, - HubMessage.Message.GotSilenceDialogMessage({ - message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }), - }), - ) - : { model: opened.model, commands: [] } - return { - model: { state: flags.state, hub: picked.model }, - commands: [...toLab(opened), ...toLab(picked)], - } + const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context) + const filled = Arr.reduce( + panelSteps(flags.state, flags.timeZone), + { model: opened.model, commands: toLab(opened) }, + (current, message) => { + const next = step(current.model, message, context) + return { model: next.model, commands: [...current.commands, ...toLab(next)] } + }, + ) + return { model: { ...lab, hub: filled.model }, commands: filled.commands } } export const update = (model: Model, message: Message): Return => Message.match(message, { GotHubMessage: ({ message: child }) => { - const next = step(model.hub, child) + const next = step(model.hub, child, contextOf(model.timeZone)) return { model: { ...model, hub: next.model }, commands: toLab(next) } }, }) @@ -230,6 +270,6 @@ export const view = (model: Model, h: HtmlBuilder): Html => slotId: "lab-alerts-hub", model: model.hub, view: hubView, - viewInputs: { context: CONTEXT }, + viewInputs: { context: contextOf(model.timeZone) }, toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 843e1bdbb2..5059ffb8f8 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel empty, picking a time and filled in (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index e9f48fefbb..669786b354 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -6,11 +6,12 @@ import { rowsToAlertIncidentDocuments, rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" +import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" +import type { ScopeSuggestions } from "../silence-dialog/dialog" type OverviewKey = readonly [ Model["rules"], @@ -112,6 +113,33 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) +const sortedNames = (names: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter(names, Str.isNonEmpty)), Order.String) + +const silenceSuggestionsFor = lastBy( + ([rules, namespaces]: readonly [ + Model["rules"], + Model["serviceNamespaces"]["data"], + ]): ScopeSuggestions => { + const documents = rowsToAlertRuleDocuments(rules ?? [], buildRuleStatesByRuleId([])) + return { + services: sortedNames([ + ...Arr.flatMap(documents, (rule) => rule.serviceNames), + ...Option.match(AsyncData.getData(namespaces), { onNone: () => [], onSome: Rec.keys }), + ]), + environments: sortedNames(Arr.flatMap(documents, (rule) => rule.environments)), + tags: sortedNames(Arr.flatMap(documents, (rule) => rule.tags)), + } + }, +) + +/** + * What the silence panel suggests, from what the hub already holds: the services, environments and + * tags its rules name, and the services the Uptime section listed if it was opened. + */ +export const silenceSuggestionsOf = (model: Model): ScopeSuggestions => + silenceSuggestionsFor([model.rules, model.serviceNamespaces.data]) + /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { switch (status) { diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 395a2fcd02..e0b5755d6f 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -3,12 +3,14 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG, hubAt } from "../shared/test-rows" +import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" +import { silenceSuggestionsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" @@ -193,6 +195,8 @@ const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args? const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => Arr.map(commands ?? [], ({ name }) => name) const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) +const scopeCombo = (field: SilenceDialog.ScopeField, message: Combobox.Message) => + silenceMessage(SilenceDialog.Message.GotScopeComboMessage({ field, message })) /** The Silences tab with `?sheet=silence` followed: the panel is open. */ const withPanel = (grants = ENGINEER) => @@ -208,7 +212,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with typed scope fields, Cancel and a save that waits for a valid form", () => { + test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { const opened = withPanel() expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( @@ -216,12 +220,80 @@ describe("alerts hub, silence side panel", () => { given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), - expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Environments" })).toExist(), + expectView(role("combobox", { name: "Tags" })).toExist(), + expectView( + text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), + ).toExist(), expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), ) }) + test("the chips inputs suggest what the org's rules name, and a picked one becomes a chip", () => { + const withRules = pageUpdate( + withPanel().model, + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(1, { service_names_json: ["checkout"], tags_json: ["db"] }), + ruleRow(2, { + service_names_json: ["payments", "checkout"], + environments_json: ["staging"], + }), + ], + }), + ).model + expect(silenceSuggestionsOf(withRules)).toEqual({ + services: ["checkout", "payments"], + environments: ["production", "staging"], + tags: ["db"], + }) + const listed = pageUpdate(withRules, scopeCombo("services", Combobox.Message.ClickedInput())).model + scene( + { update: pageUpdate, view }, + given(listed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "checkout" })).toExist(), + expectView(role("option", { name: "payments" })).toExist(), + ) + const picked = pageUpdate( + listed, + scopeCombo("services", Combobox.Message.ClickedItem({ value: "payments" })), + ).model + scene( + { update: pageUpdate, view }, + given(picked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("button", { name: "Remove payments" })).toExist(), + ) + }) + + test("a picked end is on the viewer's clock, and the field names the zone", () => { + const berlin: PageContext = { ...context, timeZone: "Europe/Berlin" } + const opened = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "silence" }), + berlin, + ).model + const custom = update( + opened, + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + berlin, + ).model + scene( + { update: (model: Model, message: Message) => update(model, message, berlin), view }, + given(custom), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + // NOW is 1 October: Berlin is on summer time. + expectView(text("Ends, CEST")).toExist(), + expectView(text("Your local time. At most 30 days from now.")).toExist(), + ) + }) + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model expect(entered.silenceDialog.dialog.isOpen).toBe(false) @@ -306,9 +378,7 @@ describe("alerts hub, silence side panel", () => { test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { const filled = Arr.reduce( [ - silenceMessage( - SilenceDialog.Message.ChangedScopeField({ field: "services", value: "checkout" }), - ), + scopeCombo("services", Combobox.Message.ClickedItem({ value: "checkout" })), silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), ], withPanel().model, diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index 6526d14694..84d833a2dc 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -200,18 +200,19 @@ describe("alerts hub, SLOs tab", () => { expect(names(pageUpdate(failed, Message.ClickedRetrySlos()).commands)).toEqual(["FetchAlertsHubSlos"]) }) - test("the header's main create button makes an SLO here, for admins only, and the menu offers it too", () => { + test("the header's one primary action is New SLO here, for admins only, and the Inbox's menu offers it too", () => { scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING)), expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING, onSlos(MEMBER))), expectView(role("link", { name: "New SLO" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New rule" })).not.toExist(), ) const picked = pageUpdate( withSlos(HEALTHY_AND_BURNING), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index 5694c615e2..83ffcfbd3b 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -320,21 +320,29 @@ describe("alerts hub, Uptime tab", () => { expect(done.model.deletingMonitorId).toBeNull() }) - test("the header's main create button makes an uptime check here, for admins only", () => { + test("the header's one primary action is New monitor here, for admins only", () => { scene( { update: pageUpdate, view }, given(withMonitors(THREE)), - expectView(role("link", { name: "New check" })).toHaveAttr("href", "/alerts/uptime/new"), + expectView(role("link", { name: "New monitor" })).toHaveAttr("href", "/alerts/uptime/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, - given({ ...withMonitors(THREE), search: { tab: "uptime", serviceName: "checkout-api" } }), - expectView(role("link", { name: "New check" })).toHaveAttr( + given({ ...withMonitors(THREE), search: { serviceName: "checkout-api" } }), + expectView(role("link", { name: "New monitor" })).toHaveAttr( "href", "/alerts/uptime/new?serviceName=checkout-api", ), ) + // The empty state offers the first check itself, so the header stays empty. + scene( + { update: pageUpdate, view }, + given(withMonitors([])), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New uptime check" })).toExist(), + ) const asMember = pageUpdate( withMonitors(THREE), Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER }), @@ -342,15 +350,16 @@ describe("alerts hub, Uptime tab", () => { scene( { update: pageUpdate, view }, given(asMember), - expectView(role("link", { name: "New check" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New rule" })).not.toExist(), ) }) - test("the New rule chevron opens both kinds, and Uptime check goes to the uptime form", () => { + test("on the Inbox the New rule chevron opens every kind, and Uptime check goes to the uptime form", () => { scene( { update: pageUpdate, view }, - given(withMonitors(THREE)), + given({ ...withMonitors(THREE), tab: "inbox" as const }), + expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), click(role("button", { name: "More rule types" })), Command.resolve( Animation.WaitForPaint, diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 9e44f27e3d..5d0c3508cb 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -138,6 +138,48 @@ describe("alerts hub", () => { ) }) + test("each section's header has one primary action: its own, and New rule only on the Inbox and Rules", () => { + const newRule = role("link", { name: "New rule" }) + const moreKinds = role("button", { name: "More rule types" }) + for (const tab of ["inbox", "rules"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).toHaveAttr("href", "/alerts/create"), + expectView(moreKinds).toExist(), + ) + } + for (const tab of ["uptime", "slos", "silences", "maintenance", "on-call", "routing"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).not.toExist(), + expectView(moreKinds).not.toExist(), + ) + } + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "silences" })), + expectView(role("button", { name: "New silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "routing" })), + expectView(role("button", { name: "Add destination" })).toExist(), + ) + // A viewer who can't create a section's thing gets no header action there. + const asMember = pageUpdate( + loaded({ tab: "silences" }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:member"]) }), + ).model + scene( + { update: pageUpdate, view }, + given(asMember), + expectView(role("button", { name: "New silence" })).not.toExist(), + expectView(newRule).not.toExist(), + ) + }) + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { const inbox = loaded() expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 7aad64110e..13c16cc1b9 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -284,10 +284,3 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { 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-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 61ba57ff02..b4fae5ff48 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -18,6 +18,7 @@ import { sectionHeading } from "../../kit/section-heading" import { windowIdOfParam } from "../shared/maintenance-windows" import { endsAtMs, + formatSilenceRange, formatSilenceTime, isCancelledSilence, silenceScopeParts, @@ -51,12 +52,11 @@ const silencesEmptyState = (h: H): Html => const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { const status = silenceStatusAt(silence, nowMs) - const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) return status === "active" ? `Until ${end}` : status === "scheduled" - ? `${start} to ${end}` + ? formatSilenceRange(startsAtMs(silence), endsAtMs(silence), nowMs, timeZone) : isCancelledSilence(silence) ? `Cancelled ${end}` : `Ended ${end}` diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index e9e8dd6843..45ca0d6631 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -18,6 +18,7 @@ import { dashboardPage } from "../../kit/dashboard-page" import type { BreadcrumbEntry } from "../../../shell" import type { AlertsTab } from "../../../route/search" import { alertsHref, createHref } from "../shared/links" +import { newScheduleHref } from "../shared/oncall-schedules" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { @@ -28,6 +29,7 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, + silenceSuggestionsOf, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -46,13 +48,7 @@ import * as WindowDialogView from "../maintenance-dialog/view" 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 { deleteScheduleDialog, hasSchedules, oncallTab, whoIsOnCallStrip } from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -99,26 +95,18 @@ const newRuleItem = ( ], }) -/** The split create button: its main part makes the section's own kind (an uptime check on Uptime, an SLO - * on SLOs, else a telemetry rule), the chevron offers every kind. */ -const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { +/** The Inbox's and Rules' split create button: a telemetry rule, and behind the chevron every kind. */ +const newRuleButton = (h: H, model: Model): Html => { const serviceName = model.search.serviceName - // Uptime checks and SLOs are admin-only to create; members keep the telemetry rule. - const lead = - canManage(model) && tab === "uptime" - ? { href: newMonitorHref({ serviceName }), label: "New check" } - : canManage(model) && tab === "slos" - ? { href: newSloHref({ serviceName }), label: "New SLO" } - : { href: createHref({ serviceName }), label: "New rule" } return hstack(h, { align: "center" }, [ linkButton( h, { size: "sm", - href: lead.href, + href: createHref({ serviceName }), className: splitButtonClass("lead"), }, - [icon(h, "PlusIcon"), lead.label], + [icon(h, "PlusIcon"), "New rule"], ), h.submodel({ slotId: model.newRuleMenu.id, @@ -183,52 +171,46 @@ const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { ]) } -// Destinations are a Routing concern, so "New destination" only shows on that tab. -const headerActions = (h: H, model: Model, tab: AlertsTab): Html => - hstack(h, { gap: "2" }, [ - ...(canManage(model) && tab === "silences" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewSilence())], - }, - [icon(h, "BellSlashIcon"), "New silence"], - ), - ] - : []), - ...(canManage(model) && tab === "maintenance" && hasWindows(model) - ? [ - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.nowMs === null, - attributes: [h.OnClick(Message.ClickedNewWindow())], - }, - [icon(h, "CalendarIcon"), "New window"], - ), - ] - : []), - ...(canManage(model) && tab === "on-call" && hasSchedules(model) ? [newScheduleButton(h)] : []), - ...(canManage(model) && tab === "routing" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewDestination())], - }, - [icon(h, "PlusIcon"), "New destination"], - ), - ] - : []), - newRuleButton(h, model, tab), - ]) +const hasRows = (rows: Option.Option>): boolean => + Option.exists(rows, (list) => list.length > 0) + +const primaryLink = (h: H, href: string, label: string): Html => + linkButton(h, { size: "sm", href }, [icon(h, "PlusIcon"), label]) + +const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => + button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) + +/** + * The header's one primary action: what the open section lists. A section whose empty state + * already offers it shows none until it has rows, and a viewer who can't create it sees none. + */ +const headerActions = (h: H, model: Model, tab: AlertsTab): Html | undefined => { + if (tab === "inbox" || tab === "rules") return newRuleButton(h, model) + if (!canManage(model)) return undefined + const serviceName = model.search.serviceName + switch (tab) { + case "uptime": + return hasRows(AsyncData.getData(model.monitors.data)) + ? primaryLink(h, newMonitorHref({ serviceName }), "New monitor") + : undefined + case "slos": + return hasRows(AsyncData.getData(model.slos.data)) + ? primaryLink(h, newSloHref({ serviceName }), "New SLO") + : undefined + case "silences": + return primaryButton(h, Message.ClickedNewSilence(), "New silence") + case "maintenance": + return hasWindows(model) + ? primaryButton(h, Message.ClickedNewWindow(), "New window", model.nowMs === null) + : undefined + case "on-call": + return hasSchedules(model) ? primaryLink(h, newScheduleHref(), "New schedule") : undefined + case "routing": + return destinationsOf(model).length > 0 + ? primaryButton(h, Message.ClickedNewDestination(), "Add destination") + : undefined + } +} /** `SyncUnavailable`: the rules or incidents stream stopped before its first answer. */ const SYNC_UNAVAILABLE = makeClientErrorBody({ @@ -404,7 +386,7 @@ export const view = Submodel.defineView((model, slotId: "alerts-silence", model: model.silenceDialog, view: SilenceDialogView.view, - viewInputs: { id: "alerts-silence" }, + viewInputs: { id: "alerts-silence", suggestions: silenceSuggestionsOf(model) }, toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index 44e0a356f2..bcc96dc098 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -3,6 +3,7 @@ import { Option, Schema, Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" import type { FieldState } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -59,6 +60,30 @@ const stateOf = (value: string, isInvalid = false): FieldState => ({ isTouched: false, }) +/** A dialog field's label for a control the text field doesn't draw (chips, a segmented choice): the same look. */ +export const dialogLabel = ( + h: HtmlBuilder, + options: Readonly<{ id: string; htmlFor?: string }>, + text: string, +): Html => + label( + h, + { + className: "text-xs", + attributes: [ + h.Id(options.id), + ...(options.htmlFor === undefined ? [] : [h.For(options.htmlFor)]), + ], + }, + [text], + ) + +/** A line of help under a group of dialog fields, in the face a field's own description uses. */ +export const dialogHelp = (h: HtmlBuilder, id: string, text: string): Html => + fieldDescription(h, { ids: { control: id, label: id, error: id, description: id }, state: stateOf("") }, [ + text, + ]) + export type DialogTextFieldOptions = Readonly<{ idPrefix: string name: string diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1d0e7e8f89..c482cf734a 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { Option, Schema } from "effect" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { activeSilenceFor, matchersForRule } from "./silences" +import { activeSilenceFor, formatSilenceRange, formatSilenceTime, matchersForRule } from "./silences" const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") const asSilenceId = Schema.decodeSync(AlertSilenceId) @@ -39,6 +39,33 @@ const groupedRule = { queryBuilderDraft: null, } +describe("silence times", () => { + const BERLIN = "Europe/Berlin" + + it("reads on the viewer's clock, and names the day when it is not today there", () => { + // NOW is 13:00 in Berlin. + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 0), NOW, BERLIN)).toBe("03:00 PM") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN)).toBe("Nov 7, 09:00 AM") + // 23:30 UTC is still today in UTC, and already tomorrow in Berlin. + const late = Date.UTC(2026, 10, 6, 23, 30) + expect(formatSilenceTime(late, NOW, "UTC")).toBe("11:30 PM") + expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") + }) + + it("names the day of a window once when both ends share it", () => { + const start = Date.UTC(2026, 10, 7, 14, 0) + expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to 04:00 PM", + ) + expect(formatSilenceRange(start, start + 24 * 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to Nov 8, 03:00 PM", + ) + expect(formatSilenceRange(NOW + 60 * 60_000, NOW + 2 * 60 * 60_000, NOW, BERLIN)).toBe( + "02:00 PM to 03:00 PM", + ) + }) +}) + describe("activeSilenceFor", () => { it("picks the latest-ending silence in force, from the window not the stored status", () => { const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 8db968ee93..5a5be70bf7 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -78,6 +78,20 @@ export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` } +/** "15:00 to 16:00" today, "Nov 7, 15:00 to 16:00" on another day: the day is named once when both ends share it. */ +export const formatSilenceRange = ( + startMs: number, + endMs: number, + nowMs: number, + timeZone: string, +): string => { + const isOneDay = formatDateInTimezone(startMs, { timeZone }) === formatDateInTimezone(endMs, { timeZone }) + const end = isOneDay + ? formatTimeInTimezone(endMs, { timeZone, withSeconds: false }) + : formatSilenceTime(endMs, nowMs, timeZone) + return `${formatSilenceTime(startMs, nowMs, timeZone)} to ${end}` +} + /** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 8e8c49ab56..3b62de3bad 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -2,13 +2,19 @@ import { describe, expect, it } from "vitest" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import { + endZoneLabel, init, isFormReady, matchersOf, Message, + type Model, + openFor, openNew, OutMessage, + parseLocalDateTime, + type ScopeField, silenceEndMs, untilError, update, @@ -36,6 +42,41 @@ describe("silenceEndMs", () => { ) }) + it("reads a custom end on the viewer's clock on either side of a DST change", () => { + // Berlin springs forward on 2026-03-29: 09:00 the day before is CET, 09:00 that day is CEST. + const before = Date.UTC(2026, 2, 28, 12, 0) + expect(parseLocalDateTime("2026-03-28T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 28, 8, 0)), + ) + expect(parseLocalDateTime("2026-03-29T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + expect(silenceEndMs("custom", "2026-03-29T09:00", before, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + // New York falls back on 2026-11-01: 12:00 that day is EST, five hours behind UTC. + expect(parseLocalDateTime("2026-10-31T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 9, 31, 16, 0)), + ) + expect(parseLocalDateTime("2026-11-01T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 10, 1, 17, 0)), + ) + // The same wall clock read as UTC is a different instant: the form never assumes UTC. + expect(parseLocalDateTime("2026-03-29T09:00", "UTC")).toEqual( + Option.some(Date.UTC(2026, 2, 29, 9, 0)), + ) + }) + + it("names the zone the custom end is in, as it is at that end", () => { + const model = { timeZone: BERLIN, nowMs: Date.UTC(2026, 2, 28, 12, 0) } + expect(endZoneLabel({ ...model, until: "" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-28T18:00" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-29T09:00" })).toBe("CEST") + expect(endZoneLabel({ ...model, timeZone: "UTC", until: "" })).toBe("UTC") + // A zone ICU has no abbreviation for reads as its offset. + expect(endZoneLabel({ ...model, timeZone: "Asia/Kolkata", until: "" })).toBe("UTC+5:30") + }) + it("reads a custom end in the viewer's zone and refuses one in the past", () => { expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( Option.some(Date.UTC(2026, 10, 6, 17, 30)), @@ -46,20 +87,76 @@ describe("silenceEndMs", () => { }) describe("matchersOf", () => { - it("splits typed lists and drops empty fields", () => { + it("takes the chips of each field and drops the empty ones", () => { expect( matchersOf({ _tag: "Typed", - services: " checkout, payments ,checkout", - tags: "", - environments: "production", + services: ["checkout", "payments"], + tags: [], + environments: ["production"], }), ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) }) }) +describe("scope chips", () => { + const pick = (model: Model, field: ScopeField, value: string) => + update( + model, + Message.GotScopeComboMessage({ field, message: Combobox.Message.ClickedItem({ value }) }), + ).model + + it("picking a suggestion adds a chip, picking it again or removing it takes it off", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const picked = pick(pick(opened, "services", "checkout"), "environments", "production") + expect(matchersOf(picked.scope)).toEqual({ services: ["checkout"], environments: ["production"] }) + expect(matchersOf(pick(picked, "services", "checkout").scope)).toEqual({ + environments: ["production"], + }) + const removed = update( + picked, + Message.GotScopeComboMessage({ + field: "environments", + message: Combobox.Message.RemovedChip({ value: "production" }), + }), + ).model + expect(matchersOf(removed.scope)).toEqual({ services: ["checkout"] }) + }) + + it("a typed name is picked with Enter, and the next open starts without chips", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const combo = (message: Combobox.Message) => Message.GotScopeComboMessage({ field: "tags", message }) + const enter = (model: Model, value: string) => + update( + update(model, combo(Combobox.Message.ChangedInput({ value }))).model, + // The view lists the suggestions, then what is typed when it is none of them. + combo(Combobox.Message.PressedInputKey({ key: "Enter", items: ["database", value] })), + ).model + // The first suggestion that matches wins; a name nothing suggests is added as typed. + const entered = enter(enter(opened, "dat"), "queue") + expect(matchersOf(entered.scope)).toEqual({ tags: ["database", "queue"] }) + expect(entered.combos.tags.inputValue).toBe("") + expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) + }) + + it("a scope fixed by a rule ignores the chips", () => { + const fixed = openFor( + init("silence"), + { label: "Rule", matchers: { rule_ids: [] } }, + BERLIN, + NOW, + ).model + expect(pick(fixed, "services", "checkout").scope).toEqual(fixed.scope) + }) +}) + describe("isFormReady", () => { - const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + const typed = (service: string) => ({ + _tag: "Typed" as const, + services: service === "" ? [] : [service], + tags: [], + environments: [], + }) it("needs a reason and something to match", () => { const model = init("silence") @@ -106,7 +203,7 @@ describe("saving", () => { } const saving = () => { const opened = openNew(init("silence"), BERLIN, NOW).model - const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: ["checkout"] } } return update(typed, Message.ClickedSave()).model } diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 2eba7bba2e..8b69c61740 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,10 +3,12 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetim import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" -import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" +import { shortZoneName } from "../../kit/timezone-zones" +import { hasMatchers, MatcherField } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -17,16 +19,41 @@ export type SilenceDuration = typeof SilenceDuration.Type export const ScopeField = MatcherField export type ScopeField = MatcherField -/** What the silence covers: fixed by the page that opened it, or typed in (the hub's "New silence"). */ +const Chips = Schema.Array(Schema.String) + +/** What the silence covers: fixed by the page that opened it, or picked as chips (the hub's "New silence"). */ export const Scope = Schema.Union([ Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), - Schema.TaggedStruct("Typed", TypedMatchers.fields), + Schema.TaggedStruct("Typed", { services: Chips, tags: Chips, environments: Chips }), ]) export type Scope = typeof Scope.Type +/** What each chips input offers before anything is typed: the names the org's rules already use. */ +export type ScopeSuggestions = Readonly>> + +const NO_CHIPS: Extract = { + _tag: "Typed", + services: [], + tags: [], + environments: [], +} + +/** One chips input per scope field; each field's chips live on the scope. */ +const ScopeCombos = Schema.Struct({ + services: Combobox.Model, + tags: Combobox.Model, + environments: Combobox.Model, +}) + +const initCombos = (id: string): typeof ScopeCombos.Type => { + const combo = (field: ScopeField) => Combobox.init(`${id}-${field}`, "multiple", { autoHighlight: true }) + return { services: combo("services"), tags: combo("tags"), environments: combo("environments") } +} + export const Model = Schema.Struct({ dialog: Dialog.Model, scope: Scope, + combos: ScopeCombos, reason: Schema.String, duration: SilenceDuration, /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ @@ -42,7 +69,8 @@ export type Model = typeof Model.Type export const init = (id: string): Model => ({ dialog: Dialog.init(id, "sheet"), - scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + scope: NO_CHIPS, + combos: initCombos(id), reason: "", duration: "1h", until: "", @@ -57,7 +85,7 @@ export const Message = defineMessageUnion({ ChangedReason: { value: Schema.String }, ChangedDuration: { duration: SilenceDuration }, ChangedUntil: { value: Schema.String }, - ChangedScopeField: { field: ScopeField, value: Schema.String }, + GotScopeComboMessage: { field: ScopeField, message: Combobox.Message }, ClickedCancel: {}, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, @@ -122,9 +150,22 @@ export const silenceEndMs = ( } } -/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +/** The matchers the form describes; a field without chips is left out, and none mean nothing to save yet. */ export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => - scope._tag === "Fixed" ? scope.matchers : matchersFromTyped(scope) + scope._tag === "Fixed" + ? scope.matchers + : { + ...(scope.services.length === 0 ? undefined : { services: scope.services }), + ...(scope.tags.length === 0 ? undefined : { tags: scope.tags }), + ...(scope.environments.length === 0 ? undefined : { environments: scope.environments }), + } + +/** The zone the custom end is read in, as the viewer knows it at that end ("CET", then "CEST" past the change). */ +export const endZoneLabel = (model: Pick): string => + shortZoneName( + model.timeZone, + Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), + ) /** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ export const untilError = (model: Model): Option.Option => { @@ -187,6 +228,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu { ...model, scope, + combos: initCombos(model.dialog.id), timeZone, nowMs, reason: "", @@ -208,7 +250,23 @@ export const openFor = ( /** A silence the user scopes by service, tag and environment. */ export const openNew = (model: Model, timeZone: string, nowMs: number): Return => - open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) + open(model, NO_CHIPS, timeZone, nowMs) + +/** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ +const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { + if (model.scope._tag === "Fixed") return { model } + const step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + const scope = + step.outMessage?._tag === "ChangedSelection" + ? { ...model.scope, [field]: step.outMessage.selected } + : model.scope + return { + model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, + commands: Command.mapMessages(step.commands ?? [], (inner) => + Message.GotScopeComboMessage({ field, message: inner }), + ), + } +} /** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) @@ -236,10 +294,7 @@ export const update = (model: Model, message: Message): Return => ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), - ChangedScopeField: ({ field, value }) => - model.scope._tag === "Fixed" - ? { model } - : { model: { ...model, scope: { ...model.scope, [field]: value } } }, + GotScopeComboMessage: ({ field, message: child }) => scopeComboStep(model, field, child), ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index d2758eb2df..73a6d6e305 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,16 +1,33 @@ -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 { eyebrow } from "@maple/views/ui/eyebrow" import { button } from "@maple/views/ui/button" +import * as Combobox from "@maple/views/ui/combobox" +import { chipsView } from "@maple/views/ui/combobox-view" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" -import { isFormReady, Message, type Model, type SilenceDuration, silenceEndMs, untilError } from "./dialog" -import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" +import { + endZoneLabel, + isFormReady, + Message, + type Model, + type Scope, + type ScopeField, + type ScopeSuggestions, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { + dialogHelp, + dialogLabel, + dialogTextField, + type DialogTextFieldOptions, +} from "../shared/matcher-fields" import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder @@ -45,89 +62,145 @@ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { : "Every notification of this rule" } -const scopeSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), - model.scope._tag === "Fixed" - ? panel(h, { padded: true, className: "gap-1" }, [ - typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), - typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), - ]) - : panel( +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "Add a service" }, + { field: "environments", label: "Environments", placeholder: "Add an environment" }, + { field: "tags", label: "Tags", placeholder: "Add a tag" }, +] + +const SCOPE_HELP_ID = "silence-scope-help" + +const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } + +/** The suggestions, then what is typed when it is none of them: a name no rule lists can still be added. */ +const itemsOf = (suggested: ReadonlyArray, typed: string): ReadonlyArray => { + const draft = Str.trim(typed) + return draft === "" || suggested.includes(draft) ? suggested : [...suggested, draft] +} + +/** One scope field: its label and the chips input, suggesting what the org's rules already name. */ +const chipsField = ( + h: H, + model: Model, + scope: Extract, + suggestions: ScopeSuggestions, + { field, label, placeholder }: (typeof SCOPE_FIELDS)[number], +): Html => { + const combo = model.combos[field] + const labelId = `${combo.id}-label` + return vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: labelId, htmlFor: Combobox.inputId(combo.id) }, label), + h.submodel({ + slotId: `${combo.id}-chips`, + model: { ...combo, selected: scope[field] }, + view: chipsView, + toParentMessage: (message) => Message.GotScopeComboMessage({ field, message }), + viewInputs: { + items: itemsOf(suggestions[field], combo.inputValue), + ariaLabelledBy: labelId, + ariaDescribedBy: SCOPE_HELP_ID, + placeholder, + emptyText: "Type a name, then press Enter", + isDisabled: model.isSaving, + toView: (parts) => h.div([], parts), + }, + }), + ]) +} + +const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): ReadonlyArray => { + const scope = model.scope + return scope._tag === "Fixed" + ? [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-covers-label" }, "Covers"), + panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(scope.matchers)]), + ]), + ]), + ] + : [ + ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), + dialogHelp( h, - { padded: true, className: "gap-3" }, - matcherFields(h, { - idPrefix: "silence", - typed: model.scope, - onInput: (field, value) => Message.ChangedScopeField({ field, value }), - }), + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", ), - ]) + ] +} -const durationSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["For"]), +const durationSection = (h: H, model: Model): ReadonlyArray => [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-duration-label" }, "Duration"), segmentedSelect(h, { ariaLabel: "Silence duration", options: DURATIONS, value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), - size: "sm", + size: "lg", className: "w-fit flex-wrap", }), - ...(model.duration === "custom" - ? [ - textField(h, { - name: "until", - label: "Ends", - value: model.until, - placeholder: "", - type: "datetime-local", - onInput: (value) => Message.ChangedUntil({ value }), - description: `In ${model.timeZone}. At most 30 days from now.`, - error: untilError(model), - }), - ] - : []), - ]) - -/** The "Silence notifications" side panel: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, _inputs, h) => - sidePanelSheet(h, { - sheet: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - title: "Silence notifications", - description: - "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - body: [ - vstack(h, { gap: "5" }, [ - scopeSection(h, model), - durationSection(h, model), + ]), + ...(model.duration === "custom" + ? [ textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), + name: "until", + // The viewer's own clock, named: "Ends, CET". + label: `Ends, ${endZoneLabel(model)}`, + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: "Your local time. At most 30 days from now.", + error: untilError(model), }), - ]), - ], - onSubmit: Message.ClickedSave(), - footer: (close) => [ - button( - h, - { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, - ["Cancel"], - ), - button( - h, - { - type: "submit", - isLoading: model.isSaving, - isDisabled: model.isSaving || !isFormReady(model), - }, - [submitLabel(model)], - ), - ], - }), + ] + : []), +] + +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView( + (model, inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + body: [ + vstack(h, { gap: "5" }, [ + ...scopeSection(h, model, inputs.suggestions ?? NO_SUGGESTIONS), + ...durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ]), + ], + onSubmit: Message.ClickedSave(), + footer: (close) => [ + button( + h, + { + variant: "outline", + isDisabled: model.isSaving, + attributes: model.isSaving ? [] : close, + }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [submitLabel(model)], + ), + ], + }), ) diff --git a/apps/web/src/page/kit/timezone-zones.ts b/apps/web/src/page/kit/timezone-zones.ts index 6cf9a54649..091b719af5 100644 --- a/apps/web/src/page/kit/timezone-zones.ts +++ b/apps/web/src/page/kit/timezone-zones.ts @@ -26,6 +26,37 @@ const clockFormatter = Option.liftThrowable( new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false }), ) +const abbreviationFormatter = (locale: string) => + Option.liftThrowable( + (timeZone: string) => new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "short" }), + ) +const ABBREVIATION_FORMATTERS = [abbreviationFormatter("en-GB"), abbreviationFormatter("en-US")] + +/** + * A zone's short name at `atMs`: its abbreviation where ICU has one (`CET`, `CEST`, `EST`), else + * its offset (`UTC+5:30`). An unknown zone reads `UTC`. + */ +export const shortZoneName = (timeZone: string, atMs: number): string => + Option.getOrElse( + Arr.findFirst( + Arr.flatMap(ABBREVIATION_FORMATTERS, (formatterFor) => + Option.toArray( + Option.flatMap(formatterFor(timeZone), (formatter) => + Option.map( + Arr.findFirst( + formatter.formatToParts(atMs), + (entry) => entry.type === "timeZoneName", + ), + (entry) => entry.value, + ), + ), + ), + ), + (name) => /^[A-Z]{2,5}$/.test(name), + ), + () => formatUtcOffset(timeZone, atMs), + ) + /** `formatUtcOffset`: `UTC+2`, `UTC-5:30`, `UTC`, the offset a zone is on at `atMs`. */ export const formatUtcOffset = (timeZone: string, atMs: number): string => { const part = Option.match(offsetFormatter(timeZone), { diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index d41ccc49d9..e38119c24d 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -38,7 +38,7 @@ export interface NavSubItem { readonly alsoActive?: ReadonlyArray /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ readonly count?: "alertsInbox" - /** Starts a group of rows: a thin rule above it, no heading. */ + /** Starts a group of rows: a small gap above it, no heading. */ readonly startsGroup?: boolean } diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index fd4ae2e8c8..c7444e9066 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -471,14 +471,13 @@ describe("alerts section", () => { ) }) - test("the Inbox counts unacknowledged incidents for this org, and thin rules split the groups", () => { + test("the Inbox counts unacknowledged incidents for this org", () => { const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) scene( { update, view: shellAt("/alerts/rules") }, given(counted.model), measured, expect(text("3")).toExist(), - expect(role("separator")).toExist(), ) // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. scene( diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 4206e21fe9..6137a5ff5a 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -1,4 +1,5 @@ import { isClerkAuthEnabled } from "../lib/services/common/auth-mode" +import { cn } from "@maple/ui/lib/utils" import { Option, Array as Arr, String as Str } from "effect" import type { InfraSurfaceLiteral } from "@maple/domain/http" import { AsyncData } from "foldkit" @@ -16,7 +17,6 @@ import { sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, - sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -104,10 +104,6 @@ const subCountOf = (model: Model, sub: NavSubItem): number | undefined => ? model.alertsInboxCount : undefined -// A rule between groups keeps the sub-list's lane line unbroken. -const groupRule = (h: HtmlBuilder): Html => - sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) - const subList = ( h: HtmlBuilder, model: Model, @@ -118,54 +114,54 @@ const subList = ( return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.flatMap(subItems, (sub, index) => { + Arr.map(subItems, (sub, index) => { const count = subCountOf(model, sub) - return [ - ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), - sidebarMenuSubItem( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ...(count === undefined - ? [] - : [ - sidebarMenuBadge( - h, - [ - typography( - h, - { size: "xs", mono: true, numeric: true, tone: "error" }, - [String(count)], - ), - ], - "top-1 sm:top-0.5", - ), - ]), - ], + return sidebarMenuSubItem( + h, + cn( + "border-l-2 ps-2.5", + sub.href === activeSubHref ? "border-sidebar-primary" : "border-sidebar-border", + // A 4px gap, not a rule, sets a group of rows apart. + sub.startsGroup === true && index > 0 && "mt-1", ), - ] + [ + sidebarMenuSubButton( + h, + { + className: + "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + h.span([], [sub.title]), + ], + ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1/2 -translate-y-1/2", + ), + ]), + ], + ) }), ) } diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..585ba45007 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -236,7 +236,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/time-controls.ts` `isPresetValue`, `presetLabel`, `presetSpanLabel`, `searchPreset`, `timeControls`, `timeRangeTriggerContent`: The time range header controls: the picker trigger, the reset for a custom range, and Reload. - `apps/web/src/page/kit/time-range-picker.ts` `searchPatchOf`, `dashboardTimeRangeOf`, `rejectedToast`, `unpicked`, `isActivePreset`, `isOpenKey`, `openOnHotkey`, `view` (stateful: Model, init, update): `TimeRangePicker`: the header popover with the preset rail and quick select. - `apps/web/src/page/kit/timezone-select-view.ts` `zoneListView`: The timezone select's list: the search input, "No timezone matches that." and the grouped zone list with clock and offset. -- `apps/web/src/page/kit/timezone-zones.ts` `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. +- `apps/web/src/page/kit/timezone-zones.ts` `shortZoneName`, `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. - `apps/web/src/page/kit/underline-tabs.ts` `underlineTabStrip`, `underlineTabClass`, `linkTabStrip`: `UnderlineTabStrip` and `underlineTabClass`: a row of tabs with an underlined active tab. - `apps/web/src/page/kit/where-autocomplete-view.ts` `suggestionListbox`, `whereKeyHandler`: `WhereClauseEditor`'s suggestion list and key handling, generic over the owner's Message. - `apps/web/src/page/kit/where-autocomplete.ts` `caretEvents`, `keepFocusEvents`, `listboxEvents`, `placeCaret`, `initWhereCaret`, `isOpenOf`, `activeKeysAt`, `syncedCaret`, `applied`, `listboxStyle`: The where clause autocomplete and its suggestion list, shared by every where editor (see lib/query-builder/where-clause-autocomplete.ts). diff --git a/packages/views/src/ui/segmented-select.ts b/packages/views/src/ui/segmented-select.ts index abc39de79f..a48365741f 100644 --- a/packages/views/src/ui/segmented-select.ts +++ b/packages/views/src/ui/segmented-select.ts @@ -122,7 +122,8 @@ export type SegmentedSelectConfig = Readonly<{ options: ReadonlyArray> value: T onChange: (value: T) => M - size?: "xs" | "sm" | "default" + /** `lg` keeps each segment at least as tall as a small button, for a choice in a form. */ + size?: "xs" | "sm" | "default" | "lg" ariaLabel?: string className?: string }> From 7e5000c073b013824b83d7c40dadf4098a6bd0db Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:32:34 +0100 Subject: [PATCH 15/35] fix(alerts): layer a saved schedule's overrides into the editor preview Editing a saved schedule now reads its overrides and renders the draft with them, so the preview's gaps match paging and the detail page. The timeline is a skeleton until they land; a failed read previews without them, says so and offers another try. A new schedule asks for none. The timeline's day header keeps each day's full name for screen readers when its drawn label is shortened, and durationText's comment says minutes show only under a day. --- apps/web/src/lab/oncall-lab.ts | 16 ++- .../src/page/alerts/schedule-form/command.ts | 13 +- .../src/page/alerts/schedule-form/message.ts | 4 +- .../src/page/alerts/schedule-form/model.ts | 4 +- .../src/page/alerts/schedule-form/preview.ts | 73 ++++++++++- .../schedule-form/schedule-form.scene.test.ts | 119 ++++++++++++++++++ .../src/page/alerts/schedule-form/update.ts | 33 ++++- .../page/alerts/shared/coverage-preview.ts | 30 ++++- .../alerts/shared/schedule-coverage.test.ts | 3 + .../page/alerts/shared/schedule-coverage.ts | 2 +- .../views/src/ui/shift-timeline.scene.test.ts | 11 +- packages/views/src/ui/shift-timeline.test.ts | 6 + packages/views/src/ui/shift-timeline.ts | 24 +++- 13 files changed, 314 insertions(+), 24 deletions(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 4cda5eb6a2..d53e3cd1dd 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -57,6 +57,7 @@ export const LabState = Schema.Literals([ "editor-covered", "editor-gaps", "editor-early", + "editor-override", "editor-edit", "editor-invalid", "editor-error", @@ -196,7 +197,7 @@ const addPerson = (index: number) => }) const formFor = (state: LabState): Step => { - const isEdit = state === "editor-edit" || state === "editor-error" + const isEdit = state === "editor-edit" || state === "editor-error" || state === "editor-override" const start = FormUpdate.init( { _tag: "AlertsScheduleNew", search: isEdit ? { scheduleId: schedulePublicId(1) } : {} }, CONTEXT, @@ -215,6 +216,10 @@ const formFor = (state: LabState): Step => key: start.model.existing.key ?? "", result: Result.succeed(checkoutSchedule(ONCALL_NOW)), }), + FormMessage.Message.CompletedFetchOverrides({ + key: start.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), ] : []), ] @@ -285,7 +290,14 @@ const formFor = (state: LabState): Step => ] : []), ] - : [] + : state === "editor-override" + ? [ + // One weeknights layer, so the saved overrides sit over real gaps. + FormMessage.Message.ClickedRemoveLayer({ key: "l2" }), + FormMessage.Message.ClickedRemoveLayer({ key: "l1" }), + FormMessage.Message.ClickedAddRestriction({ key: "l0" }), + ] + : [] return fold(start, [...base, ...extra], formStep) } diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index ffa4c6ff33..e3672cba30 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -4,7 +4,7 @@ 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 { listOverrides, retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" import { listOrgMembers } from "../shared/org-members" import { Message } from "./message" @@ -27,6 +27,17 @@ export const FetchExistingSchedule = Command.define("FetchScheduleFormSchedule", ), }) +/** The edited schedule's overrides that have not ended, for the preview. */ +export const FetchScheduleFormOverrides = Command.define("FetchScheduleFormOverrides", { + 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 FetchScheduleFormMembers = Command.define("FetchScheduleFormMembers", { messages: [Message.CompletedFetchMembers], execute: listOrgMembers.pipe( diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index fb69c903e0..53db9f5d13 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OnCallWeekday, UserId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, V2OnCallSchedule } from "@maple/domain/http/v2" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { LoadFailure } from "@maple/views/data/load-failure" @@ -23,6 +23,8 @@ export const Message = defineMessageUnion({ ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, ClickedRetryExisting: {}, + CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, + ClickedRetryOverrides: {}, CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ChangedName: { value: Schema.String }, diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index f192a593d3..9100139c76 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OnCallScheduleId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, 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" @@ -19,6 +19,8 @@ export const Model = Schema.Struct({ editingId: Schema.NullOr(OnCallScheduleId), /** The edited schedule's read; the form fills from it once. */ existing: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** The edited schedule's overrides, which the preview layers over the draft; a new one has none. */ + overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), form: Form, /** The browser's zone and clock arrived: a new form starts from them. */ isReady: Schema.Boolean, diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts index 3147b1949a..e131afad45 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -1,7 +1,10 @@ import type { OnCallShift } from "@maple/alerting-core/oncall" -import { OnCallTimeZone } from "@maple/domain/http" +import { OnCallOverride, OnCallTimeZone } from "@maple/domain/http" +import type { V2OnCallOverride } from "@maple/domain/http/v2" +import { skeleton } from "@maple/views/ui/skeleton" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import { Array as Arr, Option, Schema, String as Str } from "effect" +import { AsyncData } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { coveragePreview, @@ -11,6 +14,7 @@ import { } from "../shared/coverage-preview" import { memberName, type OrgMember } from "../shared/org-members" import { draftCoverage, layerOnCallAt, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { isoMs } from "../shared/time" import { layerStartMs, type LayerDraft } from "./form" import { Message } from "./message" import type { Model } from "./model" @@ -57,6 +61,39 @@ export const previewNotes = (inputs: PreviewInputs): ReadonlyArray }) } +const decodeOverride = Schema.decodeUnknownResult(OnCallOverride) + +/** The API's overrides as the engine reads them; one it cannot read (a bad instant) is left out. */ +export const engineOverrides = (overrides: ReadonlyArray): ReadonlyArray => + Arr.filterMap(overrides, (override) => + decodeOverride({ + id: override.id, + userId: override.user_id, + startsAt: isoMs(override.starts_at), + endsAt: isoMs(override.ends_at), + createdAt: isoMs(override.created_at), + }), + ) + +/** + * Where the edited schedule's overrides stand: `ready` (a new schedule has none), `loading` + * (nothing can be said about gaps yet), or `failed` / `retrying` (the preview goes on without them). + */ +export type OverridesState = "ready" | "loading" | "failed" | "retrying" + +const NO_OVERRIDES: ReadonlyArray = [] + +export const overridesStateOf = (model: Pick): OverridesState => + model.editingId === null + ? "ready" + : AsyncData.isFailure(model.overrides.data) + ? model.overrides.retrying === true + ? "retrying" + : "failed" + : Option.isSome(AsyncData.getData(model.overrides.data)) + ? "ready" + : "loading" + const asPreview = (shift: OnCallShift): PreviewShift => ({ userId: shift.userId, startMs: shift.startMs, @@ -73,10 +110,33 @@ const render = ( weekOffset: number, rangeDays: RangeDays, isNew: boolean, + overrides: ReadonlyArray, + overridesState: OverridesState, ): Html => { const timeZone = isZone(zone) ? zone : "UTC" const range = previewRange(nowMs, timeZone, weekOffset, Number(rangeDays)) - const coverage = draftCoverage({ timeZone: zone, layers }, [], range.startMs, range.endMs, nowMs) + // Overrides beat every layer when paging, so the preview layers them over the draft too. + const coverage = draftCoverage( + { timeZone: zone, layers }, + engineOverrides(overrides), + range.startMs, + range.endMs, + nowMs, + ) + const overridesNote: ReadonlyArray> = + overridesState === "failed" || overridesState === "retrying" + ? [ + { + tone: "info", + text: "Overrides could not be loaded, so gaps may be filled.", + action: { + label: "Try again", + onClick: Message.ClickedRetryOverrides(), + isDisabled: overridesState === "retrying", + }, + }, + ] + : [] return coveragePreview(h, { scope: PREVIEW_SCOPE, title: "Coverage preview", @@ -108,8 +168,9 @@ const render = ( onFocusGap: (index) => Message.ClickedPreviewGap({ index }), // A draft has no past to look back on. isPreviousDisabled: weekOffset <= 0, - notes: previewNotes({ timeZone: zone, layers, members, nowMs, isNew }), - note: isNew ? undefined : "Overrides are not shown here.", + notes: [...overridesNote, ...previewNotes({ timeZone: zone, layers, members, nowMs, isNew })], + // Until the overrides land nothing can be said about gaps: one may cover them. + placeholder: overridesState === "loading" ? skeleton(h, "h-40 w-full") : undefined, }) } @@ -117,7 +178,7 @@ const slot = createLazy() const MINUTE_MS = 60_000 -/** Redrawn only when the zone, the layers, the members, the minute or the range change, not on every key. */ +/** Redrawn only when the zone, layers, members, overrides, minute or range change, not on every key. */ export const schedulePreview = (h: HtmlBuilder, model: Model): Html => slot(render, [ h, @@ -128,4 +189,6 @@ export const schedulePreview = (h: HtmlBuilder, model: Model): Html => model.previewWeekOffset, model.previewRange, model.editingId === null, + Option.getOrElse(AsyncData.getData(model.overrides.data), () => NO_OVERRIDES), + overridesStateOf(model), ]) 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 index fed1c5e6fe..d311bdc489 100644 --- 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 @@ -6,6 +6,7 @@ import { expect as expectView, given, role, scene, text, withViewInputs } from " import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { + checkoutOverrides, checkoutSchedule, memberId, ONCALL_NOW, @@ -51,6 +52,24 @@ const ready = (search: { scheduleId?: string } = {}) => { const memberNameOf = (index: number) => oncallMembers[index]?.name ?? "" +/** The saved checkout schedule cut down to one weeknights layer, so its days and weekends are gaps. */ +const weeknightsOnly = (model: Model) => + run(model, [ + Message.ClickedRemoveLayer({ key: "l2" }), + Message.ClickedRemoveLayer({ key: "l1" }), + Message.ClickedAddRestriction({ key: "l0" }), + ]) + +const overridesFailure = { + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the overrides", + retryable: false, + recovery: "retry", +} as const + const addPerson = (key: string, index: number) => Message.GotParticipantMenuMessage({ key, @@ -425,4 +444,104 @@ describe("schedule form", () => { expect(run(later, [Message.ClickedPreviewToday()]).previewWeekOffset).toBe(0) }) }) + + describe("overrides in the preview of a saved schedule", () => { + test("an edit reads the schedule's overrides; a new schedule asks for none", () => { + expect(names(init({ _tag: "AlertsScheduleNew", search: {} }, context).commands)).not.toContain( + "FetchScheduleFormOverrides", + ) + expect( + names( + init({ _tag: "AlertsScheduleNew", search: { scheduleId: schedulePublicId(1) } }, context) + .commands, + ), + ).toContain("FetchScheduleFormOverrides") + // A new schedule's preview does not wait on anything. + scene( + { update: pageUpdate, view }, + given(run(ready(), [addPerson("l0", 0)])), + expectView(text("Covered for the next 14 days")).toExist(), + ) + }) + + test("until the overrides land the timeline is a skeleton and nothing is said about gaps", () => { + scene( + { update: pageUpdate, view }, + given(weeknightsOnly(ready({ scheduleId: schedulePublicId(1) }))), + expectView(text("Coverage preview")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gaps? in the next/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + expectView(text(/^Nobody is on call/u)).not.toExist(), + ) + }) + + test("an override covering part of a gap fills it, as it does when paging", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + // Dana Kim covers Fri 13 Nov 12:00 to Sat 14 Nov 12:00, over the Friday and weekend gaps. + const loaded = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + expectView( + role("img", { name: "Override: Dana Kim, Fri 13 Nov, 12:00 to Sat 14 Nov, 12:00" }), + ).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).not.toExist(), + expectView(role("button", { name: "Sat 14 Nov 12:00 to Mon 16 Nov 18:00" })).toExist(), + // The first weekend is partly covered now, so the second is the longest gap. + expectView( + text( + "10 gaps in the next 14 days, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)", + ), + ).toExist(), + expectView(text(/Overrides are not shown/u)).not.toExist(), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + ) + }) + + test("a failed overrides read previews without them, says so and offers another try", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + const failed = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.fail(overridesFailure), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text(/Overrides could not be loaded, so gaps may be filled\./u)).toExist(), + expectView(role("button", { name: "Try again" })).not.toBeDisabled(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).toExist(), + expectView(role("img", { name: /^Override:/u })).not.toExist(), + ) + const retried = pageUpdate(failed, Message.ClickedRetryOverrides()) + expect(names(retried.commands)).toEqual(["FetchScheduleFormOverrides"]) + // The note stays up while the retry is out, with its link off. + scene( + { update: pageUpdate, view }, + given(retried.model), + expectView(text(/Overrides could not be loaded/u)).toExist(), + expectView(role("button", { name: "Try again" })).toBeDisabled(), + ) + const recovered = run(retried.model, [ + Message.CompletedFetchOverrides({ + key: retried.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(recovered), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + ) + }) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index 0a4af206fa..01b868656f 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -1,4 +1,4 @@ -import { UserId } from "@maple/domain/http" +import { type OnCallScheduleId, 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" @@ -12,6 +12,7 @@ import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, + FetchScheduleFormOverrides, FetchScheduleFormSession, ReadScheduleFormDefaults, SaveSchedule, @@ -41,6 +42,18 @@ export const PREVIEW_SCOPE = "schedule-preview" export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` +/** Reads the edited schedule's overrides (again), for the preview. */ +const requestOverrides = (model: Model, context: PageContext, scheduleId: OnCallScheduleId) => { + const overrides = Keyed.supersede( + model.overrides, + requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }), + ) + return { + model: { ...model, overrides }, + commands: [FetchScheduleFormOverrides({ key: overrides.key, scheduleId })], + } +} + export const init = (route: FormRoute, context: PageContext): Return => { const editingId = Option.getOrNull( Option.flatMap(Option.fromNullishOr(route.search.scheduleId), scheduleIdFromUrl), @@ -49,6 +62,7 @@ export const init = (route: FormRoute, context: PageContext): Return => { search: route.search, editingId, existing: Keyed.idle(), + overrides: Keyed.idle(), form: emptyForm("UTC", 0), isReady: false, nowMs: 0, @@ -70,13 +84,19 @@ export const init = (route: FormRoute, context: PageContext): Return => { FetchScheduleFormMembers(), FetchScheduleFormSession({ orgId: context.orgId }), ] + // A new schedule has no overrides, so nothing is read for it. if (editingId === null) return { model, commands: common } + const overrides = requestOverrides(model, context, editingId) const key = requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }) return Option.match(Keyed.request(model.existing, key, "Load"), { - onNone: () => ({ model, commands: common }), + onNone: () => ({ model: overrides.model, commands: [...common, ...overrides.commands] }), onSome: (existing) => ({ - model: { ...model, existing }, - commands: [...common, FetchExistingSchedule({ key, scheduleId: editingId })], + model: { ...overrides.model, existing }, + commands: [ + ...common, + FetchExistingSchedule({ key, scheduleId: editingId }), + ...overrides.commands, + ], }), }) } @@ -269,6 +289,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [FetchExistingSchedule({ key: existing.key, scheduleId: editingId })], } }, + CompletedFetchOverrides: ({ key, result }) => ({ + model: { ...model, overrides: Keyed.accept(model.overrides, key, result) }, + }), + ClickedRetryOverrides: () => + model.editingId === null ? { model } : requestOverrides(model, context, model.editingId), ChangedLayerText: ({ key, field, value }) => field === "startsAt" ? editLayer(model, key, (layer) => ({ ...layer, startsAt: value, startsAtMs: null })) diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index d873cd7292..183b6c5263 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -10,6 +10,7 @@ import { iconButton } from "@maple/views/ui/icon-button" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { formatShiftEdge } from "./oncall-schedules" import { gapRangeText, gapSummary, isUncovered, type Interval } from "./schedule-coverage" @@ -58,8 +59,15 @@ export type PreviewLane = Readonly<{ emptyText: string }> -/** A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. */ -export type PreviewNote = Readonly<{ tone: "info" | "warn"; text: string }> +/** + * A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. + * `action` is a link after the text ("Try again"). + */ +export type PreviewNote = Readonly<{ + tone: "info" | "warn" + text: string + action?: Readonly<{ label: string; onClick: M; isDisabled?: boolean }> +}> export type CoveragePreviewOptions = Readonly<{ /** Prefixes the gap bars' ids, unique on the page. */ @@ -84,7 +92,7 @@ export type CoveragePreviewOptions = Readonly<{ isPreviousDisabled?: boolean /** A final shift's bar action (the detail page's "cover with an override"). */ onFinalShift?: (shift: PreviewShift) => Option.Option - notes?: ReadonlyArray + notes?: ReadonlyArray> /** A line under the header. The axis names the zone, so this does not. */ note?: string | undefined /** Drawn in place of the summary and timeline while their data loads; the header stays. */ @@ -231,7 +239,7 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, body) } -const noteView = (h: HtmlBuilder, note: PreviewNote): Html => +const noteView = (h: HtmlBuilder, note: PreviewNote): Html => hstack(h, { gap: "1.5", align: "start", attributes: [h.Key(note.text)] }, [ h.span( [ @@ -242,7 +250,19 @@ const noteView = (h: HtmlBuilder, note: PreviewNote): Html => ], [icon(h, note.tone === "warn" ? "CircleWarningIcon" : "CircleInfoIcon", { size: 14 })], ), - typography(h, { size: "xs", tone: "muted", as: "p" }, [note.text]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + note.text, + ...(note.action === undefined + ? [] + : [ + " ", + textLink( + h, + { onClick: note.action.onClick, isDisabled: note.action.isDisabled ?? false }, + [note.action.label], + ), + ]), + ]), ]) const controls = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index adcc07d41b..b459ff3bee 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -257,6 +257,9 @@ describe("gapSummary", () => { it("formats durations", () => { expect(durationText(90 * 60_000)).toBe("1h 30m") expect(durationText(76 * HOUR)).toBe("3d 4h") + // From a day up the minutes are left out, not rounded into the hours. + expect(durationText(58 * HOUR + 30 * 60_000)).toBe("2d 10h") + expect(durationText(23 * HOUR + 30 * 60_000)).toBe("23h 30m") }) }) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts index 2a85dbf5ea..f4c206818a 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -201,7 +201,7 @@ export const gapRangeText = (gap: Interval, timeZone: string, checkedToMs: numbe return `${start} to ${end}` } -/** "16h", "1h 30m", "3d 4h": a gap's length, rounded to minutes. */ +/** "16h", "1h 30m", "3d 4h": a gap's length. Minutes show only under a day; a longer one reads in days and hours. */ export const durationText = (ms: number): string => { const minutes = Math.round(ms / 60_000) const days = Math.floor(minutes / 1440) diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 23536c2692..1f49587692 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -1,4 +1,4 @@ -import { expect as expectView, given, role, scene, text } from "foldkit/scene" +import { expect as expectView, given, role, scene, text, within } from "foldkit/scene" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { describe, test } from "vitest" @@ -124,4 +124,13 @@ describe("shift timeline", () => { expectView(role("img", { name: "Ben, Mon" })).toHaveAttr("tabIndex", "-1"), ) }) + + test("every day of the header is named in full, however its drawn label is shortened", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(within(role("columnheader", { name: "Shifts" }), text("Mon 9 Nov"))).toExist(), + expectView(within(role("columnheader", { name: "Shifts" }), text("Sun 15 Nov"))).toExist(), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index dbc5c86208..dc0f4d85c0 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -56,6 +56,12 @@ describe("shift timeline layout", () => { ) }) + test("each day carries its full name, month included, across a month's end", () => { + const from = Date.parse("2026-11-28T23:00:00.000Z") // Sun 29 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 3 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => day.label)).toEqual(["Sun 29 Nov", "Mon 30 Nov", "Tue 1 Dec"]) + }) + test("Saturdays and Sundays are marked as the weekend, on the zone's own days", () => { const from = Date.parse("2026-11-12T23:00:00.000Z") // Fri 13 Nov, 00:00 in Berlin const days = timelineDays(from, from + 4 * 24 * HOUR, "Europe/Berlin") diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 58656c254c..b8ab8deb98 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -47,6 +47,8 @@ export type TimelineDay = Readonly<{ endMs: number weekday: string dayOfMonth: number + /** The day in full ("Tue 10 Nov"): its name wherever the drawn label is shortened or hidden. */ + label: string leftPct: number widthPct: number isWeekend: boolean @@ -60,6 +62,14 @@ const weekdayOf = (ms: number, timeZone: string): string => { return formatter.format(ms) } +const monthFormatters = new Map() +const monthOf = (ms: number, timeZone: string): string => { + const cached = monthFormatters.get(timeZone) + const formatter = cached ?? new Intl.DateTimeFormat("en-US", { timeZone, month: "short" }) + if (cached === undefined) monthFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + const WEEKEND = new Set(["Sat", "Sun"]) /** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ @@ -92,15 +102,20 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re return Arr.flatMap(Arr.range(0, count), (offset): ReadonlyArray => { const startMs = zonedDayStart(fromMs, timeZone, offset) const endMs = zonedDayStart(fromMs, timeZone, offset + 1) + // The day's first instant inside the range names it. + const atMs = Math.max(startMs, fromMs) + const weekday = weekdayOf(atMs, timeZone) + const dayOfMonth = zonedDateParts(atMs, timeZone).day return Option.match(placeBar(fromMs, toMs, startMs, endMs), { onNone: () => [], onSome: (place) => [ { startMs, endMs, - weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), - isWeekend: WEEKEND.has(weekdayOf(Math.max(startMs, fromMs), timeZone)), - dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, + weekday, + isWeekend: WEEKEND.has(weekday), + dayOfMonth, + label: `${weekday} ${dayOfMonth} ${monthOf(atMs, timeZone)}`, ...place, }, ], @@ -314,6 +329,8 @@ export const shiftTimeline = ( h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [ + // Read out in full; the two drawn forms below shorten or hide it by the day's width. + h.span([h.Class("sr-only")], [day.label]), // "Tue 10" where a day is wide enough, else the weekday's letter over the date. typography( h, @@ -323,6 +340,7 @@ export const shiftTimeline = ( numeric: true, truncate: true, className: "hidden px-1.5 py-1.5 @min-[3.5rem]:block", + attributes: [h.AriaHidden(true)], }, [`${day.weekday} ${day.dayOfMonth}`], ), From 78ebb4a21244a375422b28d0fea0605104374c7d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:01:09 +0100 Subject: [PATCH 16/35] fix(alerts): silence form says what is missing instead of a silent disabled save --- .../alerts/hub/hub-silences.scene.test.ts | 44 ++++++++++++++-- .../page/alerts/silence-dialog/dialog.test.ts | 19 +++++++ .../src/page/alerts/silence-dialog/dialog.ts | 52 ++++++++++++------- .../src/page/alerts/silence-dialog/view.ts | 34 +++++++++--- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index e0b5755d6f..db20e245b5 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -6,7 +6,17 @@ import { Result, Schema, Array as Arr } from "effect" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + expect as expectView, + Mount, + given, + role, + scene, + selector, + submit, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" @@ -212,7 +222,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { + test("the URL opens it with a chips input per scope field, Cancel and a save", () => { const opened = withPanel() expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( @@ -227,7 +237,35 @@ describe("alerts hub, silence side panel", () => { text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), ).toExist(), expectView(role("button", { name: "Cancel" })).toExist(), - expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { + scene( + { update: pageUpdate, view }, + given(withPanel().model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + submit(selector("form")), + expectView(text("Add at least one service, environment or tag")).toExist(), + expectView(text("Add a reason")).toExist(), + expectView(role("textbox", { name: "Reason" })).toHaveAttr("aria-invalid", "true"), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + ) + const tried = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(tried.commands)).toEqual([]) + // Each line goes as its field is filled in. + const reasoned = pageUpdate( + tried.model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ).model + scene( + { update: pageUpdate, view }, + given(reasoned), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Add a reason")).not.toExist(), + expectView(text("Add at least one service, environment or tag")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 3b62de3bad..0bdddb0dde 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -14,6 +14,8 @@ import { openNew, OutMessage, parseLocalDateTime, + reasonError, + scopeError, type ScopeField, silenceEndMs, untilError, @@ -185,6 +187,23 @@ describe("isFormReady", () => { Option.some("A silence can last at most 30 days"), ) }) + + it("a tried save on an incomplete form sends nothing and turns the errors on, an empty custom end included", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const custom = update(opened, Message.ChangedDuration({ duration: "custom" })).model + expect(untilError(custom)).toEqual(Option.none()) + const tried = update(custom, Message.ClickedSave()) + expect(tried.commands ?? []).toEqual([]) + expect(tried.model.isSaving).toBe(false) + expect(tried.model.isShowingErrors).toBe(true) + expect(untilError(tried.model)).toEqual(Option.some("Pick a date and time")) + expect(reasonError(tried.model.reason)).toEqual(Option.some("Add a reason")) + expect(scopeError(tried.model.scope)).toEqual( + Option.some("Add at least one service, environment or tag"), + ) + // The next open starts quiet again. + expect(openNew(tried.model, BERLIN, NOW).model.isShowingErrors).toBe(false) + }) }) describe("saving", () => { diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 8b69c61740..c1c3ed72af 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -62,6 +62,8 @@ export const Model = Schema.Struct({ /** The wall clock when the dialog opened: a custom end is checked against it. */ nowMs: Schema.Number, isSaving: Schema.Boolean, + /** A save was tried on an incomplete form: each field left out says so. */ + isShowingErrors: Schema.Boolean, /** Bumped on every open: a save's result only settles the form it was sent from. */ openCount: Schema.Number, }) @@ -77,6 +79,7 @@ export const init = (id: string): Model => ({ timeZone: "UTC", nowMs: 0, isSaving: false, + isShowingErrors: false, openCount: 0, }) @@ -167,9 +170,10 @@ export const endZoneLabel = (model: Pick) Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), ) -/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +/** Why a typed custom end cannot be used, or none when it can; an empty one waits for a tried save. */ export const untilError = (model: Model): Option.Option => { - if (model.duration !== "custom" || model.until === "") return Option.none() + if (model.duration !== "custom") return Option.none() + if (model.until === "") return model.isShowingErrors ? Option.some("Pick a date and time") : Option.none() return Option.match(parseLocalDateTime(model.until, model.timeZone), { onNone: () => Option.some("Pick a date and time"), onSome: (endMs) => @@ -181,6 +185,15 @@ export const untilError = (model: Model): Option.Option => { }) } +/** A silence always says why: the reason shows on its row and on the pages it covers. */ +export const reasonError = (reason: string): Option.Option => + Str.isEmpty(Str.trim(reason)) ? Option.some("Add a reason") : Option.none() + +export const scopeError = (scope: Scope): Option.Option => + hasMatchers(matchersOf(scope)) + ? Option.none() + : Option.some("Add at least one service, environment or tag") + /** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ export const isFormReady = (model: Model): boolean => Str.isNonEmpty(Str.trim(model.reason)) && @@ -235,6 +248,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu duration: "1h", until: "", isSaving: false, + isShowingErrors: false, openCount: model.openCount + 1, }, Dialog.open(model.dialog), @@ -271,22 +285,24 @@ const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Messa /** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) -const save = (model: Model): Return => - model.isSaving || !isFormReady(model) - ? { model } - : { - model: { ...model, isSaving: true }, - commands: [ - SaveSilence({ - openCount: model.openCount, - reason: Str.trim(model.reason), - matchers: matchersOf(model.scope), - duration: model.duration, - until: model.until, - timeZone: model.timeZone, - }), - ], - } +const save = (model: Model): Return => { + if (model.isSaving) return { model } + // An incomplete form stays open and names what is missing, as the other alert forms do. + if (!isFormReady(model)) return { model: { ...model, isShowingErrors: true } } + return { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } +} export const update = (model: Model, message: Message): Return => Message.match(message, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 0c1db820aa..93e90ad049 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -9,13 +9,15 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" +import { fieldErrorText } from "@maple/views/ui/field" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" import { endZoneLabel, - isFormReady, Message, type Model, + reasonError, type Scope, + scopeError, type ScopeField, type ScopeSuggestions, type SilenceDuration, @@ -51,6 +53,10 @@ const submitLabel = (model: Model): string => `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, }) +/** A field's error once a save was tried. */ +const shown = (model: Model, error: Option.Option): Option.Option => + model.isShowingErrors ? error : Option.none() + const textField = (h: H, options: Omit, "idPrefix">): Html => dialogTextField(h, { ...options, idPrefix: "silence" }) @@ -69,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ERROR_ID = "silence-scope-error" const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } @@ -122,11 +129,22 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon ] : [ ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), - dialogHelp( - h, - SCOPE_HELP_ID, - "Fill in at least one. A rule is silenced only if it matches every field you fill in.", - ), + vstack(h, { gap: "1.5" }, [ + dialogHelp( + h, + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", + ), + ...Option.toArray( + Option.map(shown(model, scopeError(scope)), (message) => + fieldErrorText( + h, + { attributes: [h.Id(SCOPE_ERROR_ID), h.Role("alert")] }, + message, + ), + ), + ), + ]), ] } @@ -179,6 +197,7 @@ export const view = Submodel.defineView Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), }), ]), ], @@ -198,7 +217,8 @@ export const view = Submodel.defineView Date: Tue, 10 Nov 2026 14:01:49 +0100 Subject: [PATCH 17/35] fix(alerts): silence chips split on commas and keep a chip typed twice --- .../src/page/alerts/shared/matcher-fields.ts | 3 +- .../page/alerts/silence-dialog/dialog.test.ts | 47 +++++++++++++++++++ .../src/page/alerts/silence-dialog/dialog.ts | 19 ++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index bcc96dc098..722e5698e0 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -30,7 +30,8 @@ export const typedFromMatchers = (matchers: V2AlertSilenceMatchers): TypedMatche environments: Arr.join(matchers.environments ?? [], ", "), }) -const splitList = (value: string): ReadonlyArray => +/** A comma-separated list as its names: trimmed, the empty ones dropped, each once. */ +export const splitList = (value: string): ReadonlyArray => Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) /** The matchers the fields describe; an empty field is left out. */ diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 0bdddb0dde..9b3b282415 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -141,6 +141,53 @@ describe("scope chips", () => { expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) }) + it("commas in a typed entry make one chip per name, trimmed, without empties or repeats", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const enter = (model: Model, value: string) => + update( + update(model, services(Combobox.Message.ChangedInput({ value }))).model, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api", value] })), + ).model + const entered = enter(opened, "demo-worker, demo-api,,") + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-worker", "demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // A name that is already a chip stays where it is; only the new one is added. + expect(matchersOf(enter(entered, "demo-api,demo-web").scope)).toEqual({ + services: ["demo-worker", "demo-api", "demo-web"], + }) + // A list picked from the popup with the pointer splits the same way. + expect(matchersOf(pick(opened, "services", "a , b").scope)).toEqual({ services: ["a", "b"] }) + }) + + it("typing a name that is already a chip, then Enter, keeps the chip and clears the input", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const picked = pick(opened, "services", "demo-api") + const typed = update(picked, services(Combobox.Message.ChangedInput({ value: "demo-api" }))).model + const entered = update( + typed, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // With nothing typed, Enter on a highlighted option still toggles it, as a pointer pick does. + const highlighted = update( + picked, + services(Combobox.Message.PressedInputKey({ key: "ArrowDown", items: ["demo-api"] })), + ).model + expect( + matchersOf( + update( + highlighted, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model.scope, + ), + ).toEqual({}) + }) + it("a scope fixed by a rule ignores the chips", () => { const fixed = openFor( init("silence"), diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c1c3ed72af..a29b1de01b 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -8,7 +8,7 @@ import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { shortZoneName } from "../../kit/timezone-zones" -import { hasMatchers, MatcherField } from "../shared/matcher-fields" +import { hasMatchers, MatcherField, splitList } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -266,13 +266,26 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, NO_CHIPS, timeZone, nowMs) +/** The chips for what was picked or typed: "checkout, payments" is two names, each listed once. */ +const chipsOf = (names: ReadonlyArray): ReadonlyArray => + Arr.dedupe(Arr.flatMap(names, splitList)) + /** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { if (model.scope._tag === "Fixed") return { model } - const step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + const chips = model.scope[field] + const step = Combobox.update({ ...model.combos[field], selected: chips }, message) + // Enter on typed text only ever adds: the combobox would toggle a name that is already a chip off. + const isTypedEntry = + message._tag === "PressedInputKey" && Str.isNonEmpty(Str.trim(model.combos[field].inputValue)) const scope = step.outMessage?._tag === "ChangedSelection" - ? { ...model.scope, [field]: step.outMessage.selected } + ? { + ...model.scope, + [field]: chipsOf( + isTypedEntry ? [...chips, ...step.outMessage.selected] : step.outMessage.selected, + ), + } : model.scope return { model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, From a037e66d7038644300995d74f95c40ea1ad5b549 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:02:48 +0100 Subject: [PATCH 18/35] fix(alerts): silence chips hint sits in the form, not in a popup over the next label --- .../alerts/hub/hub-silences.scene.test.ts | 27 ++++++++++++++++ .../src/page/alerts/silence-dialog/view.ts | 10 ++++-- packages/views/src/ui/combobox-view.ts | 12 ++++--- packages/views/src/ui/combobox.scene.test.ts | 32 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index db20e245b5..6dedbb673c 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -242,6 +242,33 @@ describe("alerts hub, silence side panel", () => { ) }) + test("a chips input with nothing to suggest opens no list: how to add a name is said in the form", () => { + const focused = pageUpdate( + withPanel().model, + scopeCombo("services", Combobox.Message.ClickedInput()), + ).model + scene( + { update: pageUpdate, view }, + given(focused), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Type a name and press Enter to add it. Commas separate several.")).toExist(), + expectView(role("listbox")).not.toExist(), + expectView(role("combobox", { name: "Services" })).toHaveAttr("aria-expanded", "false"), + ) + // What is typed is listed, so Enter has something to pick. + const typed = pageUpdate( + focused, + scopeCombo("services", Combobox.Message.ChangedInput({ value: "demo-api" })), + ).model + scene( + { update: pageUpdate, view }, + given(typed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "demo-api" })).toExist(), + ) + }) + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 93e90ad049..73e5106252 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -75,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ENTRY_HELP_ID = "silence-scope-entry-help" const SCOPE_ERROR_ID = "silence-scope-error" const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } @@ -105,9 +106,8 @@ const chipsField = ( viewInputs: { items: itemsOf(suggestions[field], combo.inputValue), ariaLabelledBy: labelId, - ariaDescribedBy: SCOPE_HELP_ID, + ariaDescribedBy: `${SCOPE_ENTRY_HELP_ID} ${SCOPE_HELP_ID}`, placeholder, - emptyText: "Type a name, then press Enter", isDisabled: model.isSaving, toView: (parts) => h.div([], parts), }, @@ -130,6 +130,12 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon : [ ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), vstack(h, { gap: "1.5" }, [ + // In the flow, not in the list's popup: there it sat over the next field's label. + dialogHelp( + h, + SCOPE_ENTRY_HELP_ID, + "Type a name and press Enter to add it. Commas separate several.", + ), dialogHelp( h, SCOPE_HELP_ID, diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 28819cdbea..568beb04cc 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -390,7 +390,8 @@ export type ChipsViewInputs = Readonly<{ ariaLabelledBy?: string ariaDescribedBy?: string placeholder: string - emptyText: string + /** The Empty line. Left out, a list with no item to show stays closed instead of opening on nothing. */ + emptyText?: string className?: string /** * `MultiSelectCombobox disabled`: the field stays mounted, dimmed and inert, so cards holding @@ -461,7 +462,10 @@ const chip = ( /** `` with `ComboboxChips`: chips for the selection, then the chips input. */ export const chipsView = Submodel.defineView((model, inputs, h) => { const chipsSelector = `[data-slot=combobox-chips]:has(> #${inputId(model.id)})` - const isOpen = isMounted(model.isOpen, model.animation) + const listed = inputs.isUnfiltered === true ? inputs.items : visibleItems(model, inputs.items) + // No Empty line and no item: the field reads closed, whatever opened it. + const hasList = inputs.emptyText !== undefined || listed.length > 0 + const isOpen = hasList && isMounted(model.isOpen, model.animation) const isDisabled = inputs.isDisabled ?? false const chips = h.div( [ @@ -490,7 +494,7 @@ export const chipsView = Submodel.defineView((m ...(isDisabled ? [h.Disabled(true)] : []), ...inputAttributes( h, - model, + hasList ? model : { ...model, isOpen: false }, { slot: "combobox-chips-input", ariaLabel: inputs.ariaLabel, @@ -513,7 +517,7 @@ export const chipsView = Submodel.defineView((m listPopup(h, model, { slot: "combobox", items: inputs.items, - emptyText: inputs.emptyText, + emptyText: inputs.emptyText ?? "", anchorSelector: chipsSelector, isUnfiltered: inputs.isUnfiltered, renderItem: (value: string) => inputs.renderItem?.(h, value) ?? [value], diff --git a/packages/views/src/ui/combobox.scene.test.ts b/packages/views/src/ui/combobox.scene.test.ts index 78e926812a..ce1e06b096 100644 --- a/packages/views/src/ui/combobox.scene.test.ts +++ b/packages/views/src/ui/combobox.scene.test.ts @@ -131,6 +131,38 @@ describe("combobox scene (chips)", () => { ) }) + test("without an Empty line the list opens only once it has an item to show", () => { + const quiet = { + update, + view: withViewInputs(chipsView, { + items: ["checkout-svc"], + ariaLabel: "Services", + placeholder: "Add a service", + toView: (parts) => ih.main([], parts), + })(), + } + scene( + quiet, + given(init("services", "multiple")), + type(input("Services"), "zzz"), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(role("listbox")).toBeAbsent(), + expect(role("status")).toBeAbsent(), + expect(input("Services")).toHaveAttr("aria-expanded", "false"), + type(input("Services"), "che"), + Mount.resolve(MountCombobox, Message.CompletedMountCombobox()), + expect(option("checkout-svc")).toExist(), + expect(input("Services")).toHaveAttr("aria-expanded", "true"), + ) + }) + test("no match shows the empty status; Escape closes", () => { scene( chips, From 2b33c5e0b86c640ea5fc01253e111734ea90db8f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 19/35] fix(shell): re-check a stale 'no schedules' answer so a teammate's first schedule shows the on-call row The marker lived a day and only a schedule write in the same tab cleared it, so the row stayed hidden across reloads after someone else created the org's first schedule. It now lives five minutes, and coming back into focus asks again once it has aged. --- apps/web/src/shell/oncall.scene.test.ts | 42 +++++++++++++++++++++++-- apps/web/src/shell/oncall.ts | 7 +++-- apps/web/src/shell/update.ts | 29 ++++++++++++----- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 801ee39f55..029b0c5301 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -21,6 +21,7 @@ import { MeasureSidebarOverflow } from "./mounts" import { isNoSchedulesFresh, nextHandoffMs, + NO_SCHEDULES_TTL_MS, ONCALL_IDLE_READ_MS, ONCALL_RETRY_MS, type OnCallShift, @@ -234,8 +235,45 @@ describe("on-call reads", () => { { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, ]) - expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) - expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) + }) + + test("a teammate's first schedule: back in focus, an aged 'no schedules' answer is asked again", () => { + // The marker was read from storage at sign-in, so the first read never reached the API. + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + const cached = update(booted.model, answer([], NOW + 1_000, true)).model + expectVitest(commandsOf(update(cached, Message.WokeOnCall({ nowMs: NOW + 61_000 })))).toEqual([]) + + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + expectVitest(isNoSchedulesFresh(NOW, agedAtMs - 1)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, agedAtMs)).toBe(false) + const woke = update(cached, Message.WokeOnCall({ nowMs: agedAtMs })) + expectVitest(commandsOf(woke)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: NOW } }, + ]) + + const found = update(woke.model, answer([shift({ userId: "user_someone" })], agedAtMs)) + expectVitest(commandsOf(found)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + scene( + { update, view }, + given(found.model), + measured, + expect(role("link", { name: "Not on call" })).toExist(), + ) + }) + + test("an org still without schedules is asked at most once per lifetime of its answer", () => { + const empty = update(signedIn(), answer([])).model + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + const woke = update(empty, Message.WokeOnCall({ nowMs: agedAtMs })) + const stillEmpty = update(woke.model, answer([], agedAtMs)) + expectVitest(commandsOf(stillEmpty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: agedAtMs } }, + ]) + expectVitest( + commandsOf(update(stillEmpty.model, Message.WokeOnCall({ nowMs: agedAtMs + 61_000 }))), + ).toEqual([]) }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index e29cee4d4d..bdb87b1a88 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -117,8 +117,11 @@ const ONCALL_RETRY_CAP_MS = 15 * 60_000 export const ONCALL_IDLE_READ_MS = 60 * 60_000 /** Focus re-reads at most this often. */ export const ONCALL_WAKE_MIN_MS = 60_000 -/** An org's "no schedules" answer is trusted this long before asking again. */ -export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 +/** + * An org's "no schedules" answer is trusted this long. Minutes, not a day: only a schedule written + * in this tab clears it, so a teammate's first schedule shows once it ages out. + */ +export const NO_SCHEDULES_TTL_MS = 5 * 60_000 export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..269f59789c 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -28,7 +28,13 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" -import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" +import { + isNoSchedulesFresh, + ONCALL_WAKE_MIN_MS, + type OnCallSnapshot, + scheduleAfterAnswer, + scheduleAfterFailure, +} from "./oncall" import { DashboardsData, InfraPresenceData, @@ -166,13 +172,20 @@ const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateRe } } -/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ -const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { - const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) - if (!hasSchedules) return { model } - const moved = modifyFields(model, { onCallNowMs: () => nowMs }) - return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } -} +/** + * Back in view or focus: move the pill's clock, and read again if the last event is a minute old. + * An org without schedules is asked again once that answer has aged: a teammate may have made one. + */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.getData(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, data]) => { + if (data.shifts.length === 0) + return isNoSchedulesFresh(model.noScheduleOrgs[orgId], nowMs) ? { model } : readOnCall(model) + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } + }, + }) /** A page wrote a schedule or override: the org may have its first schedule now. */ const onCallChanged = (model: Model): UpdateReturn => From 82145ab326135e4e7a11d21f1739a6636ee27fb7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 20/35] fix(shell): only the current page's link carries aria-current in the sidebar A section row whose sub-item is the current page keeps data-active="child" and leaves aria-current to the sub-item. The on-call footer row is a plain anchor, so it never claims the page its nav row already marks. --- apps/web/src/shell/nav-groups.scene.test.ts | 31 ++++++++++++++++++++- apps/web/src/shell/oncall.scene.test.ts | 22 +++++++++++++-- apps/web/src/shell/view-sidebar.ts | 12 ++------ packages/views/src/ui/sidebar.ts | 6 ++-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index a85dab0187..558131f7d7 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -1,6 +1,17 @@ import { Option } from "effect" import type { HtmlBuilder } from "foldkit/html" -import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { + all, + Command, + click, + expect, + expectAll, + given, + Mount, + role, + scene, + withViewInputs, +} from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { SaveCollapsedNavGroups } from "./command" import { Message } from "./message" @@ -100,6 +111,24 @@ describe("sidebar groups", () => { ) }) + test("one link is the current page: the sub-item while its section is open, the section once folded", () => { + const explore = role("link", { name: "Explore" }) + const current = all.role("link", { current: "page" }) + scene( + // Explore links to `/traces`, the page its Traces sub-item is. + { update, view: shellAt("/traces") }, + given(signedIn()), + measured, + expectAll(current).toHaveCount(1), + expect(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), + click(analyze), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expectAll(current).toHaveCount(1), + expect(explore).toHaveAttr("aria-current", "page"), + ) + }) + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 029b0c5301..4409b856db 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -69,15 +69,17 @@ const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache const commandsOf = (step: UpdateReturn) => Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) -const view = (() => { +const viewAt = (currentPath: string) => { const frame = withViewInputs(frameView, { - currentPath: "/", + currentPath, breadcrumbs: [{ label: "Overview" }], page: () => null, topbarActions: () => null, })() return (model: Model, h: HtmlBuilder) => frame(model, h) -})() +} + +const view = viewAt("/") const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) @@ -141,6 +143,20 @@ describe("on-call pill", () => { ) }) + test("the pill is a shortcut: on the page it links to, the nav's row is the current page, not the pill", () => { + const pill = role("link", { name: "Not on call" }) + // Where the pill links today, and where it links once on-call has a page of its own. + Arr.forEach(["/alerts?tab=oncall", "/alerts/on-call"], (currentPath) => + scene( + { update, view: viewAt(currentPath) }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(pill).toExist(), + expect(pill).not.toHaveAttr("aria-current"), + ), + ) + }) + test("on the collapsed rail the pill is a badged glyph named by the detail, which its tooltip shows", () => { const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index b2e4ef78f6..12f1b99b71 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -262,7 +262,6 @@ export const shellOnCallStatus = (model: Model): OnCallStatus => */ const onCallPill = ( h: HtmlBuilder, - currentPath: string, status: OnCallStatus, tip: RailTip, isRail: boolean, @@ -281,7 +280,8 @@ const onCallPill = ( h, { className: isOnCall ? undefined : "text-muted-foreground", - link: { href: oncallTabHref(), currentPath }, + // A shortcut, not a nav row: a plain anchor, so it never claims `aria-current="page"`. + href: oncallTabHref(), hasTooltip: true, attributes: [ // The rail's tooltip names the glyph; the open sidebar's adds what the row leaves out. @@ -405,13 +405,7 @@ export const shellSidebarFooter = ( ), sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. - ...onCallPill( - h, - currentPath, - shellOnCallStatus(model), - tip, - !model.isSidebarOpen && !model.isMobile, - ), + ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. footerNavRow( diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 42bdbeb995..dfe450e12e 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -367,7 +367,7 @@ export type SidebarMenuButtonOptions = Readonly<{ isActive?: boolean /** * A sub-item of this row is the current page: the row keeps its medium weight and leaves the - * tint to that sub-item (`data-active="child"`). + * tint and `aria-current="page"` to that sub-item (`data-active="child"`). */ hasActiveChild?: boolean /** Rendered as a router link instead of a button. */ @@ -421,7 +421,9 @@ export const sidebarMenuButton = ( return h.a( [ h.Href(options.link.href), - ...(linkActive ? [h.AriaCurrent("page"), h.DataAttribute("status", "active")] : []), + // One link is the current page: a section row leaves that to its current sub-item. + ...(linkActive && options.hasActiveChild !== true ? [h.AriaCurrent("page")] : []), + ...(linkActive ? [h.DataAttribute("status", "active")] : []), ...attributes, ], children, From f6754063a0f3b1c36046e4ce2ae80ce2d7b1fa6c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:40 +0100 Subject: [PATCH 21/35] fix(alerts): silence save button names its end on the 24h clock the picker uses --- .../alerts/hub/hub-silences.scene.test.ts | 27 +++++++++++++++++++ .../src/page/alerts/shared/silences.test.ts | 6 +++++ apps/web/src/page/alerts/shared/silences.ts | 14 +++++++--- .../src/page/alerts/silence-dialog/view.ts | 3 ++- packages/web-data/src/lib/timezone-format.ts | 10 +++++-- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 6dedbb673c..cfbb3daa93 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -359,6 +359,33 @@ describe("alerts hub, silence side panel", () => { ) }) + test("the save button names its end on the 24h clock the duration choice and the picker use", () => { + const until = (duration: SilenceDialog.SilenceDuration, value: string) => + Arr.reduce( + [ + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration })), + silenceMessage(SilenceDialog.Message.ChangedUntil({ value })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saveButton = role("button", { name: /^Silence until / }) + scene( + { update: pageUpdate, view }, + given(until("morning", "")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Until 09:00")).toExist(), + expectView(saveButton).toHaveText(/ 09:00$/u), + ) + // NOW is noon on 1 October, so 15:30 that day is still ahead. + scene( + { update: pageUpdate, view }, + given(until("custom", "2026-10-01T15:30")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(saveButton).toHaveText(/ 15:30$/u), + ) + }) + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model expect(entered.silenceDialog.dialog.isOpen).toBe(false) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index c482cf734a..0c2566425f 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -52,6 +52,12 @@ describe("silence times", () => { expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") }) + it("reads on a 24h clock when asked, midnight as 00", () => { + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 30), NOW, BERLIN, "h23")).toBe("15:30") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN, "h23")).toBe("Nov 7, 09:00") + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 23, 30), NOW, BERLIN, "h23")).toBe("Nov 7, 00:30") + }) + it("names the day of a window once when both ends share it", () => { const start = Date.UTC(2026, 10, 7, 14, 0) expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 5a5be70bf7..77db386276 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -70,9 +70,17 @@ export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_ export const isCancelledSilence = (silence: V2AlertSilence): boolean => startsAtMs(silence) === endsAtMs(silence) -/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ -export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { - const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) +/** + * "03:00 PM" today, "Nov 7, 09:00 AM" on another day, in the viewer's zone. `h23` reads "15:00", + * for a label beside the form's 24h time picker. + */ +export const formatSilenceTime = ( + ms: number, + nowMs: number, + timeZone: string, + hourCycle?: "h12" | "h23", +): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false, hourCycle }) return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) ? time : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 73e5106252..1c8b288f3e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -50,7 +50,8 @@ const submitLabel = (model: Model): string => : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { onNone: () => "Silence", onSome: (endMs) => - `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + // 24h, as the "Until 09:00" choice and the end's time picker read. + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone, "h23")}`, }) /** A field's error once a save was tried. */ diff --git a/packages/web-data/src/lib/timezone-format.ts b/packages/web-data/src/lib/timezone-format.ts index 5a3405a46d..e43e139dd7 100644 --- a/packages/web-data/src/lib/timezone-format.ts +++ b/packages/web-data/src/lib/timezone-format.ts @@ -80,13 +80,18 @@ export function formatTimestampInTimezone( export function formatTimeInTimezone( input: TimezoneFormatInput, - options: { timeZone: string; withSeconds?: boolean }, + options: { + timeZone: string + withSeconds?: boolean + /** A 24h clock (`h23`) where the time sits beside a 24h picker. */ + hourCycle?: "h12" | "h23" + }, ): string { const date = toValidDate(input) if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) - const key = `${tz}|${options.withSeconds ? "s" : ""}` + const key = `${tz}|${options.withSeconds ? "s" : ""}|${options.hourCycle ?? ""}` let formatter = timeFormatters.get(key) if (!formatter) { formatter = new Intl.DateTimeFormat("en-US", { @@ -94,6 +99,7 @@ export function formatTimeInTimezone( hour: "2-digit", minute: "2-digit", second: options.withSeconds ? "2-digit" : undefined, + hourCycle: options.hourCycle, }) timeFormatters.set(key, formatter) } From d599b1d7bf5d8f4cb42900281daf39de7ed35dde Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:43 +0100 Subject: [PATCH 22/35] fix(ui): make the sidebar row hover visible in the light theme surface-sunken sits under 1% from the light sidebar's grey. Rows now hover at half the selected tint (bg-sidebar-accent/50): visible in light, unchanged in dark. --- docs/design-rules.md | 3 ++- packages/ui/src/components/ui/sidebar.styles.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 7b109f0724..2ae063d8ae 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -98,7 +98,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi - Hover fills: a row, list item or chip hovers `bg-surface-sunken`, and a chip already on `surface-sunken` hovers `surface-strong`. A control (ghost button, toggle, tab, menu trigger) hovers `bg-accent`, and only the kit in `packages/ui` draws one. `hover:bg-muted` is never right - (`maple/surface-washes`). + (`maple/surface-washes`). A sidebar row hovers `bg-sidebar-accent/50`, half its selected tint: + the sidebar is already grey in the light theme, where a neutral wash does not show. - Hover outlines: a pickable card or chip hovers `border-border-strong`, a toned one its tone's `-a4` edge. `ring` is focus only, and `foreground` or a `/NN` tone is never a hover edge (`maple/hover-edge`). diff --git a/packages/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index 26c394eebe..13183bebcf 100644 --- a/packages/ui/src/components/ui/sidebar.styles.ts +++ b/packages/ui/src/components/ui/sidebar.styles.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority" export const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -14,9 +14,10 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { - // Hover is a neutral wash; the accent tint is the selected row's alone. + // Hover is the selected tint at half strength, so the full tint stays the selected row's. + // A neutral wash (`surface-sunken`) is under 1% from the light sidebar's own grey. default: - "hover:bg-surface-sunken active:bg-surface-sunken hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", + "hover:bg-sidebar-accent/50 active:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", outline: "bg-background ring-1 ring-sidebar-border hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground hover:ring-sidebar-accent active:ring-sidebar-accent", }, @@ -123,7 +124,7 @@ export const sidebarMenuSubItemStyles = "group/menu-sub-item relative" export const sidebarMenuSubButtonStyles = [ // 36px in the mobile sheet (below `md`, a touch target), 28px on the desktop rail. - "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ] as const From 717a12fa65eee712248d91d54cba66e9fb1841f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:04:29 +0100 Subject: [PATCH 23/35] fix(alerts): first-run Inbox and Rules leave the create actions to the empty state --- .../web/src/page/alerts/hub/hub.scene.test.ts | 37 +++++++++++++++++++ apps/web/src/page/alerts/hub/view.ts | 36 +++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 5d0c3508cb..a821b640e2 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -180,6 +180,43 @@ describe("alerts hub", () => { ) }) + test("an org without rules gets the create actions from the empty state alone, on the Inbox and on Rules", () => { + const newRule = role("link", { name: "New rule" }) + const firstRun = (at: HubAt, grants = ENGINEER): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + ], + entered(at).model, + (model, message) => pageUpdate(model, message).model, + ) + const tabs: ReadonlyArray<"inbox" | "rules"> = ["inbox", "rules"] + Arr.forEach(tabs, (tab) => { + scene( + { update: pageUpdate, view }, + given(firstRun({ tab })), + expectView(role("link", { name: "Start from scratch" })).toExist(), + expectView(role("button", { name: "Add destination" })).toExist(), + expectView(newRule).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), + ) + // A member's empty state offers nothing to create, so the header keeps its action. + scene( + { update: pageUpdate, view }, + given(firstRun({ tab }, Policy.grantsOf(["org:member"]))), + expectView(role("link", { name: "Start from scratch" })).not.toExist(), + expectView(newRule).toExist(), + ) + }) + // Still loading: nothing says the org is empty yet. + scene({ update: pageUpdate, view }, given(entered().model), expectView(newRule).toExist()) + }) + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { const inbox = loaded() expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 45ca0d6631..7278c9c6c1 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -180,12 +180,35 @@ const primaryLink = (h: H, href: string, label: string): Html => const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + /** * The header's one primary action: what the open section lists. A section whose empty state * already offers it shows none until it has rows, and a viewer who can't create it sees none. */ -const headerActions = (h: H, model: Model, tab: AlertsTab): Html | undefined => { - if (tab === "inbox" || tab === "rules") return newRuleButton(h, model) +const headerActions = ( + h: H, + model: Model, + tab: AlertsTab, + overview: Option.Option, +): Html | undefined => { + if (tab === "inbox" || tab === "rules") + // The first-run empty state offers the templates and "Start from scratch" to who can create. + return canManage(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab)) + ? undefined + : newRuleButton(h, model) if (!canManage(model)) return undefined const serviceName = model.search.serviceName switch (tab) { @@ -253,10 +276,6 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManage(model) ? Message.ClickedNewDestination() : undefined, }) - // An org with only monitors or SLOs has no visible rules but may have open incidents to show. - const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => - isManagedSignalType(incident.signalType), - ) const acknowledge = { onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), @@ -264,9 +283,8 @@ 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) + if (isFirstRun(model, data, "inbox")) return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), emptyState]) const filters = inboxFilters(h, { signal, @@ -335,7 +353,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab), + actions: headerActions(h, model, tab, overview), }, [ tab === "routing" From 994eb5af29e4cc31e154fab424f49649f34846f7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:05:14 +0100 Subject: [PATCH 24/35] fix(alerts): hub replaces away a ?sheet= that opens nothing --- .../alerts/hub/hub-silences.scene.test.ts | 34 +++++++++++++++++++ apps/web/src/page/alerts/hub/update.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index cfbb3daa93..01243dfb4b 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -423,6 +423,40 @@ describe("alerts hub, silence side panel", () => { ).toBe(false) }) + test("a `?sheet=` that opens nothing is replaced away: another section's panel, or a made-up one", () => { + const dismissals = (commands: Parameters[0]) => + Arr.filter(callsOf(commands), ([name]) => name === "DismissAlertsHubSheet") + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.search.sheet).toBeUndefined() + expect(dismissals(onRules.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts/rules?sheet=silence", id: "silence" }], + ]) + const onInbox = routeChanged(onSilences(ENGINEER, []), hubAt({ sheet: "silence" }), context) + expect(dismissals(onInbox.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts?sheet=silence", id: "silence" }], + ]) + const bogus = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "bogus" }), + context, + ) + expect(bogus.model.search.sheet).toBeUndefined() + expect(bogus.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(bogus.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=bogus`, id: "bogus" }], + ]) + // Asked once: the clock tick that follows finds nothing left to drop. + expect(dismissals(pageUpdate(bogus.model, Message.ReadClock({ nowMs: NOW })).commands)).toEqual([]) + // The open panel leaving its section closes with the param. + const left = routeChanged(withPanel().model, hubAt({ tab: "rules", sheet: "silence" }), context) + expect(left.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(left.commands)).toHaveLength(1) + }) + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) expect(cancelled.model.search.sheet).toBeUndefined() diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 89fb77bfeb..c559c3f07c 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -759,7 +759,19 @@ const silenceCommands = (step: ReturnType): Command * a viewer who may save. */ const followSilenceSheet = (model: Model, context: PageContext): Requested => { - const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" + const sheet = model.search.sheet + const isInUrl = sheet === SILENCE_SHEET && model.tab === "silences" + // A `?sheet=` nothing here opens (another section's panel, a made-up id) is replaced away. + if (sheet !== undefined && !isInUrl) { + const dropped = followSilenceSheet( + { ...model, search: { ...model.search, sheet: undefined } }, + context, + ) + return { + model: dropped.model, + commands: [...dropped.commands, DismissHubSheet({ href: hubHref(model), id: sheet })], + } + } const isOpen = model.silenceDialog.dialog.isOpen if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) From 9a1429a63fd8249e7eff2c4e850fe9610be454ca Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:14:22 +0100 Subject: [PATCH 25/35] fix(alerts): long schedule names truncate in the shifts heading and wrap in the delete confirm --- .../page/alerts/hub/hub-oncall.scene.test.ts | 32 +++++++++++++++++-- apps/web/src/page/alerts/hub/view-oncall.ts | 3 +- .../alerts/schedule/schedule.scene.test.ts | 24 +++++++++++++- .../page/alerts/shared/coverage-preview.ts | 15 +++++++-- 4 files changed, 68 insertions(+), 6 deletions(-) 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 index 37f8bc9d73..685ec689e8 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -3,9 +3,11 @@ import type { AlertsTab } from "../../../route/search" 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 { Result, Schema, Array as Arr, String as Str } from "effect" import * as Menu from "@maple/views/ui/menu" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { expect as expectView, given, Mount, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { chainDestinations, chainUuid } from "../shared/escalation-test-fixtures" @@ -220,6 +222,32 @@ describe("alerts hub, on-call schedules", () => { ) }) + test("the delete confirm is named for its schedule, a long unbroken name in full", () => { + const name = `checkout-primary-${Str.repeat(120)("x")}` + const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) + const asked = pageUpdate( + onTab( + "on-call", + ENGINEER, + Arr.map(SCHEDULES, (schedule) => + schedule.id === scheduleId ? { ...schedule, name } : schedule, + ), + ), + Message.GotRowMenuMessage({ + menuId: scheduleMenuId(scheduleId), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "delete" } }), + }), + ).model + expect(asked.confirmDeleteScheduleId).toBe(scheduleId) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("alertdialog", { name: `Delete ${name}?` })).toExist(), + expectView(within(role("alertdialog"), role("heading", { name: `Delete ${name}?` }))).toExist(), + ) + }) + test("a refused delete names the chains that still page the schedule", () => { const model = onTab("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 13c16cc1b9..fb9cc54903 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -267,7 +267,8 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { slotId: DELETE_SCHEDULE_DIALOG_ID, model: model.deleteScheduleDialog, toParentMessage: (message) => Message.GotDeleteScheduleDialogMessage({ message }), - title: `Delete ${name}?`, + // A long unbroken name wraps inside the dialog instead of running out of it. + title: [typography(h, { className: "wrap-anywhere" }, [`Delete ${name}?`])], description: "Its overrides go with it. This cannot be undone.", children: model.deleteScheduleError === null diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 9507a948a6..b9b58b1ffe 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -2,7 +2,7 @@ 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 { Option, Result, Schema, Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" import { isoMs, msIso } from "../shared/time" import { memberName } from "../shared/org-members" @@ -102,6 +102,28 @@ describe("on-call schedule page", () => { ) }) + test("the calendar's heading carries the whole schedule name as its title, for a name too long to show", () => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + const name = `checkout-primary-${Str.repeat(120)("x")}` + const long = run(entered.model, [ + 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), name }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(long), + expectView(role("heading", { name: `Shifts for ${name}` })).toHaveAttr( + "title", + `Shifts for ${name}`, + ), + ) + }) + test("the DST week runs Thursday to Wednesday on the Berlin clock, through the fall-back Sunday", () => { const model = loaded(DST_NOW) scene( diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 183b6c5263..138685b8ff 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -326,8 +326,19 @@ export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOp [h.Class("@container min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], [ hstack(h, { gap: "2", justify: "between", align: "start", wrap: true }, [ - vstack(h, { gap: "0" }, [ - heading(h, { level: 2, variant: "section" }, [options.title]), + // A set basis, not the title's own width: a long name truncates beside the controls + // while there is room for a readable one, and takes its own row when there is not. + vstack(h, { gap: "0", shrink: true, className: "grow basis-48" }, [ + heading( + h, + { + level: 2, + variant: "section", + className: "truncate", + attributes: [h.Title(options.title)], + }, + [options.title], + ), typography(h, { size: "xs", tone: "muted", as: "p" }, [ periodLabel(options.fromMs, options.toMs, options.timeZone), ]), From a8edc84ec4f4b59b6b375565ba37c5619560ce78 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:15:29 +0100 Subject: [PATCH 26/35] fix(alerts): coverage summary names the period it counts, not the next N days --- .../schedule-form/preview.scene.test.ts | 4 ++-- .../schedule-form/schedule-form.scene.test.ts | 24 ++++++++----------- .../alerts/schedule/schedule.scene.test.ts | 2 +- .../page/alerts/shared/coverage-preview.ts | 2 -- .../alerts/shared/schedule-coverage.test.ts | 24 +++++++++++-------- .../page/alerts/shared/schedule-coverage.ts | 19 ++++++--------- 6 files changed, 34 insertions(+), 41 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts index df5ffd7c15..e277485c83 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -56,13 +56,13 @@ test("typing the name keeps the drawn preview; a new range draws it again", () = scene( { update: pageUpdate, view }, given(staffed()), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), keep, type(role("textbox", { name: "Name" }), "Checkout primary"), type(role("textbox", { name: "Description (optional)" }), "Who swaps with whom"), keep, click(role("button", { name: "4 weeks" })), - expectView(text("Covered for the next 28 days")).toExist(), + expectView(text("Covered in this period")).toExist(), keep, ) expect(drawn[0]).not.toBeNull() 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 index d311bdc489..076c3e5de4 100644 --- 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 @@ -329,12 +329,12 @@ describe("schedule form", () => { }) describe("coverage preview", () => { - test("a layer with nobody in it leaves the next 14 days uncovered and says why", () => { + test("a layer with nobody in it leaves the period uncovered and says why", () => { scene( { update: pageUpdate, view }, given(ready()), expectView(text("Primary has no people, so it covers nothing.")).toExist(), - expectView(text("Nobody is on call for the next 14 days")).toExist(), + expectView(text("Nobody is on call in this period")).toExist(), expectView(text("Add people to see shifts")).toExist(), // One gap that is the whole range has no link of its own. expectView(role("button", { name: /^From /u })).not.toExist(), @@ -347,7 +347,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(staffed), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), // A new layer starts today at 09:00, which is not worth a note even after 09:00. expectView(text(/started before today/u)).not.toExist(), expectView(role("rowheader", { name: /Final schedule/u })).toExist(), @@ -358,9 +358,7 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(nights), expectView( - text( - "10 gaps in the next 14 days, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)", - ), + text("10 gaps in this period, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)"), ).toExist(), expectView(role("button", { name: "Sat 14 Nov 08:00 to Mon 16 Nov 18:00" })).toExist(), ) @@ -380,8 +378,8 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(noDays), expectView(text("Fix this layer to see shifts")).toExist(), - expectView(text("Nobody is on call for the next 14 days")).toExist(), - expectView(text("Covered for the next 14 days")).not.toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Covered in this period")).not.toExist(), ) }) @@ -431,7 +429,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(staffed), - expectView(text("Covered for the next 7 days")).toExist(), + expectView(text("Covered in this period")).toExist(), expectView(text("10 Nov to 16 Nov")).toExist(), ) const later = run(staffed, [Message.ClickedPreviewNext()]) @@ -460,7 +458,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(run(ready(), [addPerson("l0", 0)])), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), ) }) @@ -470,7 +468,7 @@ describe("schedule form", () => { given(weeknightsOnly(ready({ scheduleId: schedulePublicId(1) }))), expectView(text("Coverage preview")).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), - expectView(text(/gaps? in the next/u)).not.toExist(), + expectView(text(/gaps? in this period/u)).not.toExist(), expectView(text(/^Covered/u)).not.toExist(), expectView(text(/^Nobody is on call/u)).not.toExist(), ) @@ -496,9 +494,7 @@ describe("schedule form", () => { expectView(role("button", { name: "Sat 14 Nov 12:00 to Mon 16 Nov 18:00" })).toExist(), // The first weekend is partly covered now, so the second is the longest gap. expectView( - text( - "10 gaps in the next 14 days, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)", - ), + text("10 gaps in this period, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)"), ).toExist(), expectView(text(/Overrides are not shown/u)).not.toExist(), expectView(text(/Overrides could not be loaded/u)).not.toExist(), diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index b9b58b1ffe..575b910021 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -482,7 +482,7 @@ describe("on-call schedule page", () => { scene( { update: pageUpdate, view }, given(model), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), expectView(role("group", { name: "Range" })).toExist(), ) const month = pageUpdate(model, Message.PickedRange({ days: "28" })) diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 138685b8ff..de9569b886 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -212,8 +212,6 @@ const gapLinks = (h: HtmlBuilder, options: CoveragePreviewOptions): Htm const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { const scope = { timeZone: options.timeZone, - weekOffset: options.weekOffset, - days: Number(options.rangeDays), checkedFromMs: Math.max(options.fromMs, options.nowMs), checkedToMs: options.toMs, } diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index b459ff3bee..3a3821353e 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -222,34 +222,38 @@ describe("previewRange", () => { describe("gapSummary", () => { const scope = { timeZone: "Europe/Berlin", - weekOffset: 0, - days: 14, checkedFromMs: at("2026-11-10T10:30:00Z"), checkedToMs: at("2026-11-23T23:00:00Z"), } it("says covered when there are no gaps", () => { - expect(gapSummary([], scope)).toBe("Covered for the next 14 days") - expect(gapSummary([], { ...scope, weekOffset: 2 })).toBe("Covered in this period") + expect(gapSummary([], scope)).toBe("Covered in this period") + }) + it("names the range drawn, not a count of days: from now, a 14-day range ends short of 14 days", () => { + const nowMs = at("2026-11-10T10:30:00Z") + const range = previewRange(nowMs, "Europe/Berlin", 0, 14) + const today = { timeZone: "Europe/Berlin", checkedFromMs: nowMs, checkedToMs: range.endMs } + // What is counted stops at the range's last midnight, 11.5 hours before 14 days from now. + expect(range.endMs - nowMs).toBe(14 * 24 * HOUR - 11.5 * HOUR) + const late = { startMs: range.endMs - 2 * HOUR, endMs: range.endMs - HOUR } + expect(gapSummary([late], today)).toBe("1 gap in this period, longest 1h (Mon 23 Nov 22:00 to 23:00)") + expect(gapSummary([], today)).toBe("Covered in this period") }) it("counts gaps and names the longest in the schedule's zone", () => { const gaps = [ { startMs: at("2026-11-19T23:00:00Z"), endMs: at("2026-11-20T01:00:00Z") }, { startMs: at("2026-11-20T23:00:00Z"), endMs: at("2026-11-21T15:00:00Z") }, ] - expect(gapSummary(gaps, scope)).toBe( - "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", - ) + expect(gapSummary(gaps, scope)).toBe("2 gaps in this period, longest 16h (Sat 21 Nov 00:00 to 16:00)") }) it("says nobody is on call when the one gap is everything checked", () => { const whole = [{ startMs: scope.checkedFromMs, endMs: scope.checkedToMs }] - expect(gapSummary(whole, scope)).toBe("Nobody is on call for the next 14 days") - expect(gapSummary(whole, { ...scope, weekOffset: 1 })).toBe("Nobody is on call in this period") + expect(gapSummary(whole, scope)).toBe("Nobody is on call in this period") }) it("gives a gap cut off by the range's end no end time", () => { const closed = { startMs: at("2026-11-12T08:00:00Z"), endMs: at("2026-11-12T10:00:00Z") } const open = { startMs: at("2026-11-21T07:00:00Z"), endMs: scope.checkedToMs } expect(gapSummary([closed, open], scope)).toBe( - "2 gaps in the next 14 days, longest 2d 16h or longer (from Sat 21 Nov 08:00)", + "2 gaps in this period, longest 2d 16h or longer (from Sat 21 Nov 08:00)", ) expect(gapRangeText(closed, "Europe/Berlin", scope.checkedToMs)).toBe("Thu 12 Nov 09:00 to 11:00") expect(gapRangeText(open, "Europe/Berlin", scope.checkedToMs)).toBe("from Sat 21 Nov 08:00") diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts index f4c206818a..6788ffa3df 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -224,11 +224,9 @@ export const longestGap = (gaps: ReadonlyArray): Option.Option, scope: GapScope): string => { - const isFromToday = scope.weekOffset === 0 - if (isUncovered(gaps, scope)) - return isFromToday - ? `Nobody is on call for the next ${scope.days} days` - : "Nobody is on call in this period" + if (isUncovered(gaps, scope)) return "Nobody is on call in this period" return Option.match(longestGap(gaps), { - onNone: () => (isFromToday ? `Covered for the next ${scope.days} days` : "Covered in this period"), + onNone: () => "Covered in this period", onSome: (longest) => - `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${isFromToday ? `in the next ${scope.days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} in this period, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, }) } From fc1ac22c51b7a210d4827f5b88e486db3d4365e6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:17:46 +0100 Subject: [PATCH 27/35] test(e2e): a saved rule returns to the Rules section --- apps/web/e2e/tests/alert-rules.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index feeef87386..4c8a829eba 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -87,7 +87,7 @@ test.describe("alert rules", () => { await screen.getByLabel("Rule name").fill(`e2e tiers renamed ${tag}`) await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const renamed = await read() expect(renamed.body.name).toBe(`e2e tiers renamed ${tag}`) expect(renamed.body.thresholds).toEqual(tiers) @@ -96,7 +96,7 @@ test.describe("alert rules", () => { await app.open(editUrl) await screen.getByLabel("Critical threshold").fill("4") await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const retuned = await read() expect(Arr.map(retuned.body.thresholds, (tier) => tier.value)).toEqual([0.007, 0.04]) } finally { From c5adf7d3ee85daf6465291b58b127303ca53d180 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:29:06 +0100 Subject: [PATCH 28/35] fix(shell): an org without schedules is asked again hourly, so an open tab finds the first one --- apps/web/src/shell/oncall.scene.test.ts | 3 ++- apps/web/src/shell/oncall.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 4409b856db..77154754f3 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -240,7 +240,8 @@ describe("on-call reads", () => { expectVitest(commandsOf(empty)).toEqual([ { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: NOW } }, ]) - expectVitest(empty.model.onCallNextReadAtMs).toBeNull() + // Asked again within the hour, so a tab left open and focused still finds a first schedule. + expectVitest(empty.model.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) expectVitest(commandsOf(booted)).toContainEqual({ name: "LoadOnCall", diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index bdb87b1a88..3f00fcfc44 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -131,15 +131,16 @@ const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { } /** - * After an answer: at the next handoff, or within the hour while the user is off call; a handoff - * the answer already places in the past (the API has not caught up) retries with backoff. + * After an answer: at the next handoff, or within the hour while the user is off call or the org + * has no schedules; a handoff the answer already places in the past retries with backoff. */ export const scheduleAfterAnswer = ( snapshot: OnCallSnapshot, nowMs: number, backoffMs: number, ): OnCallSchedule => { - if (snapshot.shifts.length === 0) return { nextReadAtMs: null, backoffMs: 0 } + // No schedules yet: a teammate may make the first one while this tab stays open and focused. + if (snapshot.shifts.length === 0) return { nextReadAtMs: nowMs + ONCALL_IDLE_READ_MS, backoffMs: 0 } const handoff = nextHandoffMs(snapshot) if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" From 655534efd3e3c034e426fd95c00ef04adcf38666 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:30:36 +0100 Subject: [PATCH 29/35] docs(design-rules): list the form sheet size --- docs/design-rules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 2ae063d8ae..004863639b 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -35,9 +35,9 @@ only shrink (`bun run ui:patterns:check` in CI). The workflow is in `apps/web/CL of the scroll area, status or Back on the left, buttons on the right, primary last. The settings `saveBar` is an `actionBar` that shows while edits are unsaved. A hand-built sticky bottom bar is flagged (`maple/no-handrolled-kit-look`). -- A side sheet's width is its `size`, picked by what it holds: `default` a form or filters, - `detail` a peek at one item, `wide` a log, review or guide, `full` a trace waterfall. Never a - `max-w-*` in its className (`maple/sheet-size`). +- A side sheet's width is its `size`, picked by what it holds: `default` a filter list or a few + fields, `detail` a peek at one item, `form` a create or edit form, `wide` a log, review or guide, + `full` a trace waterfall. Never a `max-w-*` in its className (`maple/sheet-size`). ## Type and spacing From 479e46c6cb3d1e9b9877264d74530423bf3a7ca2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:44:08 +0100 Subject: [PATCH 30/35] fix(shell): a folded group keeps the current sub-item; on-call is re-read within the hour whoever holds it --- apps/web/src/shell/nav-groups.scene.test.ts | 12 +++++++----- apps/web/src/shell/oncall.scene.test.ts | 8 ++++++-- apps/web/src/shell/oncall.ts | 19 ++++++++----------- apps/web/src/shell/view-nav.ts | 17 ++++++++++++----- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 558131f7d7..2a959129a2 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -92,7 +92,7 @@ describe("sidebar groups", () => { ) }) - test("a folded group on a sub-item's page shows the parent row alone, highlighted", () => { + test("a folded group on a sub-item's page keeps the section row and that sub-item alone", () => { const explore = role("link", { name: "Explore" }) const logs = role("link", { name: "Logs" }) scene( @@ -104,14 +104,15 @@ describe("sidebar groups", () => { expect(explore).toHaveAttr("data-active", "child"), click(analyze), Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), - expect(explore).toHaveAttr("data-active", "true"), - expect(logs).toBeAbsent(), + // Still Logs: the section row alone would link to Traces and name no current page. + expect(logs).toHaveAttr("data-active", "true"), + expect(explore).toHaveAttr("data-active", "child"), expect(role("link", { name: "Traces" })).toBeAbsent(), expect(dashboards).toBeAbsent(), ) }) - test("one link is the current page: the sub-item while its section is open, the section once folded", () => { + test("one link is the current page: the sub-item, with its group open or folded", () => { const explore = role("link", { name: "Explore" }) const current = all.role("link", { current: "page" }) scene( @@ -125,7 +126,8 @@ describe("sidebar groups", () => { click(analyze), Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), expectAll(current).toHaveCount(1), - expect(explore).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), ) }) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 77154754f3..b3362e79f3 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -197,9 +197,13 @@ describe("on-call reads", () => { expectVitest(stale.model.onCall._tag).toBe("Loading") }) - test("an answer reads again after the next handoff, or within the hour while the user is off call", () => { + test("an answer reads again after the next handoff, and within the hour whoever is on call", () => { + const soon = NOW + 30 * 60_000 + const handingOff = update(signedIn(), answer([shift({ endsAtMs: soon })])).model + expectVitest(handingOff.onCallNextReadAtMs).toBe(soon + 5_000) + // A handoff hours away: a teammate's override can still change who is on call before it. const onCall = update(signedIn(), answer([shift()])).model - expectVitest(onCall.onCallNextReadAtMs).toBe(HANDOFF + 5_000) + expectVitest(onCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) const offCall = update(signedIn(), answer([shift({ userId: "user_someone", endsAtMs: null })])).model expectVitest(offCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 3f00fcfc44..fd60b46d98 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -113,7 +113,7 @@ export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { const HANDOFF_SETTLE_MS = 5_000 export const ONCALL_RETRY_MS = 60_000 const ONCALL_RETRY_CAP_MS = 15 * 60_000 -/** A shift starting out of a gap shows up in no handoff, so a user off call checks hourly. */ +/** A new shift, override or first schedule shows up in no handoff the tab knows, so it checks hourly. */ export const ONCALL_IDLE_READ_MS = 60 * 60_000 /** Focus re-reads at most this often. */ export const ONCALL_WAKE_MIN_MS = 60_000 @@ -131,24 +131,21 @@ const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { } /** - * After an answer: at the next handoff, or within the hour while the user is off call or the org - * has no schedules; a handoff the answer already places in the past retries with backoff. + * After an answer: at the next handoff, and within the hour whatever it said (a teammate's override + * or first schedule sends this tab no event); a handoff already in the past retries with backoff. */ export const scheduleAfterAnswer = ( snapshot: OnCallSnapshot, nowMs: number, backoffMs: number, ): OnCallSchedule => { - // No schedules yet: a teammate may make the first one while this tab stays open and focused. - if (snapshot.shifts.length === 0) return { nextReadAtMs: nowMs + ONCALL_IDLE_READ_MS, backoffMs: 0 } const handoff = nextHandoffMs(snapshot) if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) - const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" - const candidates = [ - ...(handoff === null ? [] : [handoff + HANDOFF_SETTLE_MS]), - ...(isOnCall ? [] : [nowMs + ONCALL_IDLE_READ_MS]), - ] - return { nextReadAtMs: candidates.length === 0 ? null : Math.min(...candidates), backoffMs: 0 } + const idle = nowMs + ONCALL_IDLE_READ_MS + return { + nextReadAtMs: handoff === null ? idle : Math.min(handoff + HANDOFF_SETTLE_MS, idle), + backoffMs: 0, + } } /** After a failed read: retry from a minute, doubling, capped. */ diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 90d8ca7ff4..c7e3c94ecb 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -148,16 +148,21 @@ const navRow = ( ): Html => { const isActive = isNavItemActive(currentPath, item) const isCollapsed = !model.isSidebarOpen && !model.isMobile - const subItems = + const allSubItems = item.subItems === undefined ? undefined : partitionSubItems(item.subItems, surfacesOf(model), currentPath).shown - if (isCollapsed && subItems !== undefined && subItems.length > 0) + if (isCollapsed && allSubItems !== undefined && allSubItems.length > 0) return collapsedSectionRow(h, model, item, isActive) - const isOpen = subItems !== undefined && subItems.length > 0 && isActive && !isGroupFolded + // A folded group keeps where you are: the section row and, under it, the current sub-item alone. + const subItems = + isGroupFolded && allSubItems !== undefined + ? Arr.filter(allSubItems, (sub) => sub.href === activeSubHrefOf(allSubItems, currentPath)) + : allSubItems + const isOpen = subItems !== undefined && subItems.length > 0 && isActive // The tint belongs to the sub-item that is the current page, when one is. const hasActiveChild = isOpen && activeSubHrefOf(subItems, currentPath) !== undefined - const preview = isOpen || subItems === undefined ? undefined : navPreview(item, subItems) + const preview = isOpen || allSubItems === undefined ? undefined : navPreview(item, allSubItems) return sidebarMenuItem(h, undefined, [ sidebarMenuButton( h, @@ -206,7 +211,9 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive && !isGroupFolded ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && (subItems.length > 0 || !isGroupFolded) + ? [subList(h, subItems, currentPath)] + : []), ]) } From 7360717b9b52481c217b63e59733ea49ba70fe5c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:44:53 +0100 Subject: [PATCH 31/35] docs(shell): navRow's comment follows the folded group's current sub-item --- apps/web/src/shell/view-nav.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index c7e3c94ecb..aff7e6f38e 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -136,7 +136,7 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c /** * `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. In a - * folded group the current page's row stands alone, highlighted, without its children. + * folded group the current section keeps its row and, of its children, the current one alone. */ const navRow = ( h: HtmlBuilder, From 72e28544e2fc080d89f6a090c2b34e406d992163 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 15:35:13 +0100 Subject: [PATCH 32/35] fix(alerts): the schedule editor's preview follows the clock while it stays open --- .../src/page/alerts/schedule-form/index.ts | 22 ++++++++++++++++++- .../src/page/alerts/schedule-form/message.ts | 1 + .../schedule-form/preview.scene.test.ts | 14 ++++++++++++ .../src/page/alerts/schedule-form/update.ts | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/schedule-form/index.ts b/apps/web/src/page/alerts/schedule-form/index.ts index 6dcd7b46cc..f429112cfa 100644 --- a/apps/web/src/page/alerts/schedule-form/index.ts +++ b/apps/web/src/page/alerts/schedule-form/index.ts @@ -1,10 +1,29 @@ -import { definePage } from "../../contract" +import { Clock, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } 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" +/** The preview's now line follows the clock, on the schedule page'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/new`: one instance per schedule being edited, or the new one. */ export const page = definePage( "AlertsScheduleNew", @@ -15,6 +34,7 @@ export const page = definePage( init, update, view, + subscriptions, 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 index 53db9f5d13..2bec9aea04 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -21,6 +21,7 @@ export type LayerField = typeof LayerField.Type export const Message = defineMessageUnion({ ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, + ReadClock: { nowMs: Schema.Number }, CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, ClickedRetryExisting: {}, CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, diff --git a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts index e277485c83..4a227fd9f5 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -69,3 +69,17 @@ test("typing the name keeps the drawn preview; a new range draws it again", () = expect(drawn[1]).toBe(drawn[0]) expect(drawn[2]).not.toBe(drawn[0]) }) + +test("the clock moves the preview's now line while the editor stays open", () => { + scene( + { update: pageUpdate, view }, + given(staffed()), + expectView(text("Now, Tue 10 Nov, 11:30")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(pageUpdate(staffed(), Message.ReadClock({ nowMs: ONCALL_NOW + 45 * 60_000 })).model), + expectView(text("Now, Tue 10 Nov, 12:15")).toExist(), + expectView(text("Now, Tue 10 Nov, 11:30")).toBeAbsent(), + ) +}) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index 01b868656f..2ee141aa8e 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -225,6 +225,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model: ready } : { model: syncZone({ ...ready, form: emptyForm(timeZone, nowMs) }) } }, + // The preview's now line and the gaps counted from it follow the clock while the editor is open. + ReadClock: ({ nowMs }) => ({ model: model.isReady ? { ...model, nowMs } : model }), CompletedFetchExisting: ({ key, result }) => { const existing = Keyed.accept(model.existing, key, result) const loaded = Option.getOrNull(AsyncData.getData(existing.data)) From 63832b866819bbaad284e327753700657a0a3a56 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 15:44:38 +0100 Subject: [PATCH 33/35] fix(alerts): schedule editor goes two-column only once the timeline fits unscrolled --- apps/web/src/page/alerts/schedule-form/view.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 05e63085ab..8246326cba 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -561,12 +561,12 @@ export const view = Submodel.defineView((model, formScrollArea( h, [ - // From 46rem of room (a 1024px screen beside the sidebar) the form keeps a narrow column and - // the preview takes the rest, held in view while the form scrolls. Under that it follows the form. + // From 58rem of room (the form column plus the 30rem the timeline needs unscrolled) the form + // keeps a narrow column and the preview takes the rest, held in view. Under that it follows the form. h.div( [ h.Class( - "grid gap-4 @min-[46rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[46rem]/scroll:items-start", + "grid gap-4 @min-[58rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[58rem]/scroll:items-start", ), ], [ @@ -578,7 +578,7 @@ export const view = Submodel.defineView((model, h.div( [ h.Class( - "min-w-0 rounded-md border bg-card p-4 @min-[46rem]/scroll:sticky @min-[46rem]/scroll:top-0 @min-[46rem]/scroll:max-h-[100cqh] @min-[46rem]/scroll:overflow-y-auto", + "min-w-0 rounded-md border bg-card p-4 @min-[58rem]/scroll:sticky @min-[58rem]/scroll:top-0 @min-[58rem]/scroll:max-h-[100cqh] @min-[58rem]/scroll:overflow-y-auto", ), ], [schedulePreview(h, model)], From 00a94199aec66307aa025e0e0363f36c1176c81e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 15:46:44 +0100 Subject: [PATCH 34/35] fix(alerts): count the gap and the preview's padding in the schedule editor's two-column breakpoint --- apps/web/src/page/alerts/schedule-form/view.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 8246326cba..1f2cad0102 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -561,12 +561,13 @@ export const view = Submodel.defineView((model, formScrollArea( h, [ - // From 58rem of room (the form column plus the 30rem the timeline needs unscrolled) the form - // keeps a narrow column and the preview takes the rest, held in view. Under that it follows the form. + // From 59rem of room (the form column, the gap, the preview's padding and the 30rem the timeline + // needs unscrolled) the form keeps a narrow column and the preview takes the rest, held in view. + // Under that it follows the form. h.div( [ h.Class( - "grid gap-4 @min-[58rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[58rem]/scroll:items-start", + "grid gap-4 @min-[59rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[59rem]/scroll:items-start", ), ], [ @@ -578,7 +579,7 @@ export const view = Submodel.defineView((model, h.div( [ h.Class( - "min-w-0 rounded-md border bg-card p-4 @min-[58rem]/scroll:sticky @min-[58rem]/scroll:top-0 @min-[58rem]/scroll:max-h-[100cqh] @min-[58rem]/scroll:overflow-y-auto", + "min-w-0 rounded-md border bg-card p-4 @min-[59rem]/scroll:sticky @min-[59rem]/scroll:top-0 @min-[59rem]/scroll:max-h-[100cqh] @min-[59rem]/scroll:overflow-y-auto", ), ], [schedulePreview(h, model)], From 58a34f0a1524f97fb610b13a54170249ef11d52d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 15:53:33 +0100 Subject: [PATCH 35/35] fix(alerts): schedule editor's two-column breakpoint also clears the preview's borders --- apps/web/src/page/alerts/schedule-form/view.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 1f2cad0102..d0ec57da45 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -561,13 +561,13 @@ export const view = Submodel.defineView((model, formScrollArea( h, [ - // From 59rem of room (the form column, the gap, the preview's padding and the 30rem the timeline - // needs unscrolled) the form keeps a narrow column and the preview takes the rest, held in view. - // Under that it follows the form. + // From 60rem of room (the form column, the gap, the preview's padding and borders, and the 30rem + // the timeline needs unscrolled, rounded up) the form keeps a narrow column and the preview takes + // the rest, held in view. Under that it follows the form. h.div( [ h.Class( - "grid gap-4 @min-[59rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[59rem]/scroll:items-start", + "grid gap-4 @min-[60rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[60rem]/scroll:items-start", ), ], [ @@ -579,7 +579,7 @@ export const view = Submodel.defineView((model, h.div( [ h.Class( - "min-w-0 rounded-md border bg-card p-4 @min-[59rem]/scroll:sticky @min-[59rem]/scroll:top-0 @min-[59rem]/scroll:max-h-[100cqh] @min-[59rem]/scroll:overflow-y-auto", + "min-w-0 rounded-md border bg-card p-4 @min-[60rem]/scroll:sticky @min-[60rem]/scroll:top-0 @min-[60rem]/scroll:max-h-[100cqh] @min-[60rem]/scroll:overflow-y-auto", ), ], [schedulePreview(h, model)],