From 2bcbbf01a7a244149ef650a9861b4dbeadf1c764 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:08:03 +0100 Subject: [PATCH 01/13] 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/13] 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 58883959f0d6f11e220a44eb44de2b1cf69e9648 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:03:18 +0100 Subject: [PATCH 03/13] 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 ef301707e6e647cadfdd939c6c15e5e91e4c2a1f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:55:35 +0100 Subject: [PATCH 04/13] 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 f9d010fc46c278ef136e3e561fcb662ff917c012 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:57 +0100 Subject: [PATCH 05/13] 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 06/13] 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 2b33c5e0b86c640ea5fc01253e111734ea90db8f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 07/13] 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 08/13] 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 d599b1d7bf5d8f4cb42900281daf39de7ed35dde Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:43 +0100 Subject: [PATCH 09/13] 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 c5adf7d3ee85daf6465291b58b127303ca53d180 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:29:06 +0100 Subject: [PATCH 10/13] 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 11/13] 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 12/13] 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 13/13] 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,