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..ab51c1c6c3 --- /dev/null +++ b/apps/web/src/lab/entries/shell.ts @@ -0,0 +1,57 @@ +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, toTooltip, update, view } from "../shell-states-lab" + +// `/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 + +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 }), + // 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/registry.ts b/apps/web/src/lab/registry.ts index 278d123e6c..1c123999df 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -507,6 +507,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..7499d93bff --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -0,0 +1,141 @@ +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 { Array as Arr } from "effect" +import { MeasureSidebarOverflow } from "../shell/mounts" +import { Message as ShellMessage } from "../shell/message" +import { + init, + Message, + type Model, + COMPACT_SHEET_ID, + DismissSheetUrl, + PushSheetUrl, + 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 dismisses it through the URL", () => { + 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(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), + ) + }) + + 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({ href: "/lab/shell", id: 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("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" })) + expectVitest(back.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( + DismissSheetUrl.name, + ) + }) + + 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), + ) + }) +}) 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..49d780bef8 --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.ts @@ -0,0 +1,375 @@ +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 { 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, + type SheetStep, + sidePanelSheet, +} from "@maple/views/ui/sheet" +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" +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" +import { foldLabShell, labShellView } from "./shell-frame" + +// `/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", "onCallFar", "notOnCall", "none"]) +export type OnCallFixture = typeof OnCallFixture.Type + +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: SHIFTS[fixture], +}) + +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, + /** The panel the sheet shows; kept through its exit transition. */ + panel: Panel, + tooltip: TooltipHost.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 }, + GotTooltipMessage: { message: TooltipHost.Message }, + /** The URL changed (a push, a replace, Back or Forward). */ + ChangedUrl: { href: Schema.String }, + ClickedOpenPanel: { panel: Panel }, + ClickedSubmitPanel: {}, + ClickedOnCallFixture: { fixture: OnCallFixture }, + ClickedStandOn: { standOn: StandOn }, + CompletedNavigate: {}, +}) +export type Message = typeof Message.Type + +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: sheetArgs, + messages: [Message.CompletedNavigate], + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** 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: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), +}) + +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) + +/** 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: 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 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", + }, + 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)), + 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 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), + } + }, + ClickedOpenPanel: ({ panel }) => ({ + model, + commands: [PushSheetUrl({ href: model.href, id: PANEL_ID[panel] })], + }), + ClickedSubmitPanel: () => 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]) + +/** The `form` size: a full create form. */ +const rulePanel = (h: HtmlBuilder, model: Model): Html => + sidePanelSheet(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: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), + 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"]), + ], + }) + +const page = (h: HtmlBuilder, model: Model): Html => + dashboardPage( + h, + { + title: "Shell states", + 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" }, [ + 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: "onCallFar", label: "On call, next week" }, + { 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)" }, + { value: "services", label: "Services (Monitor)" }, + { value: "logs", label: "Logs (Analyze)" }, + ], + }), + ), + ]), + model.panel === "snooze" ? snoozePanel(h, model) : rulePanel(h, model), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + [ + 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/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 new file mode 100644 index 0000000000..09cfb5f800 --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.ts @@ -0,0 +1,54 @@ +import { Effect, Option, Schema } from "effect" +import { back, replaceUrl } from "foldkit/navigation" + +/** + * 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" + +/** 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 !== "") + +/** The history entry state `pushSheetUrl` writes. */ +export const SheetHistoryState = Schema.Struct({ mapleSheet: Schema.String }) +const decodeSheetState = Schema.decodeUnknownOption(SheetHistoryState) + +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 538bb36529..3e49bbf34e 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,48 @@ 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() + }), +}) + +/** `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), which `readShellFlags` reads at boot. */ 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..f9dbf7960e 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -7,6 +7,11 @@ 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" +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 @@ -78,3 +83,48 @@ 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), 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: { orgId: Schema.String, noSchedulesCheckedAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], + 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, + userId: schedule.on_call_now?.user_id ?? null, + endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), + })), + }, + }) + }).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 61875459bf..ec32a46300 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -3,7 +3,9 @@ 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, + NO_SCHEDULES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, THEME_STORAGE_KEY, @@ -61,6 +63,18 @@ 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), () => []) + +/** 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), +) + /** `SaveSidebarState`'s cookie: open unless the last toggle collapsed it (as Maple Local reads it). */ export const isSidebarOpenIn = (cookie: string): boolean => !Arr.contains(Str.split("; ")(cookie), "sidebar_state=false") @@ -76,6 +90,8 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () theme: readTheme(), 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 364fd9e910..18bb2b2627 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -9,6 +9,10 @@ export { Platform } from "./palette/shortcuts" export { favoritesChanges, filterViewportChanges, + onCallChanges, + onCallReadAt, + onCallTimer, + onCallWakes, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 4a2c263971..966f002435 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,24 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + /** `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: {}, 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 f501b6a745..8f620568fb 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,18 @@ 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), + /** 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, @@ -135,6 +149,8 @@ export type ShellFlags = Readonly<{ theme: Theme pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray + collapsedNavGroups?: ReadonlyArray + noScheduleOrgs?: Readonly> rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -159,6 +175,12 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, 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 new file mode 100644 index 0000000000..2a959129a2 --- /dev/null +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -0,0 +1,151 @@ +import { Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +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" +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("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()), + measured, + 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("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( + { 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()), + // 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, with its group open or 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(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), + ) + }) + + 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(), + ) + }) + + 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..b3362e79f3 --- /dev/null +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -0,0 +1,332 @@ +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 { Message } from "./message" +import { init, type Model, OnCallData } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { + isNoSchedulesFresh, + nextHandoffMs, + NO_SCHEDULES_TTL_MS, + 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: 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 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", + userId: "user_me", + endsAtMs: HANDOFF, + ...overrides, +}) + +const snapshotOf = (shifts: ReadonlyArray): OnCallSnapshot => ({ + userId: "user_me", + viewerTimeZone: "Europe/Berlin", + shifts, +}) + +const signedIn = (overrides: Partial = {}): Model => ({ + ...init(flags), + maybeOrgId: Option.some(ORG), + hasRequestedSidebarData: true, + ...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 viewAt = (currentPath: string) => { + const frame = withViewInputs(frameView, { + 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()) + +describe("on-call pill", () => { + 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(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("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(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("Not on call")).toBeAbsent(), + expect(text("On call until Mon 09:00")).toBeAbsent(), + ] + scene({ update, view }, given(withOnCall([])), measured, ...absent) + scene( + { update, view }, + given(signedIn({ onCall: OnCallData.Failure({ error: "Failed" }) })), + measured, + ...absent, + ) + scene( + { update, view }, + given(withOnCall([shift()], { onCallNowMs: HANDOFF + 1 })), + measured, + ...absent, + ) + }) + + 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( + { update, view }, + given(withOnCall([shift()], { isSidebarOpen: false })), + measured, + 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()), + 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( + "On call until Mon 09:00 CET, 16 Nov · Checkout primary", + ), + ) + }) +}) + +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") + }) + + 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(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) + }) + + 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 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 } }, + ]) + // 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", + 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 } }, + ]) + }) + + 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([]) + }) +}) + +describe("onCallStatusOf", () => { + 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: { short: "Mon 09:00", rest: "CET, 16 Nov" }, + }) + }) + + 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(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 new file mode 100644 index 0000000000..fd60b46d98 --- /dev/null +++ b/apps/web/src/shell/oncall.ts @@ -0,0 +1,191 @@ +import { Array as Arr, Option, Order, Schema } from "effect" + +// 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, + userId: Schema.NullOr(Schema.String), + endsAtMs: Schema.NullOr(Schema.Number), +}) +export type OnCallShift = typeof OnCallShift.Type + +/** 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" + scheduleName: string + /** Other schedules the user also holds now. */ + otherSchedules: number + /** 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() + +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 ?? + 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 }, + ) + 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) + +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 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(live), { + onNone: (): OnCallStatus => (held.length > 0 ? { _tag: "Unknown" } : { _tag: "NotOnCall" }), + onSome: (shift) => ({ + _tag: "OnCall", + scheduleName: shift.scheduleName, + otherSchedules: live.length - 1, + until: + shift.endsAtMs === null ? null : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone, nowMs), + }), + }) +} + +/** 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) +} + +// 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 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 +/** + * 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 }> + +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, 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 => { + const handoff = nextHandoffMs(snapshot) + if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) + 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. */ +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 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} more` + +/** 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 status.until === null ? "On call" : `On call until ${status.until.short}` + case "NotOnCall": + 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/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index bc9e7aade1..f395917878 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", 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 480f26a23c..210f70f782 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,8 +1,10 @@ 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 { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" +import type { Model } from "./model" +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)" @@ -82,3 +84,44 @@ 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; `null` while one is in flight or none is due. */ +export const onCallReadAt = (model: Model): number | null => model.onCallNextReadAtMs + +/** 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))) + return Message.ElapsedOnCallTimer({ nowMs: yield* Clock.currentTimeMillis }) + }), + ) + +/** The tab came back into view or focus (a handoff may have passed while it slept). */ +// 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 })), +) + +/** A page wrote a schedule or an override. */ +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(), + }) +}) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 66acbbe8eb..269f59789c 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -11,12 +11,14 @@ import { FocusSiblingMenuItem, FocusShortcutSearch, LogOut, + SaveCollapsedNavGroups, + SaveNoSchedules, 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" @@ -26,6 +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 { + isNoSchedulesFresh, + ONCALL_WAKE_MIN_MS, + type OnCallSnapshot, + scheduleAfterAnswer, + scheduleAfterFailure, +} from "./oncall" import { DashboardsData, InfraPresenceData, @@ -34,6 +43,7 @@ import { initRightSheet, type Model, NamespacesData, + OnCallData, shellProbe, SourcesData, } from "./model" @@ -61,6 +71,9 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + onCall: () => OnCallData.Idle(), + onCallNextReadAtMs: () => null, + onCallBackoffMs: () => 0, namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -84,12 +97,120 @@ 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 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, 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. + * 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 => + 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) + ? Array.filter(model.collapsedNavGroups, (id) => id !== groupId) + : [...model.collapsedNavGroups, groupId] + return { + model: modifyFields(model, { collapsedNavGroups: () => groups }), + commands: [SaveCollapsedNavGroups({ groups })], } } @@ -461,6 +582,14 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { 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 }) => 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..aff7e6f38e 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, @@ -133,30 +134,42 @@ 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 section keeps its row and, of its children, the current one alone. + */ 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 - 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) + // 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 - const preview = isOpen || subItems === undefined ? undefined : navPreview(item, subItems) + // 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 || allSubItems === undefined ? undefined : navPreview(item, allSubItems) return sidebarMenuItem(h, undefined, [ sidebarMenuButton( h, { rail: isOpen ? "lane" : "active", isActive, + hasActiveChild, link: { href: item.href, currentPath }, hasTooltip: true, attributes: tip(item.title), @@ -198,7 +211,9 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && (subItems.length > 0 || !isGroupFolded) + ? [subList(h, subItems, currentPath)] + : []), ]) } @@ -276,6 +291,20 @@ export const pinnedGroup = ( ] } +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 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" + if (!model.isSidebarOpen && !model.isMobile) return "rail" + 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 +312,40 @@ 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) + const rows = + fold === "collapsed" + ? Arr.filter(group.items, (item) => isNavItemActive(currentPath, item)) + : group.items + return sidebarGroup(h, undefined, [ + ...(group.label === undefined + ? [] + : 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, + rows.length === 0 + ? [] + : [ + sidebarMenu( + h, + undefined, + 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 1bf4a398ec..12f1b99b71 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -25,6 +25,10 @@ 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 { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" +import { isPillShown, onCallDetail, onCallLabel, type OnCallStatus, onCallStatusOf } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -244,6 +248,67 @@ 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: (snapshot) => onCallStatusOf(snapshot, model.onCallNowMs), + }) + +/** + * 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, + status: OnCallStatus, + tip: RailTip, + isRail: boolean, +): 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", + // 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. + ...(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 = ( h: HtmlBuilder, @@ -339,6 +404,8 @@ export const shellSidebarFooter = ( [], ), sidebarMenu(h, undefined, [ + // A row of the footer's list, so it keeps the list's 4px rhythm. + ...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/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index af4ad7e522..74ecf10a8c 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 { 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" @@ -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)), + ...(isPillShown(onCall) ? [onCallDetail(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..da920252d9 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,6 +215,35 @@ const root = Subscription.make()((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.onCallReadAt(model.shell) : null, + }), + dependenciesToStream: ({ atMs }) => + atMs === null + ? Stream.empty + : 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`). */ shellDashboardFavorites: entry( {}, diff --git a/docs/design-rules.md b/docs/design-rules.md index 0ae8ecd8ea..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 @@ -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`). @@ -97,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/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index 7bcec0b55c..c4c8bf9a47 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" @@ -27,12 +31,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/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index d8f10547f7..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-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-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,8 +14,10 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { + // 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-sidebar-accent active:bg-sidebar-accent 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", }, @@ -121,7 +123,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-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 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/INDEX.md b/packages/views/src/ui/INDEX.md index f01bff1962..1bca428dc4 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`, `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`, `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. @@ -228,6 +228,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`, `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/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/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..a0b121df0a 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 type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { Command } from "foldkit" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" +import * as Dialog from "./dialog" +import * as DialogView from "./dialog-view" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -127,3 +131,95 @@ 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` by default; pick `compact` when the form is a handful of short fields. */ + size?: SidePanelSize + /** The scrolling body. */ + body: 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 +}> + +// 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. 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, + options: SidePanelSheetOptions, +): Html => { + const { sheet } = options + return h.submodel({ + slotId: `${sheet.id}-sheet`, + model: sheet, + view: DialogView.view, + viewInputs: { + side: "right", + size: SIDE_PANEL_SIZE[options.size ?? "form"], + scrim: "panel", + className: REDUCED_MOTION, + hasDescription: options.description !== undefined, + toView: ({ portal }) => portal, + toContent: (close) => [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined + ? [] + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), + ], + }, + 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..dfe450e12e 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -206,16 +206,62 @@ 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 + onToggle: Message +}> + +/** A group label that collapses its rows: a 28px button with `aria-expanded` and a faint chevron. */ +export const sidebarGroupToggle = ( + h: HtmlBuilder, + options: SidebarGroupToggleOptions, +): Html => + h.button( + [ + h.Type("button"), + h.Class( + cn( + ...sidebarGroupLabelStyles, + "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"), + h.DataAttribute("slot", "sidebar-group-toggle"), + h.AriaExpanded(options.isExpanded), + h.AriaControls(options.controlsId), + h.OnClick(options.onToggle), + ], + [ + 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( + "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", + ), + }), + ], + ) + /** ``: the icon button in a group label's corner. */ export const sidebarGroupAction = ( h: HtmlBuilder, @@ -319,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 and `aria-current="page"` 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). */ @@ -355,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), @@ -367,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,