diff --git a/apps/web/package.json b/apps/web/package.json index 5e40e73a9d..cbc8133733 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 5f7dd1883f..d53e3cd1dd 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -54,6 +54,10 @@ export const LabState = Schema.Literals([ "schedules", "strip", "editor-new", + "editor-covered", + "editor-gaps", + "editor-early", + "editor-override", "editor-edit", "editor-invalid", "editor-error", @@ -186,8 +190,14 @@ const hubFor = (state: LabState): Step => { const formStep = (model: FormModel.Model, message: FormMessage.Message) => FormUpdate.update(model, message, CONTEXT) +const addPerson = (index: number) => + FormMessage.Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index, item: { kind: "Item", value: memberId(index) } }, + }) + const formFor = (state: LabState): Step => { - const isEdit = state === "editor-edit" || state === "editor-error" + const isEdit = state === "editor-edit" || state === "editor-error" || state === "editor-override" const start = FormUpdate.init( { _tag: "AlertsScheduleNew", search: isEdit ? { scheduleId: schedulePublicId(1) } : {} }, CONTEXT, @@ -206,6 +216,10 @@ const formFor = (state: LabState): Step => key: start.model.existing.key ?? "", result: Result.succeed(checkoutSchedule(ONCALL_NOW)), }), + FormMessage.Message.CompletedFetchOverrides({ + key: start.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), ] : []), ] @@ -252,7 +266,38 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : [] + : state === "editor-covered" || state === "editor-gaps" || state === "editor-early" + ? [ + FormMessage.Message.ChangedName({ value: "Checkout primary" }), + addPerson(0), + addPerson(1), + // Weeknights only: the days and weekends show as gaps. + ...(state === "editor-gaps" + ? [FormMessage.Message.ClickedAddRestriction({ key: "l0" })] + : []), + // A start two days back: the rotation is already under way. + ...(state === "editor-early" + ? [ + FormMessage.Message.PickedRotation({ + key: "l0", + rotation: "daily", + }), + FormMessage.Message.ChangedLayerText({ + key: "l0", + field: "startsAt", + value: "2026-11-08T09:00", + }), + ] + : []), + ] + : state === "editor-override" + ? [ + // One weeknights layer, so the saved overrides sit over real gaps. + FormMessage.Message.ClickedRemoveLayer({ key: "l2" }), + FormMessage.Message.ClickedRemoveLayer({ key: "l1" }), + FormMessage.Message.ClickedAddRestriction({ key: "l0" }), + ] + : [] return fold(start, [...base, ...extra], formStep) } diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 37f8bc9d73..685ec689e8 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -3,9 +3,11 @@ import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" -import { Result, Schema, Array as Arr } from "effect" +import { Result, Schema, Array as Arr, String as Str } from "effect" import * as Menu from "@maple/views/ui/menu" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { expect as expectView, given, Mount, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { chainDestinations, chainUuid } from "../shared/escalation-test-fixtures" @@ -220,6 +222,32 @@ describe("alerts hub, on-call schedules", () => { ) }) + test("the delete confirm is named for its schedule, a long unbroken name in full", () => { + const name = `checkout-primary-${Str.repeat(120)("x")}` + const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) + const asked = pageUpdate( + onTab( + "on-call", + ENGINEER, + Arr.map(SCHEDULES, (schedule) => + schedule.id === scheduleId ? { ...schedule, name } : schedule, + ), + ), + Message.GotRowMenuMessage({ + menuId: scheduleMenuId(scheduleId), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "delete" } }), + }), + ).model + expect(asked.confirmDeleteScheduleId).toBe(scheduleId) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("alertdialog", { name: `Delete ${name}?` })).toExist(), + expectView(within(role("alertdialog"), role("heading", { name: `Delete ${name}?` }))).toExist(), + ) + }) + test("a refused delete names the chains that still page the schedule", () => { const model = onTab("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 13c16cc1b9..fb9cc54903 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -267,7 +267,8 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { slotId: DELETE_SCHEDULE_DIALOG_ID, model: model.deleteScheduleDialog, toParentMessage: (message) => Message.GotDeleteScheduleDialogMessage({ message }), - title: `Delete ${name}?`, + // A long unbroken name wraps inside the dialog instead of running out of it. + title: [typography(h, { className: "wrap-anywhere" }, [`Delete ${name}?`])], description: "Its overrides go with it. This cannot be undone.", children: model.deleteScheduleError === null diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index ffa4c6ff33..e3672cba30 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -4,7 +4,7 @@ import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" -import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" +import { listOverrides, retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" import { listOrgMembers } from "../shared/org-members" import { Message } from "./message" @@ -27,6 +27,17 @@ export const FetchExistingSchedule = Command.define("FetchScheduleFormSchedule", ), }) +/** The edited schedule's overrides that have not ended, for the preview. */ +export const FetchScheduleFormOverrides = Command.define("FetchScheduleFormOverrides", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchOverrides], + execute: ({ key, scheduleId }) => + listOverrides(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchOverrides({ key, result })), + ), +}) + export const FetchScheduleFormMembers = Command.define("FetchScheduleFormMembers", { messages: [Message.CompletedFetchMembers], execute: listOrgMembers.pipe( diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index dbb603b9ee..2fad2e32ec 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -199,7 +199,7 @@ export type LayerErrors = Readonly<{ restrictions: ReadonlyArray> }> -const restrictionError = (restriction: RestrictionDraft): Option.Option => +export const restrictionError = (restriction: RestrictionDraft): Option.Option => restriction.weekdays.length === 0 ? Option.some("Pick at least one day") : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) @@ -207,7 +207,7 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option : Option.none() /** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ -const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => +export const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => { diff --git a/apps/web/src/page/alerts/schedule-form/index.ts b/apps/web/src/page/alerts/schedule-form/index.ts index 6dcd7b46cc..f429112cfa 100644 --- a/apps/web/src/page/alerts/schedule-form/index.ts +++ b/apps/web/src/page/alerts/schedule-form/index.ts @@ -1,10 +1,29 @@ -import { definePage } from "../../contract" +import { Clock, Effect, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" import { oncallTabHref } from "../shared/oncall-schedules" import { Message } from "./message" import { Model } from "./model" import { init, update } from "./update" import { view } from "./view" +/** The preview's now line follows the clock, on the schedule page's 30-second cadence. */ +const CLOCK_TICK = "30 seconds" + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + clock: entry(Schema.Struct({}).fields, { + modelToDependencies: () => ({}), + dependenciesToStream: () => + Stream.tick(CLOCK_TICK).pipe( + Stream.drop(1), + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), + ), + ), + }), +})) + /** `/alerts/schedules/new`: one instance per schedule being edited, or the new one. */ export const page = definePage( "AlertsScheduleNew", @@ -15,6 +34,7 @@ export const page = definePage( init, update, view, + subscriptions, breadcrumbs: () => [ { label: "Alerts", href: "/alerts" }, { label: "On-call", href: oncallTabHref() }, diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index 922636d3dc..2bec9aea04 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -1,12 +1,13 @@ import * as Policy from "@maple/domain/policy" import { OnCallWeekday, UserId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, V2OnCallSchedule } from "@maple/domain/http/v2" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { LoadFailure } from "@maple/views/data/load-failure" import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import { OrgMember } from "../shared/org-members" +import { RangeDays } from "../shared/coverage-preview" import { RotationKind } from "./form" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -20,8 +21,11 @@ export type LayerField = typeof LayerField.Type export const Message = defineMessageUnion({ ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, + ReadClock: { nowMs: Schema.Number }, CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, ClickedRetryExisting: {}, + CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, + ClickedRetryOverrides: {}, CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ChangedName: { value: Schema.String }, @@ -46,6 +50,13 @@ export const Message = defineMessageUnion({ edge: Schema.Literals(["start", "end"]), value: Schema.String, }, + ClickedPreviewPrevious: {}, + ClickedPreviewNext: {}, + ClickedPreviewToday: {}, + PickedPreviewRange: { days: RangeDays }, + /** A gap in the summary: its bar on the timeline takes focus. */ + ClickedPreviewGap: { index: Schema.Number }, + CompletedFocusGap: {}, ClickedSave: {}, CompletedSave: { result: outcome(V2OnCallSchedule) }, }) diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index da4206ac16..9100139c76 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OnCallScheduleId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, V2OnCallSchedule } from "@maple/domain/http/v2" import { Array as Arr, Order, Schema, String as Str } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" @@ -8,6 +8,7 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import { AlertsScheduleNewSearch } from "../../../route/search" import { OrgMember } from "../shared/org-members" +import { RangeDays } from "../shared/coverage-preview" import { Form } from "./form" // `/alerts/schedules/new`: create an on-call schedule, or edit one (`?scheduleId=`). @@ -18,6 +19,8 @@ export const Model = Schema.Struct({ editingId: Schema.NullOr(OnCallScheduleId), /** The edited schedule's read; the form fills from it once. */ existing: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** The edited schedule's overrides, which the preview layers over the draft; a new one has none. */ + overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), form: Form, /** The browser's zone and clock arrived: a new form starts from them. */ isReady: Schema.Boolean, @@ -35,6 +38,9 @@ export const Model = Schema.Struct({ saveFailure: Schema.NullOr(LoadFailure), grants: Schema.Array(Policy.PermissionName), isSessionLoaded: Schema.Boolean, + /** The coverage preview: weeks from today, and how many days it shows. */ + previewWeekOffset: Schema.Number, + previewRange: RangeDays, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts new file mode 100644 index 0000000000..4a227fd9f5 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -0,0 +1,85 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Option, Result, Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + find, + given, + role, + scene, + tap, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { memberId, ONCALL_NOW, oncallMembers } from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// The preview renders the whole schedule, so it must not redo that work on edits it does not read. + +const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const staffed = (): Model => + Arr.reduce( + [ + Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: ["UTC", "Europe/Berlin"], + }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value: memberId(0) } }, + }), + ], + init({ _tag: "AlertsScheduleNew", search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + +test("typing the name keeps the drawn preview; a new range draws it again", () => { + // The preview's rendered node: the same object while nothing it reads has changed. + const drawn: Array = [] + const keep = tap((simulation) => { + drawn.push(Option.getOrNull(find(simulation.html, '[data-slot="coverage-preview"]'))) + }) + scene( + { update: pageUpdate, view }, + given(staffed()), + expectView(text("Covered in this period")).toExist(), + keep, + type(role("textbox", { name: "Name" }), "Checkout primary"), + type(role("textbox", { name: "Description (optional)" }), "Who swaps with whom"), + keep, + click(role("button", { name: "4 weeks" })), + expectView(text("Covered in this period")).toExist(), + keep, + ) + expect(drawn[0]).not.toBeNull() + expect(drawn[1]).toBe(drawn[0]) + expect(drawn[2]).not.toBe(drawn[0]) +}) + +test("the clock moves the preview's now line while the editor stays open", () => { + scene( + { update: pageUpdate, view }, + given(staffed()), + expectView(text("Now, Tue 10 Nov, 11:30")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(pageUpdate(staffed(), Message.ReadClock({ nowMs: ONCALL_NOW + 45 * 60_000 })).model), + expectView(text("Now, Tue 10 Nov, 12:15")).toExist(), + expectView(text("Now, Tue 10 Nov, 11:30")).toBeAbsent(), + ) +}) diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts new file mode 100644 index 0000000000..e131afad45 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -0,0 +1,194 @@ +import type { OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallOverride, OnCallTimeZone } from "@maple/domain/http" +import type { V2OnCallOverride } from "@maple/domain/http/v2" +import { skeleton } from "@maple/views/ui/skeleton" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import { AsyncData } from "foldkit" +import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" +import { + coveragePreview, + type PreviewNote, + type PreviewShift, + type RangeDays, +} from "../shared/coverage-preview" +import { memberName, type OrgMember } from "../shared/org-members" +import { draftCoverage, layerOnCallAt, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { isoMs } from "../shared/time" +import { layerStartMs, type LayerDraft } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { PREVIEW_SCOPE } from "./update" + +// The editor's live coverage preview: the draft rendered as it is edited, no save needed. + +const isZone = Schema.is(OnCallTimeZone) + +const layerName = (name: string, index: number) => + Str.trim(name) === "" ? `Layer ${index + 1}` : Str.trim(name) + +/** What the preview reads off the Model; it redraws only when one of these changes. */ +export type PreviewInputs = Readonly<{ + timeZone: string + layers: ReadonlyArray + members: ReadonlyArray | null + nowMs: number + /** A new schedule: its starts are the user's own, so an early one is worth explaining. */ + isNew: boolean +}> + +/** Plain-language notes on the draft's layers, one per thing the preview cannot say by itself. */ +export const previewNotes = (inputs: PreviewInputs): ReadonlyArray => { + const todayMs = isZone(inputs.timeZone) ? zonedDayStart(inputs.nowMs, inputs.timeZone) : inputs.nowMs + return Arr.flatMap(inputs.layers, (layer, index): ReadonlyArray => { + const name = layerName(layer.name, index) + if (layer.participants.length === 0) + return [{ tone: "warn", text: `${name} has no people, so it covers nothing.` }] + // A saved schedule's start is always behind it, and a new layer starts today by default. + const startedEarlier = + inputs.isNew && Option.exists(layerStartMs(layer, inputs.timeZone), (ms) => ms < todayMs) + if (!startedEarlier) return [] + const under = `${name} started before today, so the rotation is already under way` + return [ + { + tone: "info", + text: Option.match(layerOnCallAt(layer, inputs.timeZone, inputs.nowMs), { + onNone: () => `${under}.`, + onSome: (userId) => `${under}: ${memberName(inputs.members, userId)} is on call now.`, + }), + }, + ] + }) +} + +const decodeOverride = Schema.decodeUnknownResult(OnCallOverride) + +/** The API's overrides as the engine reads them; one it cannot read (a bad instant) is left out. */ +export const engineOverrides = (overrides: ReadonlyArray): ReadonlyArray => + Arr.filterMap(overrides, (override) => + decodeOverride({ + id: override.id, + userId: override.user_id, + startsAt: isoMs(override.starts_at), + endsAt: isoMs(override.ends_at), + createdAt: isoMs(override.created_at), + }), + ) + +/** + * Where the edited schedule's overrides stand: `ready` (a new schedule has none), `loading` + * (nothing can be said about gaps yet), or `failed` / `retrying` (the preview goes on without them). + */ +export type OverridesState = "ready" | "loading" | "failed" | "retrying" + +const NO_OVERRIDES: ReadonlyArray = [] + +export const overridesStateOf = (model: Pick): OverridesState => + model.editingId === null + ? "ready" + : AsyncData.isFailure(model.overrides.data) + ? model.overrides.retrying === true + ? "retrying" + : "failed" + : Option.isSome(AsyncData.getData(model.overrides.data)) + ? "ready" + : "loading" + +const asPreview = (shift: OnCallShift): PreviewShift => ({ + userId: shift.userId, + startMs: shift.startMs, + endMs: shift.endMs, + isOverride: shift.source.kind === "override", +}) + +const render = ( + h: HtmlBuilder, + zone: string, + layers: ReadonlyArray, + members: ReadonlyArray | null, + nowMs: number, + weekOffset: number, + rangeDays: RangeDays, + isNew: boolean, + overrides: ReadonlyArray, + overridesState: OverridesState, +): Html => { + const timeZone = isZone(zone) ? zone : "UTC" + const range = previewRange(nowMs, timeZone, weekOffset, Number(rangeDays)) + // Overrides beat every layer when paging, so the preview layers them over the draft too. + const coverage = draftCoverage( + { timeZone: zone, layers }, + engineOverrides(overrides), + range.startMs, + range.endMs, + nowMs, + ) + const overridesNote: ReadonlyArray> = + overridesState === "failed" || overridesState === "retrying" + ? [ + { + tone: "info", + text: "Overrides could not be loaded, so gaps may be filled.", + action: { + label: "Try again", + onClick: Message.ClickedRetryOverrides(), + isDisabled: overridesState === "retrying", + }, + }, + ] + : [] + return coveragePreview(h, { + scope: PREVIEW_SCOPE, + title: "Coverage preview", + timeZone, + fromMs: range.startMs, + toMs: range.endMs, + nowMs, + weekOffset, + rangeDays, + lanes: Arr.map(layers, (layer, index) => ({ + key: layer.key, + label: layerName(layer.name, index), + detail: `Layer ${index + 1}`, + shifts: Option.map(coverage.layers[index] ?? Option.none(), Arr.map(asPreview)), + emptyText: + layer.participants.length === 0 + ? "Add people to see shifts" + : Option.isNone(previewLayerOf(layer, timeZone)) + ? "Fix this layer to see shifts" + : "Not active in this period", + })), + final: Arr.map(coverage.final, asPreview), + gaps: coverage.gaps, + nameOf: (userId) => memberName(members, userId), + onPrevious: Message.ClickedPreviewPrevious(), + onNext: Message.ClickedPreviewNext(), + onToday: Message.ClickedPreviewToday(), + onRange: (days) => Message.PickedPreviewRange({ days }), + onFocusGap: (index) => Message.ClickedPreviewGap({ index }), + // A draft has no past to look back on. + isPreviousDisabled: weekOffset <= 0, + notes: [...overridesNote, ...previewNotes({ timeZone: zone, layers, members, nowMs, isNew })], + // Until the overrides land nothing can be said about gaps: one may cover them. + placeholder: overridesState === "loading" ? skeleton(h, "h-40 w-full") : undefined, + }) +} + +const slot = createLazy() + +const MINUTE_MS = 60_000 + +/** Redrawn only when the zone, layers, members, overrides, minute or range change, not on every key. */ +export const schedulePreview = (h: HtmlBuilder, model: Model): Html => + slot(render, [ + h, + model.form.timeZone, + model.form.layers, + model.members, + Math.floor(model.nowMs / MINUTE_MS) * MINUTE_MS, + model.previewWeekOffset, + model.previewRange, + model.editingId === null, + Option.getOrElse(AsyncData.getData(model.overrides.data), () => NO_OVERRIDES), + overridesStateOf(model), + ]) diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index f290843e39..076c3e5de4 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -6,6 +6,7 @@ import { expect as expectView, given, role, scene, text, withViewInputs } from " import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { + checkoutOverrides, checkoutSchedule, memberId, ONCALL_NOW, @@ -49,6 +50,26 @@ const ready = (search: { scheduleId?: string } = {}) => { ]) } +const memberNameOf = (index: number) => oncallMembers[index]?.name ?? "" + +/** The saved checkout schedule cut down to one weeknights layer, so its days and weekends are gaps. */ +const weeknightsOnly = (model: Model) => + run(model, [ + Message.ClickedRemoveLayer({ key: "l2" }), + Message.ClickedRemoveLayer({ key: "l1" }), + Message.ClickedAddRestriction({ key: "l0" }), + ]) + +const overridesFailure = { + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the overrides", + retryable: false, + recovery: "retry", +} as const + const addPerson = (key: string, index: number) => Message.GotParticipantMenuMessage({ key, @@ -306,4 +327,217 @@ describe("schedule form", () => { "FetchScheduleFormSchedule", ) }) + + describe("coverage preview", () => { + test("a layer with nobody in it leaves the period uncovered and says why", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + expectView(text("Primary has no people, so it covers nothing.")).toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Add people to see shifts")).toExist(), + // One gap that is the whole range has no link of its own. + expectView(role("button", { name: /^From /u })).not.toExist(), + expectView(role("img", { name: "No one on call from Tue 10 Nov 11:30" })).toExist(), + ) + }) + + test("adding a person updates the preview to covered, and a restriction opens gaps", () => { + const staffed = run(ready(), [addPerson("l0", 0)]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered in this period")).toExist(), + // A new layer starts today at 09:00, which is not worth a note even after 09:00. + expectView(text(/started before today/u)).not.toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + ) + // Weeknights only (18:00 to 08:00): days and weekends are gaps. + const nights = run(staffed, [Message.ClickedAddRestriction({ key: "l0" })]) + scene( + { update: pageUpdate, view }, + given(nights), + expectView( + text("10 gaps in this period, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)"), + ).toExist(), + expectView(role("button", { name: "Sat 14 Nov 08:00 to Mon 16 Nov 18:00" })).toExist(), + ) + const focus = pageUpdate(nights, Message.ClickedPreviewGap({ index: 4 })) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) + + test("a window with no days stops the layer previewing instead of reading as always active", () => { + const nights = run(ready(), [addPerson("l0", 0), Message.ClickedAddRestriction({ key: "l0" })]) + const noDays = run( + nights, + Arr.map(["mon", "tue", "wed", "thu", "fri"] as const, (weekday) => + Message.ToggledRestrictionDay({ key: "l0", restrictionKey: "r1", weekday }), + ), + ) + scene( + { update: pageUpdate, view }, + given(noDays), + expectView(text("Fix this layer to see shifts")).toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Covered in this period")).not.toExist(), + ) + }) + + test("a new schedule started on an earlier day says the rotation is under way and who has it", () => { + // Two people, daily at 09:00 from Sun 8 Nov: by Tue 10 Nov 11:30 it is the first one's turn again. + const early = run(ready(), [ + addPerson("l0", 0), + addPerson("l0", 1), + Message.PickedRotation({ key: "l0", rotation: "daily" }), + Message.ChangedLayerText({ key: "l0", field: "startsAt", value: "2026-11-08T09:00" }), + ]) + scene( + { update: pageUpdate, view }, + given(early), + expectView( + text( + `Primary started before today, so the rotation is already under way: ${memberNameOf(0)} is on call now.`, + ), + ).toExist(), + ) + // A saved schedule always started in the past: no note for it. + scene( + { update: pageUpdate, view }, + given(ready({ scheduleId: schedulePublicId(1) })), + expectView(text(/started before today/u)).not.toExist(), + ) + }) + + test("a draft has no past: Previous is off from today", () => { + const model = ready() + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Previous week" })).toBeDisabled(), + ) + expect(pageUpdate(model, Message.ClickedPreviewPrevious()).model.previewWeekOffset).toBe(0) + const later = run(model, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(role("button", { name: "Previous week" })).not.toBeDisabled(), + ) + }) + + test("the range switch and week steps change what the preview covers", () => { + const staffed = run(ready(), [addPerson("l0", 0), Message.PickedPreviewRange({ days: "7" })]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered in this period")).toExist(), + expectView(text("10 Nov to 16 Nov")).toExist(), + ) + const later = run(staffed, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(text("Covered in this period")).toExist(), + expectView(text("17 Nov to 23 Nov")).toExist(), + ) + expect(run(later, [Message.ClickedPreviewToday()]).previewWeekOffset).toBe(0) + }) + }) + + describe("overrides in the preview of a saved schedule", () => { + test("an edit reads the schedule's overrides; a new schedule asks for none", () => { + expect(names(init({ _tag: "AlertsScheduleNew", search: {} }, context).commands)).not.toContain( + "FetchScheduleFormOverrides", + ) + expect( + names( + init({ _tag: "AlertsScheduleNew", search: { scheduleId: schedulePublicId(1) } }, context) + .commands, + ), + ).toContain("FetchScheduleFormOverrides") + // A new schedule's preview does not wait on anything. + scene( + { update: pageUpdate, view }, + given(run(ready(), [addPerson("l0", 0)])), + expectView(text("Covered in this period")).toExist(), + ) + }) + + test("until the overrides land the timeline is a skeleton and nothing is said about gaps", () => { + scene( + { update: pageUpdate, view }, + given(weeknightsOnly(ready({ scheduleId: schedulePublicId(1) }))), + expectView(text("Coverage preview")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gaps? in this period/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + expectView(text(/^Nobody is on call/u)).not.toExist(), + ) + }) + + test("an override covering part of a gap fills it, as it does when paging", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + // Dana Kim covers Fri 13 Nov 12:00 to Sat 14 Nov 12:00, over the Friday and weekend gaps. + const loaded = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + expectView( + role("img", { name: "Override: Dana Kim, Fri 13 Nov, 12:00 to Sat 14 Nov, 12:00" }), + ).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).not.toExist(), + expectView(role("button", { name: "Sat 14 Nov 12:00 to Mon 16 Nov 18:00" })).toExist(), + // The first weekend is partly covered now, so the second is the longest gap. + expectView( + text("10 gaps in this period, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)"), + ).toExist(), + expectView(text(/Overrides are not shown/u)).not.toExist(), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + ) + }) + + test("a failed overrides read previews without them, says so and offers another try", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + const failed = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.fail(overridesFailure), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text(/Overrides could not be loaded, so gaps may be filled\./u)).toExist(), + expectView(role("button", { name: "Try again" })).not.toBeDisabled(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).toExist(), + expectView(role("img", { name: /^Override:/u })).not.toExist(), + ) + const retried = pageUpdate(failed, Message.ClickedRetryOverrides()) + expect(names(retried.commands)).toEqual(["FetchScheduleFormOverrides"]) + // The note stays up while the retry is out, with its link off. + scene( + { update: pageUpdate, view }, + given(retried.model), + expectView(text(/Overrides could not be loaded/u)).toExist(), + expectView(role("button", { name: "Try again" })).toBeDisabled(), + ) + const recovered = run(retried.model, [ + Message.CompletedFetchOverrides({ + key: retried.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(recovered), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + ) + }) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index f8ee3957de..2ee141aa8e 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -1,4 +1,4 @@ -import { UserId } from "@maple/domain/http" +import { type OnCallScheduleId, UserId } from "@maple/domain/http" import { Option, Result, Schema, Array as Arr } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import * as Keyed from "@maple/views/data/keyed" @@ -7,10 +7,12 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, + FetchScheduleFormOverrides, FetchScheduleFormSession, ReadScheduleFormDefaults, SaveSchedule, @@ -35,8 +37,23 @@ type FormRoute = Extract export const ZONE_PICKER_ID = "schedule-form-zone" +/** Prefixes the preview's gap bar ids. */ +export const PREVIEW_SCOPE = "schedule-preview" + export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` +/** Reads the edited schedule's overrides (again), for the preview. */ +const requestOverrides = (model: Model, context: PageContext, scheduleId: OnCallScheduleId) => { + const overrides = Keyed.supersede( + model.overrides, + requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }), + ) + return { + model: { ...model, overrides }, + commands: [FetchScheduleFormOverrides({ key: overrides.key, scheduleId })], + } +} + export const init = (route: FormRoute, context: PageContext): Return => { const editingId = Option.getOrNull( Option.flatMap(Option.fromNullishOr(route.search.scheduleId), scheduleIdFromUrl), @@ -45,6 +62,7 @@ export const init = (route: FormRoute, context: PageContext): Return => { search: route.search, editingId, existing: Keyed.idle(), + overrides: Keyed.idle(), form: emptyForm("UTC", 0), isReady: false, nowMs: 0, @@ -58,19 +76,27 @@ export const init = (route: FormRoute, context: PageContext): Return => { saveFailure: null, grants: [], isSessionLoaded: false, + previewWeekOffset: 0, + previewRange: DEFAULT_RANGE_DAYS, } const common = [ ReadScheduleFormDefaults(), FetchScheduleFormMembers(), FetchScheduleFormSession({ orgId: context.orgId }), ] + // A new schedule has no overrides, so nothing is read for it. if (editingId === null) return { model, commands: common } + const overrides = requestOverrides(model, context, editingId) const key = requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }) return Option.match(Keyed.request(model.existing, key, "Load"), { - onNone: () => ({ model, commands: common }), + onNone: () => ({ model: overrides.model, commands: [...common, ...overrides.commands] }), onSome: (existing) => ({ - model: { ...model, existing }, - commands: [...common, FetchExistingSchedule({ key, scheduleId: editingId })], + model: { ...overrides.model, existing }, + commands: [ + ...common, + FetchExistingSchedule({ key, scheduleId: editingId }), + ...overrides.commands, + ], }), }) } @@ -199,6 +225,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model: ready } : { model: syncZone({ ...ready, form: emptyForm(timeZone, nowMs) }) } }, + // The preview's now line and the gaps counted from it follow the clock while the editor is open. + ReadClock: ({ nowMs }) => ({ model: model.isReady ? { ...model, nowMs } : model }), CompletedFetchExisting: ({ key, result }) => { const existing = Keyed.accept(model.existing, key, result) const loaded = Option.getOrNull(AsyncData.getData(existing.data)) @@ -263,6 +291,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [FetchExistingSchedule({ key: existing.key, scheduleId: editingId })], } }, + CompletedFetchOverrides: ({ key, result }) => ({ + model: { ...model, overrides: Keyed.accept(model.overrides, key, result) }, + }), + ClickedRetryOverrides: () => + model.editingId === null ? { model } : requestOverrides(model, context, model.editingId), ChangedLayerText: ({ key, field, value }) => field === "startsAt" ? editLayer(model, key, (layer) => ({ ...layer, startsAt: value, startsAtMs: null })) @@ -301,6 +334,22 @@ export const update = (model: Model, message: Message, context: PageContext): Re editRestriction(model, key, restrictionKey, (restriction) => edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, ), + // A draft has no past: the preview never steps back from today. + ClickedPreviewPrevious: () => ({ + model: { ...model, previewWeekOffset: Math.max(0, model.previewWeekOffset - 1) }, + }), + ClickedPreviewNext: () => ({ model: { ...model, previewWeekOffset: model.previewWeekOffset + 1 } }), + ClickedPreviewToday: () => ({ model: { ...model, previewWeekOffset: 0 } }), + PickedPreviewRange: ({ days }) => ({ model: { ...model, previewRange: days } }), + ClickedPreviewGap: ({ index }) => ({ + model, + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: PREVIEW_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], + }), + CompletedFocusGap: () => ({ model }), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 3dc01e80da..d0ec57da45 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -49,6 +49,7 @@ import { } from "./form" import { type LayerField, Message } from "./message" import { canManage, type Model, zoneItems } from "./model" +import { schedulePreview } from "./preview" import { participantMenuId } from "./update" import { stripClass } from "@maple/views/ui/rule" @@ -557,9 +558,38 @@ export const view = Submodel.defineView((model, if (!model.isReady || (isEdit && !AsyncData.isSuccess(model.existing.data))) return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) return dashboardPage(h, { ...header, fill: true }, [ - formScrollArea(h, [ - formCard(h, [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], FORM_WIDTH), - ]), + formScrollArea( + h, + [ + // From 60rem of room (the form column, the gap, the preview's padding and borders, and the 30rem + // the timeline needs unscrolled, rounded up) the form keeps a narrow column and the preview takes + // the rest, held in view. Under that it follows the form. + h.div( + [ + h.Class( + "grid gap-4 @min-[60rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[60rem]/scroll:items-start", + ), + ], + [ + formCard( + h, + [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], + FORM_WIDTH, + ), + h.div( + [ + h.Class( + "min-w-0 rounded-md border bg-card p-4 @min-[60rem]/scroll:sticky @min-[60rem]/scroll:top-0 @min-[60rem]/scroll:max-h-[100cqh] @min-[60rem]/scroll:overflow-y-auto", + ), + ], + [schedulePreview(h, model)], + ), + ], + ), + ], + // A size container: the columns read its width and the preview's height limit its height. + "@container-[size]/scroll", + ), actionBar(h, model), ]) }) diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts index cf1187acc6..7607bca282 100644 --- a/apps/web/src/page/alerts/schedule/message.ts +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -5,6 +5,7 @@ import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" +import { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -26,6 +27,10 @@ export const Message = defineMessageUnion({ ClickedPreviousWeek: {}, ClickedNextWeek: {}, ClickedThisWeek: {}, + PickedRange: { days: RangeDays }, + /** A gap in the summary: its bar on the calendar takes focus. */ + ClickedGap: { index: Schema.Number }, + CompletedFocusGap: {}, /** A final shift's bar: an override over its whole range, for someone else to cover. */ ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, ClickedNewOverride: {}, diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index 545d5434ef..68afb4905e 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -5,6 +5,7 @@ import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" +import { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" // `/alerts/schedules/$scheduleId`: who is on call, the calendar of shifts, and the overrides. @@ -30,6 +31,8 @@ export const Model = Schema.Struct({ scheduleAskedAtMs: Schema.NullOr(Schema.Number), /** Weeks from this one the calendar shows: 0 is the week starting today. */ weekOffset: Schema.Number, + /** How many days the calendar shows from its week's first day. */ + rangeDays: RangeDays, shifts: KeyedSchema(V2OnCallShifts, LoadFailure), overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 4866bbee22..575b910021 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { OnCallOverrideId } from "@maple/domain/http" import { zonedDayStart } from "@maple/views/ui/shift-timeline" -import { Option, Result, Schema, Array as Arr } from "effect" +import { Option, Result, Schema, Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" import { isoMs, msIso } from "../shared/time" import { memberName } from "../shared/org-members" @@ -92,9 +92,7 @@ describe("on-call schedule page", () => { given(loaded(ONCALL_NOW)), expectView(text("Checkout primary")).toExist(), expectView( - text( - "Times in Europe/Berlin. Yours is UTC. Pick a shift in the final row to cover it with an override.", - ), + text("Your time zone is UTC. Pick a shift in the final row to cover it with an override."), ).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).toExist(), expectView(role("rowheader", { name: /Weeknights/u })).toExist(), @@ -104,13 +102,35 @@ describe("on-call schedule page", () => { ) }) + test("the calendar's heading carries the whole schedule name as its title, for a name too long to show", () => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + const name = `checkout-primary-${Str.repeat(120)("x")}` + const long = run(entered.model, [ + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed({ ...checkoutSchedule(ONCALL_NOW), name }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(long), + expectView(role("heading", { name: `Shifts for ${name}` })).toHaveAttr( + "title", + `Shifts for ${name}`, + ), + ) + }) + test("the DST week runs Thursday to Wednesday on the Berlin clock, through the fall-back Sunday", () => { const model = loaded(DST_NOW) scene( { update: pageUpdate, view }, given(model), expectView(text("Sun 25")).toExist(), - expectView(text("22 Oct to 28 Oct")).toExist(), + expectView(text("22 Oct to 4 Nov")).toExist(), ) }) @@ -122,7 +142,7 @@ describe("on-call schedule page", () => { scene( { update: pageUpdate, view }, given(next.model), - expectView(text("17 Nov to 23 Nov")).toExist(), + expectView(text("17 Nov to 30 Nov")).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), ) scene( @@ -394,7 +414,7 @@ describe("on-call schedule page", () => { given(loaded(ONCALL_NOW, MEMBER)), expectView(role("button", { name: "Add override" })).not.toExist(), expectView(role("button", { name: /^Delete Dana Kim's override, /u })).not.toExist(), - expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), + expectView(text("Your time zone is UTC.")).toExist(), ) }) @@ -456,4 +476,56 @@ describe("on-call schedule page", () => { expectView(text("Couldn't load members, try reloading the page")).toExist(), ) }) + + test("the calendar summarises coverage like the editor, with a range switch that reads the new range", () => { + const model = loaded(ONCALL_NOW) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Covered in this period")).toExist(), + expectView(role("group", { name: "Range" })).toExist(), + ) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + expect(names(month.commands)).toEqual(["FetchOnCallShifts"]) + expect(Option.map(rangeOf(month.model), (r) => Math.round((r.toMs - r.fromMs) / 86_400_000))).toEqual( + Option.some(28), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(month.model, ONCALL_NOW)), + expectView(text("10 Nov to 7 Dec")).toExist(), + ) + }) + + test("a wider range draws a skeleton, not the old shifts as one long gap, until its shifts land", () => { + const model = loaded(ONCALL_NOW) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + scene( + { update: pageUpdate, view }, + given(month.model), + expectView(text("10 Nov to 7 Dec")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gap/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a range that has wholly passed says its gaps are not checked, not that it was covered", () => { + const before = pageUpdate( + pageUpdate(loaded(ONCALL_NOW), Message.ClickedPreviousWeek()).model, + Message.ClickedPreviousWeek(), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(before.model, ONCALL_NOW)), + expectView(text("27 Oct to 9 Nov")).toExist(), + expectView(text("Past shifts. Gaps are only checked from now.")).toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a gap in the summary focuses its bar on the calendar", () => { + const focus = pageUpdate(loaded(ONCALL_NOW), Message.ClickedGap({ index: 0 })) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 47b0e8f77d..51d897df50 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -8,6 +8,7 @@ import * as Dialog from "@maple/views/ui/dialog" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -30,9 +31,12 @@ type ScheduleRoute = Extract export const OVERRIDE_DIALOG_ID = "schedule-override" export const DELETE_OVERRIDE_DIALOG_ID = "schedule-delete-override" -/** The calendar shows one week of the schedule zone's days, from today. */ +/** The calendar steps a week of the schedule zone's days at a time. */ export const CALENDAR_DAYS = 7 +/** Prefixes the calendar's gap bar ids. */ +export const CALENDAR_SCOPE = "schedule-calendar" + export const scheduleOf = (model: Model): Option.Option => AsyncData.getData(model.schedule.data) @@ -46,7 +50,7 @@ export const rangeOf = (model: Model): Option.Option { schedule: Keyed.idle(), scheduleAskedAtMs: null, weekOffset: 0, + rangeDays: DEFAULT_RANGE_DAYS, shifts: Keyed.idle(), overrides: Keyed.idle(), members: null, @@ -344,6 +349,16 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), ClickedThisWeek: () => moveWeek(model, context, 0), + PickedRange: ({ days }) => requestShifts({ ...model, rangeDays: days }, context, "Load"), + ClickedGap: ({ index }) => ({ + model, + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: CALENDAR_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], + }), + CompletedFocusGap: () => ({ model }), // An ended shift has nothing left to cover; a running one is covered from now to its end. ClickedShift: ({ startMs, endMs }) => canManage(model) && endMs > (model.nowMs ?? 0) ? openOverride(model, startMs, endMs) : { model }, diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index 90a45a91eb..b2bd2e7796 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -15,7 +15,6 @@ import { input } from "@maple/views/ui/input" import { label } from "@maple/views/ui/label" import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" import { personLabel } from "@maple/views/ui/person-avatar" -import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" import { skeleton } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -24,11 +23,14 @@ import { dashboardPage } from "../../kit/dashboard-page" import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" import { resourceNotFound } from "../../kit/resource-not-found" import { sectionHeading } from "../../kit/section-heading" +import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/oncall-schedules" +import { gapsOf } from "../shared/schedule-coverage" import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" import { + CALENDAR_SCOPE, DELETE_OVERRIDE_DIALOG_ID, OVERRIDE_DIALOG_ID, overrideIssue, @@ -58,145 +60,100 @@ const onCallLine = (model: Model, schedule: V2OnCallSchedule): string => `${memberName(model.members, now.user_id)} is on call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, }) -/** A shift as a bar: the person's name, and who and when in its tooltip. */ -const barOf = ( - model: Model, - schedule: V2OnCallSchedule, - shift: V2OnCallShift, - key: string, - isFinal: boolean, -): TimelineBar => { - const startMs = isoMs(shift.starts_at) - const endMs = isoMs(shift.ends_at) - const name = memberName(model.members, shift.user_id) - const isOverride = shift.source.kind === "override" - const bar: TimelineBar = { - key, - startMs, - endMs, - label: name, - title: `${isOverride ? "Override: " : ""}${name}, ${rangeText(startMs, endMs, schedule.time_zone)}`, - tone: isOverride ? "info" : isFinal ? "primary" : "neutral", - } - // Only a final shift that has not ended opens an override, and only for someone who may add one. - return isFinal && canManage(model) && endMs > (model.nowMs ?? 0) - ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } - : bar -} - -/** The final row on top, then the layers from the top one down, as they stack. */ -const timelineRows = ( - model: Model, - schedule: V2OnCallSchedule, - shifts: V2OnCallShifts, -): ReadonlyArray> => [ - { - key: "final", - label: "Final schedule", - detail: "Who gets paged", - bars: Arr.map(shifts.final, (shift, index) => barOf(model, schedule, shift, `final-${index}`, true)), - emptyText: "Nobody on call in this period", - }, - ...Arr.reverse( - Arr.map(schedule.layers, (layer, index): TimelineRow => ({ - key: `layer-${index}`, - label: layer.name, - detail: `Layer ${index + 1}`, - bars: Arr.map(shifts.layers[index] ?? [], (shift, at) => - barOf(model, schedule, shift, `layer-${index}-${at}`, false), - ), - emptyText: "Not active in this period", - })), - ), -] - -/** "10 Nov to 16 Nov": the first and last day the calendar shows. */ -const weekLabel = (fromMs: number, toMs: number, timeZone: string): string => { - const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) - return `${day.format(fromMs)} to ${day.format(toMs - 1)}` -} +const previewShiftOf = (shift: V2OnCallShift): PreviewShift => ({ + userId: shift.user_id, + startMs: isoMs(shift.starts_at), + endMs: isoMs(shift.ends_at), + isOverride: shift.source.kind === "override", +}) -/** The calendar's header: the week, prev/this week/next, and which zone the times read in. */ +/** The calendar: the same coverage timeline the editor previews, from the server's shifts. */ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { const range = rangeOf(model) const nowMs = model.nowMs ?? 0 - // Shifts kept from another week (a week's read still in flight) draw as the skeleton, not blank rows. - const timeline = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => - !Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) - ? skeleton(h, "h-40 w-full") - : shiftTimeline(h, { - fromMs, - toMs, - timeZone: schedule.time_zone, - nowMs, - rows: timelineRows(model, schedule, shifts), - ariaLabel: `Shifts for ${schedule.name}`, - nowLabel: `Now, ${formatShiftEdge(nowMs, schedule.time_zone)}`, - legend: [ - { tone: "primary", label: "On call" }, - { tone: "info", label: "Override" }, - { tone: "neutral", label: "Layer shift" }, - ], - }) + // The axis names the schedule's zone; the note only says when the viewer's differs. + const noteParts = [ + ...(viewerZone === schedule.time_zone ? [] : [`Your time zone is ${viewerZone}.`]), + ...(canManage(model) ? ["Pick a shift in the final row to cover it with an override."] : []), + ] + const note = noteParts.length === 0 ? undefined : Arr.join(noteParts, " ") + const preview = ( + fromMs: number, + toMs: number, + shifts: Option.Option, + placeholder?: Html, + ): Html => { + const final = Option.match(shifts, { + onNone: () => [], + onSome: (s) => Arr.map(s.final, previewShiftOf), + }) + return coveragePreview(h, { + scope: CALENDAR_SCOPE, + title: `Shifts for ${schedule.name}`, + timeZone: schedule.time_zone, + fromMs, + toMs, + nowMs, + weekOffset: model.weekOffset, + rangeDays: model.rangeDays, + lanes: Arr.map(schedule.layers, (layer, index) => ({ + key: `layer-${index}`, + label: layer.name, + detail: `Layer ${index + 1}`, + shifts: Option.map(shifts, (s) => Arr.map(s.layers[index] ?? [], previewShiftOf)), + emptyText: "Not active in this period", + })), + final, + gaps: gapsOf(final, Math.max(fromMs, nowMs), toMs), + nameOf: (userId) => memberName(model.members, userId), + onPrevious: Message.ClickedPreviousWeek(), + onNext: Message.ClickedNextWeek(), + onToday: Message.ClickedThisWeek(), + onRange: (days) => Message.PickedRange({ days }), + onFocusGap: (index) => Message.ClickedGap({ index }), + // Only a final shift that has not ended opens an override, and only for someone who may add one. + onFinalShift: (shift) => + canManage(model) && shift.endMs > nowMs + ? Option.some(Message.ClickedShift({ startMs: shift.startMs, endMs: shift.endMs })) + : Option.none(), + note, + placeholder, + }) + } + const loading = (fromMs: number, toMs: number) => + preview(fromMs, toMs, Option.none(), skeleton(h, "h-40 w-full")) + // Shifts kept from another range (a read in flight, either end moved) draw as the skeleton: a + // shorter read on a longer axis would show its missing days as one long gap. + const loaded = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + Option.exists( + readRangeOf(model), + (read) => isoMs(shifts.from) === read.fromMs && isoMs(shifts.to) === read.toMs, + ) + ? preview(fromMs, toMs, Option.some(shifts)) + : loading(fromMs, toMs) const body = Option.match(range, { onNone: () => skeleton(h, "h-40 w-full"), onSome: ({ fromMs, toMs }) => AsyncData.match(model.shifts.data, { - onIdle: () => skeleton(h, "h-40 w-full"), - onLoading: () => skeleton(h, "h-40 w-full"), + onIdle: () => loading(fromMs, toMs), + onLoading: () => loading(fromMs, toMs), onFailure: (failure) => - errorState(h, { - failure, - title: "Couldn't load the shifts", - onRetry: Message.ClickedRetryShifts(), - }), - onRefreshing: (shifts) => timeline(fromMs, toMs, shifts), - onStale: ({ data }) => timeline(fromMs, toMs, data), - onSuccess: (shifts) => timeline(fromMs, toMs, shifts), - }), - }) - const zoneNote = - viewerZone === schedule.time_zone - ? `Times in ${schedule.time_zone}.` - : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` - return h.section( - [h.Class("space-y-3")], - [ - sectionHeading(h, { - title: Option.match(range, { - onNone: () => "This week", - onSome: ({ fromMs, toMs }) => weekLabel(fromMs, toMs, schedule.time_zone), - }), - actions: hstack(h, { gap: "1" }, [ - iconButton( - h, - { label: "Previous week", attributes: [h.OnClick(Message.ClickedPreviousWeek())] }, - [icon(h, "ChevronLeftIcon")], + preview( + fromMs, + toMs, + Option.none(), + errorState(h, { + failure, + title: "Couldn't load the shifts", + onRetry: Message.ClickedRetryShifts(), + }), ), - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.weekOffset === 0, - attributes: [h.OnClick(Message.ClickedThisWeek())], - }, - ["Today"], - ), - iconButton( - h, - { label: "Next week", attributes: [h.OnClick(Message.ClickedNextWeek())] }, - [icon(h, "ChevronRightIcon")], - ), - ]), + onRefreshing: (shifts) => loaded(fromMs, toMs, shifts), + onStale: ({ data }) => loaded(fromMs, toMs, data), + onSuccess: (shifts) => loaded(fromMs, toMs, shifts), }), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - zoneNote, - ...(canManage(model) ? [" Pick a shift in the final row to cover it with an override."] : []), - ]), - body, - ], - ) + }) + return h.section([h.Class("space-y-3")], [body]) } const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V2OnCallOverride): Html => { diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts new file mode 100644 index 0000000000..de9569b886 --- /dev/null +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -0,0 +1,354 @@ +import { initialsFrom } from "@maple/ui/lib/initials" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" +import { Command, Dom } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { button } from "@maple/views/ui/button" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { formatShiftEdge } from "./oncall-schedules" +import { gapRangeText, gapSummary, isUncovered, type Interval } from "./schedule-coverage" + +// The coverage timeline both the schedule editor (from the draft) and the detail page (from the +// server's shifts) draw: a lane per layer, the final lane with its "No one" gaps, and the controls. + +export const RangeDays = Schema.Literals(["7", "14", "28"]) +export type RangeDays = typeof RangeDays.Type +export const DEFAULT_RANGE_DAYS: RangeDays = "14" + +const RANGE_OPTIONS: ReadonlyArray> = [ + { value: "7", label: "1 week" }, + { value: "14", label: "2 weeks" }, + { value: "28", label: "4 weeks" }, +] + +/** The summary links this many gaps in a narrow preview and in a wide one; the rest are on the timeline. */ +const NARROW_GAP_LINKS = 3 +const WIDE_GAP_LINKS = 6 + +/** The element id of a gap's bar, so its summary link can scroll to and focus it. */ +export const gapBarId = (scope: string, index: number): string => `${scope}-gap-${index}` + +export const GapFocusMessage = defineMessageUnion({ CompletedFocusGap: {} }) + +/** A gap's bar takes focus, which scrolls it into view; a page lifts the result into its own message. */ +export const FocusGap = Command.define("FocusCoverageGap", { + args: { scope: Schema.String, index: Schema.Number }, + messages: [GapFocusMessage.CompletedFocusGap], + execute: ({ scope, index }) => + Dom.focus(`#${gapBarId(scope, index)}`).pipe( + Effect.ignore, + Effect.as(GapFocusMessage.CompletedFocusGap()), + ), +}) + +export type PreviewShift = Readonly<{ userId: string; startMs: number; endMs: number; isOverride: boolean }> + +export type PreviewLane = Readonly<{ + key: string + label: string + detail: string + /** None while the layer cannot be drawn; `emptyText` says why. */ + shifts: Option.Option> + emptyText: string +}> + +/** + * A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. + * `action` is a link after the text ("Try again"). + */ +export type PreviewNote = Readonly<{ + tone: "info" | "warn" + text: string + action?: Readonly<{ label: string; onClick: M; isDisabled?: boolean }> +}> + +export type CoveragePreviewOptions = Readonly<{ + /** Prefixes the gap bars' ids, unique on the page. */ + scope: string + title: string + timeZone: string + fromMs: number + toMs: number + nowMs: number + weekOffset: number + rangeDays: RangeDays + lanes: ReadonlyArray + final: ReadonlyArray + gaps: ReadonlyArray + nameOf: (userId: string) => string + onPrevious: M + onNext: M + onToday: M + onRange: (days: RangeDays) => M + onFocusGap: (index: number) => M + /** A draft has no past: its preview cannot step back from today. */ + isPreviousDisabled?: boolean + /** A final shift's bar action (the detail page's "cover with an override"). */ + onFinalShift?: (shift: PreviewShift) => Option.Option + notes?: ReadonlyArray> + /** A line under the header. The axis names the zone, so this does not. */ + note?: string | undefined + /** Drawn in place of the summary and timeline while their data loads; the header stays. */ + placeholder?: Html | undefined +}> + +const rangeText = (startMs: number, endMs: number, timeZone: string) => + `${formatShiftEdge(startMs, timeZone)} to ${formatShiftEdge(endMs, timeZone)}` + +/** The lanes as timeline rows: the final row on top, then the layers top first, as they stack. */ +export const previewRows = (options: CoveragePreviewOptions): ReadonlyArray> => { + const shiftBar = (shift: PreviewShift, key: string, isFinal: boolean): TimelineBar => { + const name = options.nameOf(shift.userId) + const bar: TimelineBar = { + key, + startMs: shift.startMs, + endMs: shift.endMs, + label: name, + shortLabel: initialsFrom(name), + title: `${shift.isOverride ? "Override: " : ""}${name}, ${rangeText(shift.startMs, shift.endMs, options.timeZone)}`, + tone: shift.isOverride ? "info" : isFinal ? "primary" : "neutral", + } + const click = + isFinal && options.onFinalShift !== undefined ? options.onFinalShift(shift) : Option.none() + return Option.match(click, { onNone: () => bar, onSome: (onClick) => ({ ...bar, onClick }) }) + } + const gapBars = Arr.map(options.gaps, (gap, index): TimelineBar => { + const range = gapRangeText(gap, options.timeZone, options.toMs) + return { + key: `gap-${gap.startMs}`, + id: gapBarId(options.scope, index), + startMs: gap.startMs, + endMs: gap.endMs, + label: "No one", + // An open gap reads "No one on call from Sat 21 Nov 08:00". + title: Str.startsWith("from ")(range) ? `No one on call ${range}` : `No one on call, ${range}`, + tone: "gap", + } + }) + return [ + { + key: "final", + label: "Final schedule", + detail: "Who gets paged", + bars: [ + ...Arr.map(options.final, (shift, index) => shiftBar(shift, `final-${index}`, true)), + ...gapBars, + ], + emptyText: "Nobody on call in this period", + }, + ...Arr.reverse( + Arr.map(options.lanes, (lane): TimelineRow => ({ + key: lane.key, + label: lane.label, + detail: lane.detail, + // The final row above already carries every shift that pages; these stay out of the tab order. + isTabStop: false, + bars: Option.match(lane.shifts, { + onNone: () => [], + onSome: (shifts) => + Arr.map(shifts, (shift, at) => shiftBar(shift, `${lane.key}-${at}`, false)), + }), + emptyText: lane.emptyText, + })), + ), + ] +} + +/** "10 Nov to 23 Nov": the first and last day shown. */ +const periodLabel = (fromMs: number, toMs: number, timeZone: string): string => { + const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) + return `${day.format(fromMs)} to ${day.format(toMs - 1)}` +} + +const summaryLine = ( + h: HtmlBuilder, + iconName: "CircleCheckIcon" | "AlertWarningIcon" | "ClockIcon", + iconClass: string, + text: string, +): Html => + hstack(h, { gap: "1.5", align: "start" }, [ + h.span([h.AriaHidden(true), h.Class(`mt-0.5 shrink-0 ${iconClass}`)], [icon(h, iconName)]), + typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [text]), + ]) + +const gapLinks = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const count = options.gaps.length + const more = (shown: number, className: string): ReadonlyArray => + count > shown + ? [ + typography( + h, + { size: "xs", tone: "muted", className, attributes: [h.Key(`more-${shown}`)] }, + [`and ${count - shown} more on the timeline`], + ), + ] + : [] + return hstack(h, { gap: "1", wrap: true }, [ + ...Arr.map(Arr.take(options.gaps, WIDE_GAP_LINKS), (gap, index) => + button( + h, + { + size: "xs", + variant: "outline", + // A narrow preview (a phone) links fewer gaps. + className: index < NARROW_GAP_LINKS ? undefined : "hidden @min-[30rem]:inline-flex", + attributes: [h.Key(`gap-link-${gap.startMs}`), h.OnClick(options.onFocusGap(index))], + }, + [Str.capitalize(gapRangeText(gap, options.timeZone, options.toMs))], + ), + ), + ...more(NARROW_GAP_LINKS, "@min-[30rem]:hidden"), + ...more(WIDE_GAP_LINKS, "hidden @min-[30rem]:inline"), + ]) +} + +const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const scope = { + timeZone: options.timeZone, + checkedFromMs: Math.max(options.fromMs, options.nowMs), + checkedToMs: options.toMs, + } + const summary = gapSummary(options.gaps, scope) + const alarm = summaryLine(h, "AlertWarningIcon", "text-severity-error", summary) + const body = + // A range that has wholly passed has nothing left to check. + options.toMs <= options.nowMs + ? [ + summaryLine( + h, + "ClockIcon", + "text-muted-foreground", + "Past shifts. Gaps are only checked from now.", + ), + ] + : options.gaps.length === 0 + ? [summaryLine(h, "CircleCheckIcon", "text-severity-info", summary)] + : // One gap that is the whole range needs no link to find it. + isUncovered(options.gaps, scope) + ? [alarm] + : [alarm, gapLinks(h, options)] + return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, body) +} + +const noteView = (h: HtmlBuilder, note: PreviewNote): Html => + hstack(h, { gap: "1.5", align: "start", attributes: [h.Key(note.text)] }, [ + h.span( + [ + h.AriaHidden(true), + h.Class( + `mt-0.5 shrink-0 ${note.tone === "warn" ? "text-severity-warn" : "text-muted-foreground"}`, + ), + ], + [icon(h, note.tone === "warn" ? "CircleWarningIcon" : "CircleInfoIcon", { size: 14 })], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + note.text, + ...(note.action === undefined + ? [] + : [ + " ", + textLink( + h, + { onClick: note.action.onClick, isDisabled: note.action.isDisabled ?? false }, + [note.action.label], + ), + ]), + ]), + ]) + +const controls = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + // The range switch takes its own row in a narrow preview; the week steps never split. + vstack(h, { gap: "2", className: "@min-[30rem]:flex-row @min-[30rem]:items-center" }, [ + segmentedSelect(h, { + options: RANGE_OPTIONS, + value: options.rangeDays, + onChange: options.onRange, + size: "xs", + ariaLabel: "Range", + className: "w-fit", + }), + hstack(h, { gap: "1", className: "shrink-0" }, [ + iconButton( + h, + { + label: "Previous week", + isDisabled: options.isPreviousDisabled ?? false, + attributes: [h.OnClick(options.onPrevious)], + }, + [icon(h, "ChevronLeftIcon")], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: options.weekOffset === 0, + attributes: [h.OnClick(options.onToday)], + }, + ["Today"], + ), + iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ + icon(h, "ChevronRightIcon"), + ]), + ]), + ]) + +const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + shiftTimeline(h, { + fromMs: options.fromMs, + toMs: options.toMs, + timeZone: options.timeZone, + nowMs: options.nowMs, + zoneLabel: options.timeZone, + rows: previewRows(options), + ariaLabel: options.title, + nowLabel: `Now, ${formatShiftEdge(options.nowMs, options.timeZone)}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + { tone: "gap", label: "No one" }, + ], + }) + +export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + h.section( + // A container: the controls and gap links lay out by the preview's width, not the screen's. + [h.Class("@container min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], + [ + hstack(h, { gap: "2", justify: "between", align: "start", wrap: true }, [ + // A set basis, not the title's own width: a long name truncates beside the controls + // while there is room for a readable one, and takes its own row when there is not. + vstack(h, { gap: "0", shrink: true, className: "grow basis-48" }, [ + heading( + h, + { + level: 2, + variant: "section", + className: "truncate", + attributes: [h.Title(options.title)], + }, + [options.title], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + periodLabel(options.fromMs, options.toMs, options.timeZone), + ]), + ]), + controls(h, options), + ]), + ...(options.note === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), + ...Arr.map(options.notes ?? [], (note) => noteView(h, note)), + ...(options.placeholder === undefined + ? [gapSummaryView(h, options), timelineView(h, options)] + : [options.placeholder]), + ], + ) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts new file mode 100644 index 0000000000..3a3821353e --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -0,0 +1,298 @@ +import { onCallAt } from "@maple/alerting-core/oncall" +import { OnCallOverride, UserId } from "@maple/domain/http" +import { Array as Arr, Option, Schema } from "effect" +import { describe, expect, it } from "vitest" +import { emptyLayer, type Form, type LayerDraft } from "../schedule-form/form" +import { + draftConfigOf, + draftCoverage, + durationText, + gapRangeText, + gapsOf, + gapSummary, + layerOnCallAt, + previewLayerOf, + previewRange, + type Interval, +} from "./schedule-coverage" + +const HOUR = 3_600_000 +const at = (iso: string) => Date.parse(iso) +const people = Schema.decodeUnknownSync(Schema.Array(UserId)) +const decodeOverrides = Schema.decodeUnknownSync(Schema.Array(OnCallOverride)) + +const layer = (patch: Partial): LayerDraft => ({ + ...emptyLayer(0, 0, at("2026-11-02T08:00:00Z"), "Europe/Berlin"), + ...patch, +}) +const formOf = (layers: ReadonlyArray, timeZone = "Europe/Berlin"): Form => ({ + name: "Ops", + description: "", + timeZone, + layers, + nextKey: layers.length, +}) + +/** Every `stepMs` across the range, checks the preview's final lane against the server's `onCallAt`. */ +const expectMatchesServer = ( + form: Form, + overrides: ReadonlyArray, + range: Interval, + stepMs: number, +) => { + const config = Option.getOrThrow(draftConfigOf(form)).config + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + const count = Math.floor((range.endMs - range.startMs) / stepMs) + Arr.forEach(Arr.range(0, count - 1), (i) => { + const ms = range.startMs + i * stepMs + 1 + const server = onCallAt(config, overrides, ms) + const preview = Arr.findFirst(coverage.final, (s) => s.startMs <= ms && ms < s.endMs) + const inGap = Arr.some(coverage.gaps, (g) => g.startMs <= ms && ms < g.endMs) + expect(Option.getOrNull(Option.map(preview, (s) => s.userId))).toBe( + server === null ? null : server.userId, + ) + expect(inGap).toBe(server === null) + }) +} + +describe("gapsOf", () => { + it("returns the whole range when nothing covers it", () => { + expect(gapsOf([], 0, 10)).toEqual([{ startMs: 0, endMs: 10 }]) + }) + it("returns no gaps when shifts cover the range end to end", () => { + expect( + gapsOf( + [ + { startMs: 5, endMs: 12 }, + { startMs: -2, endMs: 5 }, + ], + 0, + 10, + ), + ).toEqual([]) + }) + it("finds holes between, before and after shifts", () => { + expect( + gapsOf( + [ + { startMs: 2, endMs: 4 }, + { startMs: 6, endMs: 8 }, + ], + 0, + 10, + ), + ).toEqual([ + { startMs: 0, endMs: 2 }, + { startMs: 4, endMs: 6 }, + { startMs: 8, endMs: 10 }, + ]) + }) +}) + +describe("draftCoverage", () => { + const range = { startMs: at("2026-11-09T00:00:00Z"), endMs: at("2026-11-23T00:00:00Z") } + + it("cycles members at the daily handoff time", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben", "chen"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-11-09T09:00", + }), + ]) + const coverage = draftCoverage(form, [], at("2026-11-09T08:00:00Z"), at("2026-11-12T08:00:00Z"), 0) + expect(Arr.map(coverage.final, (s) => s.userId)).toEqual(["ana", "ben", "chen"]) + // 09:00 Berlin is 08:00 UTC in November. + expect(Arr.map(coverage.final, (s) => new Date(s.startMs).toISOString())).toEqual([ + "2026-11-09T08:00:00.000Z", + "2026-11-10T08:00:00.000Z", + "2026-11-11T08:00:00.000Z", + ]) + expect(coverage.gaps).toEqual([]) + }) + + it("layers a business-hours layer over a weekly one and applies overrides, matching onCallAt", () => { + const form = formOf([ + layer({ participants: people(["ana", "ben"]), rotation: "weekly", startsAt: "2026-11-02T09:00" }), + layer({ + key: "l1", + participants: people(["dev"]), + rotation: "daily", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }), + ]) + const overrides = decodeOverrides([ + { + id: "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a60", + userId: "eve", + startsAt: at("2026-11-12T10:00:00Z"), + endsAt: at("2026-11-13T02:00:00Z"), + createdAt: 1, + }, + ]) + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + expect(coverage.gaps).toEqual([]) + expect(Arr.some(coverage.final, (s) => s.userId === "eve")).toBe(true) + expectMatchesServer(form, overrides, range, HOUR / 2) + }) + + it("reports restriction holes as gaps that match onCallAt being null", () => { + const form = formOf([ + layer({ + participants: people(["ana"]), + rotation: "weekly", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "08:00", + endTime: "20:00", + }, + ], + }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + // Nights plus the two weekends. + expect(coverage.gaps.length).toBeGreaterThan(8) + expectMatchesServer(form, [], range, HOUR / 2) + }) + + it("hands off at local 09:00 across the October DST change", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-10-23T09:00", + }), + ]) + const dst = { startMs: at("2026-10-22T22:00:00Z"), endMs: at("2026-10-28T23:00:00Z") } + const coverage = draftCoverage(form, [], dst.startMs, dst.endMs, dst.startMs) + const sunday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-25T07:00:00Z")) + // Sat 09:00 CEST (07:00Z) to Sun 09:00 CET (08:00Z) is 25 hours. + const saturday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-24T07:00:00Z")) + expect(Option.map(saturday, (s) => s.endMs - s.startMs)).toEqual(Option.some(25 * HOUR)) + expect(Option.isNone(sunday)).toBe(true) + expectMatchesServer(form, [], dst, HOUR / 4) + }) + + it("treats a layer with no members as not previewable and the schedule as all gap", () => { + const form = formOf([layer({ participants: [] })]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Option.isNone(coverage.layers[0] ?? Option.some([]))).toBe(true) + expect(coverage.gaps).toEqual([{ startMs: range.startMs, endMs: range.endMs }]) + }) + + it("keeps lanes aligned with the draft when a middle layer cannot preview", () => { + const form = formOf([ + layer({ participants: people(["ana"]), startsAt: "2026-11-02T09:00" }), + layer({ key: "l1", participants: [] }), + layer({ key: "l2", participants: people(["ben"]), rotation: "custom", lengthHours: "nope" }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Arr.map(coverage.layers, Option.isSome)).toEqual([true, false, false]) + }) + + it("counts gaps only from now, and a future start leaves a gap before it", () => { + const form = formOf([layer({ participants: people(["ana"]), startsAt: "2026-11-12T09:00" })]) + const nowMs = at("2026-11-10T12:00:00Z") + const coverage = draftCoverage(form, [], range.startMs, range.endMs, nowMs) + expect(coverage.gaps).toEqual([{ startMs: nowMs, endMs: at("2026-11-12T08:00:00Z") }]) + }) +}) + +describe("previewRange", () => { + it("spans whole local days, so a DST week is an hour longer", () => { + const week = previewRange(at("2026-10-21T10:00:00Z"), "Europe/Berlin", 0, 7) + expect(week.startMs).toBe(at("2026-10-20T22:00:00Z")) + expect(week.endMs - week.startMs).toBe(7 * 24 * HOUR + HOUR) + }) +}) + +describe("gapSummary", () => { + const scope = { + timeZone: "Europe/Berlin", + checkedFromMs: at("2026-11-10T10:30:00Z"), + checkedToMs: at("2026-11-23T23:00:00Z"), + } + it("says covered when there are no gaps", () => { + expect(gapSummary([], scope)).toBe("Covered in this period") + }) + it("names the range drawn, not a count of days: from now, a 14-day range ends short of 14 days", () => { + const nowMs = at("2026-11-10T10:30:00Z") + const range = previewRange(nowMs, "Europe/Berlin", 0, 14) + const today = { timeZone: "Europe/Berlin", checkedFromMs: nowMs, checkedToMs: range.endMs } + // What is counted stops at the range's last midnight, 11.5 hours before 14 days from now. + expect(range.endMs - nowMs).toBe(14 * 24 * HOUR - 11.5 * HOUR) + const late = { startMs: range.endMs - 2 * HOUR, endMs: range.endMs - HOUR } + expect(gapSummary([late], today)).toBe("1 gap in this period, longest 1h (Mon 23 Nov 22:00 to 23:00)") + expect(gapSummary([], today)).toBe("Covered in this period") + }) + it("counts gaps and names the longest in the schedule's zone", () => { + const gaps = [ + { startMs: at("2026-11-19T23:00:00Z"), endMs: at("2026-11-20T01:00:00Z") }, + { startMs: at("2026-11-20T23:00:00Z"), endMs: at("2026-11-21T15:00:00Z") }, + ] + expect(gapSummary(gaps, scope)).toBe("2 gaps in this period, longest 16h (Sat 21 Nov 00:00 to 16:00)") + }) + it("says nobody is on call when the one gap is everything checked", () => { + const whole = [{ startMs: scope.checkedFromMs, endMs: scope.checkedToMs }] + expect(gapSummary(whole, scope)).toBe("Nobody is on call in this period") + }) + it("gives a gap cut off by the range's end no end time", () => { + const closed = { startMs: at("2026-11-12T08:00:00Z"), endMs: at("2026-11-12T10:00:00Z") } + const open = { startMs: at("2026-11-21T07:00:00Z"), endMs: scope.checkedToMs } + expect(gapSummary([closed, open], scope)).toBe( + "2 gaps in this period, longest 2d 16h or longer (from Sat 21 Nov 08:00)", + ) + expect(gapRangeText(closed, "Europe/Berlin", scope.checkedToMs)).toBe("Thu 12 Nov 09:00 to 11:00") + expect(gapRangeText(open, "Europe/Berlin", scope.checkedToMs)).toBe("from Sat 21 Nov 08:00") + }) + it("formats durations", () => { + expect(durationText(90 * 60_000)).toBe("1h 30m") + expect(durationText(76 * HOUR)).toBe("3d 4h") + // From a day up the minutes are left out, not rounded into the hours. + expect(durationText(58 * HOUR + 30 * 60_000)).toBe("2d 10h") + expect(durationText(23 * HOUR + 30 * 60_000)).toBe("23h 30m") + }) +}) + +describe("a layer being edited", () => { + const window = { key: "r0", weekdays: ["mon" as const], startTime: "18:00", endTime: "08:00" } + const staffed = layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + startsAt: "2026-11-09T09:00", + }) + + it("does not preview while one of its windows is half typed, rather than reading as always active", () => { + const halfTyped = { ...staffed, restrictions: [window, { ...window, key: "r1", endTime: "" }] } + const noDays = { ...staffed, restrictions: [{ ...window, weekdays: [] }] } + expect(Option.isNone(previewLayerOf(halfTyped, "Europe/Berlin"))).toBe(true) + expect(Option.isNone(previewLayerOf(noDays, "Europe/Berlin"))).toBe(true) + const from = at("2026-11-09T00:00:00Z") + const to = at("2026-11-16T00:00:00Z") + const coverage = draftCoverage(formOf([halfTyped]), [], from, to, from) + expect(coverage.final).toEqual([]) + expect(coverage.gaps).toEqual([{ startMs: from, endMs: to }]) + }) + + it("names who the layer alone has on call at an instant, and nobody outside its windows", () => { + // Ana holds Mon 9 Nov 09:00, Ben Tue 10 Nov 09:00. + expect(layerOnCallAt(staffed, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual( + Option.some("ben"), + ) + const nights = { ...staffed, restrictions: [window] } + expect(layerOnCallAt(nights, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual(Option.none()) + }) +}) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts new file mode 100644 index 0000000000..6788ffa3df --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -0,0 +1,256 @@ +import { onCallAt, renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallLayer, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +import type { OnCallOverride } from "@maple/domain/http" +import type { UserId } from "@maple/domain/primitives" +import { zonedDateParts } from "@maple/query-engine/datetime" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Order, Result, Schema, String as Str } from "effect" +import { layerStartMs, restrictionError, type Form, type LayerDraft } from "../schedule-form/form" + +// Coverage preview: the draft rendered with the server's own `renderOnCall`, so the preview +// and paging agree, plus the gaps where nobody is on call. + +export interface Interval { + readonly startMs: number + readonly endMs: number +} + +const byStart = Order.mapInput(Order.Number, (interval: Interval) => interval.startMs) + +/** The stretches of [fromMs, toMs) that no shift covers. */ +export const gapsOf = ( + shifts: ReadonlyArray, + fromMs: number, + toMs: number, +): ReadonlyArray => { + if (toMs <= fromMs) return [] + const sorted = Arr.sort(shifts, byStart) + const { gaps, cursor } = Arr.reduce( + sorted, + { gaps: Arr.empty(), cursor: fromMs }, + ({ gaps, cursor }, shift) => { + if (shift.endMs <= cursor || shift.startMs >= toMs) return { gaps, cursor } + const next = shift.startMs > cursor ? [...gaps, { startMs: cursor, endMs: shift.startMs }] : gaps + return { gaps: next, cursor: Math.max(cursor, shift.endMs) } + }, + ) + return cursor < toMs ? [...gaps, { startMs: cursor, endMs: toMs }] : gaps +} + +const isZone = Schema.is(OnCallTimeZone) +const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) +const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) + +/** + * A draft layer as the engine's layer, once it has people, a valid rotation and valid windows. + * A half-typed window is none, not "always active": the engine reads no windows as 24/7. + */ +export const previewLayerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Arr.some(layer.restrictions, (restriction) => Option.isSome(restrictionError(restriction))) + ? Option.none() + : Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => + decodeLayer(engineLayerOf(layer, startsAt)), + ) + +const engineLayerOf = (layer: LayerDraft, startsAt: number) => ({ + // The name never changes who is on call, so a blank one still previews. + name: Str.isEmpty(Str.trim(layer.name)) ? "Layer" : Str.slice(0, 100)(Str.trim(layer.name)), + participants: layer.participants, + rotation: + layer.rotation === "custom" + ? { kind: "custom", lengthHours: Number(Str.trim(layer.lengthHours)) } + : layer.rotation === "daily" + ? { kind: "daily", handoffTime: layer.handoffTime } + : { + kind: "weekly", + handoffWeekday: layer.handoffWeekday, + handoffTime: layer.handoffTime, + }, + startsAt, + restrictions: Arr.map(layer.restrictions, ({ weekdays, startTime, endTime }) => ({ + weekdays, + startTime, + endTime, + })), +}) + +/** Who the layer alone has on call at `atMs`; none in one of its gaps, or while it cannot preview. */ +export const layerOnCallAt = (layer: LayerDraft, timeZone: string, atMs: number): Option.Option => + previewLayerOf(layer, timeZone).pipe( + Option.flatMap((engineLayer) => decodeConfig({ timeZone, layers: [engineLayer] })), + Option.flatMap((config) => Option.fromNullishOr(onCallAt(config, [], atMs))), + Option.map((shift) => shift.userId), + ) + +export interface DraftConfig { + readonly config: OnCallScheduleConfig + /** The draft layer each config layer came from. */ + readonly draftIndexes: ReadonlyArray +} + +/** The draft as an engine config, keeping only the layers that can preview; none while no layer can. */ +export const draftConfigOf = (form: Pick): Option.Option => { + if (!isZone(form.timeZone)) return Option.none() + const usable = Arr.filterMap(form.layers, (layer, index) => + Option.match(previewLayerOf(layer, form.timeZone), { + onNone: () => Result.failVoid, + onSome: (engineLayer) => Result.succeed({ engineLayer, index }), + }), + ) + return Option.map( + decodeConfig({ timeZone: form.timeZone, layers: Arr.map(usable, (u) => u.engineLayer) }), + (config) => ({ config, draftIndexes: Arr.map(usable, (u) => u.index) }), + ) +} + +export interface Coverage { + /** Per draft layer: its shifts, or none while it cannot preview (no people, bad times). */ + readonly layers: ReadonlyArray>> + readonly final: ReadonlyArray + /** Gaps in the final schedule from `max(fromMs, nowMs)`: the past is not scheduled. */ + readonly gaps: ReadonlyArray +} + +/** The draft's layers, final schedule and gaps over [fromMs, toMs). */ +export const draftCoverage = ( + form: Pick, + overrides: ReadonlyArray, + fromMs: number, + toMs: number, + nowMs: number, +): Coverage => { + const gapFrom = Math.max(fromMs, nowMs) + return Option.match(draftConfigOf(form), { + onNone: () => ({ + layers: Arr.map(form.layers, () => Option.none()), + final: [], + gaps: gapsOf([], gapFrom, toMs), + }), + onSome: ({ config, draftIndexes }) => { + const render = renderOnCall(config, overrides, fromMs, toMs) + return { + layers: Arr.map(form.layers, (_, draftIndex) => + Option.flatMap( + Arr.findFirstIndex(draftIndexes, (i) => i === draftIndex), + (configIndex) => Option.fromNullishOr(render.layers[configIndex]), + ), + ), + final: render.final, + gaps: gapsOf(render.final, gapFrom, toMs), + } + }, + }) +} + +// RANGE: whole wall-clock days in the schedule's zone, so DST days are 23 or 25 hours. + +/** `days` wall-clock days from the start of today, `weekOffset` weeks on. */ +export const previewRange = ( + nowMs: number, + timeZone: string, + weekOffset: number, + days: number, +): Interval => ({ + startMs: zonedDayStart(nowMs, timeZone, weekOffset * 7), + endMs: zonedDayStart(nowMs, timeZone, weekOffset * 7 + days), +}) + +// COPY + +const formatters = new Map() +const formatterOf = (timeZone: string, kind: "day" | "time"): Intl.DateTimeFormat => { + const key = `${timeZone}|${kind}` + const cached = formatters.get(key) + if (cached !== undefined) return cached + const formatter = new Intl.DateTimeFormat( + "en-GB", + kind === "day" + ? { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + } + : { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }, + ) + formatters.set(key, formatter) + return formatter +} + +const sameDay = (a: number, b: number, timeZone: string) => { + const pa = zonedDateParts(a, timeZone) + const pb = zonedDateParts(b, timeZone) + return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day +} + +const dropComma = Str.replace(",", "") + +/** A gap that runs to the end of what was checked: its real end is not known. */ +export const isOpenGap = (gap: Interval, checkedToMs: number): boolean => gap.endMs >= checkedToMs + +/** "Sat 21 Nov 00:00 to 16:00", both ends in full across days, or "from Sat 21 Nov 00:00" when open. */ +export const gapRangeText = (gap: Interval, timeZone: string, checkedToMs: number): string => { + const start = dropComma(formatterOf(timeZone, "day").format(gap.startMs)) + if (isOpenGap(gap, checkedToMs)) return `from ${start}` + const end = sameDay(gap.startMs, gap.endMs, timeZone) + ? formatterOf(timeZone, "time").format(gap.endMs) + : dropComma(formatterOf(timeZone, "day").format(gap.endMs)) + return `${start} to ${end}` +} + +/** "16h", "1h 30m", "3d 4h": a gap's length. Minutes show only under a day; a longer one reads in days and hours. */ +export const durationText = (ms: number): string => { + const minutes = Math.round(ms / 60_000) + const days = Math.floor(minutes / 1440) + const hours = Math.floor((minutes % 1440) / 60) + const mins = minutes % 60 + const parts = [ + ...(days > 0 ? [`${days}d`] : []), + ...(hours > 0 ? [`${hours}h`] : []), + ...(mins > 0 && days === 0 ? [`${mins}m`] : []), + ] + return parts.length === 0 ? "0m" : Arr.join(parts, " ") +} + +/** The longest gap; the earliest among equals. */ +export const longestGap = (gaps: ReadonlyArray): Option.Option => + Arr.reduce(gaps, Option.none(), (best, gap) => + Option.match(best, { + onNone: () => Option.some(gap), + onSome: (b) => (gap.endMs - gap.startMs > b.endMs - b.startMs ? Option.some(gap) : best), + }), + ) + +/** What the summary is about: the zone and the stretch checked for gaps. */ +export type GapScope = Readonly<{ + timeZone: string + /** Gaps are checked over [checkedFromMs, checkedToMs): from now to the range's end. */ + checkedFromMs: number + checkedToMs: number +}> + +/** One gap spanning everything checked: nobody is on call at all. */ +export const isUncovered = ( + gaps: ReadonlyArray, + scope: Pick, +): boolean => { + const only = gaps.length === 1 ? gaps[0] : undefined + return only !== undefined && only.startMs <= scope.checkedFromMs && only.endMs >= scope.checkedToMs +} + +/** + * "2 gaps in this period, longest 16h (Sat 21 Nov 00:00 to 16:00)", "Covered in this period", or + * "Nobody is on call in this period" when the one gap is everything checked. "This period" is the + * range drawn: it ends at its last midnight, so a range from today is never a full "next N days". + */ +export const gapSummary = (gaps: ReadonlyArray, scope: GapScope): string => { + if (isUncovered(gaps, scope)) return "Nobody is on call in this period" + return Option.match(longestGap(gaps), { + onNone: () => "Covered in this period", + onSome: (longest) => + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} in this period, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, + }) +} diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 7405a6aac5..6a876a4117 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -12,7 +12,8 @@ import { dashboardPage } from "./dashboard-page" /** * `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) - * and its controls on the right; the two stack on phones. Rows sit in a `divide-y` card. + * and its controls on the right; the two stack in a card under 36rem wide (a phone, or a form + * beside a preview). Rows sit in a `divide-y` `formCard`, whose width they read. */ export const formSection = ( h: HtmlBuilder, @@ -29,12 +30,24 @@ export const formSection = ( [ h.AriaLabel(options.title), h.Class( - cn("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]", options.className), + cn( + "grid gap-x-8 gap-y-2 px-4 py-4 @min-[36rem]/form:grid-cols-[136px_minmax(0,1fr)]", + options.className, + ), ), ], [ h.div( - [h.Class(cn("flex flex-col gap-0.5", options.isCompact === true ? "sm:pt-1.5" : "sm:pt-2"))], + [ + h.Class( + cn( + "flex flex-col gap-0.5", + options.isCompact === true + ? "@min-[36rem]/form:pt-1.5" + : "@min-[36rem]/form:pt-2", + ), + ), + ], [ heading(h, { level: 3, variant: "subsection" }, [options.title]), ...(options.hint === undefined @@ -47,15 +60,22 @@ export const formSection = ( ) /** `FormScrollArea`: a form page's scroller above its action bar, the page's own scroll area. */ -export const formScrollArea = (h: HtmlBuilder, children: ReadonlyArray): Html => +export const formScrollArea = ( + h: HtmlBuilder, + children: ReadonlyArray, + className?: string, +): Html => h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class(cn("min-h-0 flex-1 overflow-auto p-4", className)), + ], children, ) /** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => - dividedList(h, { as: "div", className: cn("bg-card", className) }, children) + dividedList(h, { as: "div", className: cn("@container/form bg-card", className) }, children) /** * `FormActionBar`: pinned under a form's scroller, outside it, so it never floats over the form: diff --git a/bun.lock b/bun.lock index 5006b9d3e7..9f2f5b2ab0 100644 --- a/bun.lock +++ b/bun.lock @@ -322,6 +322,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/packages/alerting-core/package.json b/packages/alerting-core/package.json index 2069779090..a14c20056e 100644 --- a/packages/alerting-core/package.json +++ b/packages/alerting-core/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./hysteresis": "./src/hysteresis.ts" + "./hysteresis": "./src/hysteresis.ts", + "./oncall": "./src/oncall.ts" }, "scripts": { "test": "vitest run", diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index e71375759d..d6d4db9b5f 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -830,3 +830,12 @@ @utility loading-delay { animation: loading-delay 0s 300ms both; } + +/* A hatch for something missing (an on-call gap): the error tone's lines over no fill. */ +@utility hatch-error { + background-image: repeating-linear-gradient( + 135deg, + color-mix(in oklab, var(--severity-error) 32%, transparent) 0 1px, + transparent 1px 6px + ); +} diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 5cbc212759..51e16857f9 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -194,7 +194,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/day-label.ts` `dayKey`, `dayLabel`: Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. -- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack in a card under 36rem wide (a phone,... - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. - `apps/web/src/page/kit/guided-setup.ts` `keyFor`, `setupBlocks`, `serverKey`, `promptKey`, `frameworkIconStrip`, `frameworkPicker`, `setupAgentPrompt`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 0181893586..1f49587692 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -1,4 +1,4 @@ -import { expect as expectView, given, role, scene, text } from "foldkit/scene" +import { expect as expectView, given, role, scene, text, within } from "foldkit/scene" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { describe, test } from "vitest" @@ -33,11 +33,21 @@ const view = (model: Model, h: HtmlBuilder): Html => tone: "primary", onClick: Message.ClickedBar(), }, + { + key: "gap", + id: "gap-0", + startMs: FROM + HOUR, + endMs: FROM + 3 * HOUR, + label: "No one", + title: "No one on call, Mon 9 Nov 01:00 to 03:00", + tone: "gap", + }, ], }, { key: "layer", label: "Primary", + isTabStop: false, bars: [ { key: "b", @@ -98,4 +108,29 @@ describe("shift timeline", () => { expectView(role("img", { name: "Cy, next week" })).not.toExist(), ) }) + + test("a gap is a named, focusable bar a summary can reach by id; a layer's bars leave the tab order", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "id", + "gap-0", + ), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "tabIndex", + "0", + ), + expectView(role("img", { name: "Ben, Mon" })).toHaveAttr("tabIndex", "-1"), + ) + }) + + test("every day of the header is named in full, however its drawn label is shortened", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(within(role("columnheader", { name: "Shifts" }), text("Mon 9 Nov"))).toExist(), + expectView(within(role("columnheader", { name: "Shifts" }), text("Sun 15 Nov"))).toExist(), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index a34045cf4f..dc0f4d85c0 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -55,4 +55,21 @@ describe("shift timeline layout", () => { "2026-11-11T05:00:00.000Z", ) }) + + test("each day carries its full name, month included, across a month's end", () => { + const from = Date.parse("2026-11-28T23:00:00.000Z") // Sun 29 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 3 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => day.label)).toEqual(["Sun 29 Nov", "Mon 30 Nov", "Tue 1 Dec"]) + }) + + test("Saturdays and Sundays are marked as the weekend, on the zone's own days", () => { + const from = Date.parse("2026-11-12T23:00:00.000Z") // Fri 13 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 4 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => [day.weekday, day.isWeekend])).toEqual([ + ["Fri", false], + ["Sat", true], + ["Sun", true], + ["Mon", false], + ]) + }) }) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 6fe6520d72..b8ab8deb98 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -1,6 +1,6 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" import { cn } from "@maple/ui/lib/utils" -import { Array as Arr, Option, Result } from "effect" +import { Array as Arr, Option, Result, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { pressable } from "./pressable" import { hstack, vstack } from "./stack" @@ -12,18 +12,22 @@ import { typography } from "./typography" // on-call schedule's layers and its final row). Days follow the zone's wall clock, so a DST day is // 23 or 25 hours wide. -/** A bar's look: a layer's shift, the effective shift, or an override over it. */ -export type TimelineBarTone = "neutral" | "primary" | "info" +/** A bar's look: a layer's shift, the effective shift, an override over it, or a gap nobody covers. */ +export type TimelineBarTone = "neutral" | "primary" | "info" | "gap" export type TimelineBar = Readonly<{ key: string startMs: number endMs: number label: string + /** Drawn when the label cannot fit but two characters can (a person's initials). */ + shortLabel?: string /** The tooltip and accessible name: who, and from when to when. */ title: string tone: TimelineBarTone onClick?: M + /** The bar's element id, so a summary elsewhere can scroll to and focus it. */ + id?: string }> export type TimelineRow = Readonly<{ @@ -31,6 +35,8 @@ export type TimelineRow = Readonly<{ label: string detail?: string bars: ReadonlyArray> + /** False keeps the row's read-only bars out of the tab order (a layer under the final row). */ + isTabStop?: boolean /** What an empty row says, for a row with no bars in the range. */ emptyText?: string }> @@ -41,8 +47,11 @@ export type TimelineDay = Readonly<{ endMs: number weekday: string dayOfMonth: number + /** The day in full ("Tue 10 Nov"): its name wherever the drawn label is shortened or hidden. */ + label: string leftPct: number widthPct: number + isWeekend: boolean }> const weekdayFormatters = new Map() @@ -53,6 +62,16 @@ const weekdayOf = (ms: number, timeZone: string): string => { return formatter.format(ms) } +const monthFormatters = new Map() +const monthOf = (ms: number, timeZone: string): string => { + const cached = monthFormatters.get(timeZone) + const formatter = cached ?? new Intl.DateTimeFormat("en-US", { timeZone, month: "short" }) + if (cached === undefined) monthFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + +const WEEKEND = new Set(["Sat", "Sun"]) + /** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ export const zonedDayStart = (ms: number, timeZone: string, offsetDays = 0): number => { const parts = zonedDateParts(ms, timeZone) @@ -83,14 +102,20 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re return Arr.flatMap(Arr.range(0, count), (offset): ReadonlyArray => { const startMs = zonedDayStart(fromMs, timeZone, offset) const endMs = zonedDayStart(fromMs, timeZone, offset + 1) + // The day's first instant inside the range names it. + const atMs = Math.max(startMs, fromMs) + const weekday = weekdayOf(atMs, timeZone) + const dayOfMonth = zonedDateParts(atMs, timeZone).day return Option.match(placeBar(fromMs, toMs, startMs, endMs), { onNone: () => [], onSome: (place) => [ { startMs, endMs, - weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), - dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, + weekday, + isWeekend: WEEKEND.has(weekday), + dayOfMonth, + label: `${weekday} ${dayOfMonth} ${monthOf(atMs, timeZone)}`, ...place, }, ], @@ -104,6 +129,8 @@ const BAR_TONE = { // An override is the one bar that must stand out, even 8px wide on a dark surface: a stronger // wash and the tone's own edge. info: "bg-severity-info-a3 border-severity-info text-severity-info", + // A gap reads as missing cover: no fill, a dashed error edge and the error tone's hatch. + gap: "hatch-error border-dashed border-severity-error text-severity-error", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ @@ -111,7 +138,8 @@ const MIN_BAR_PX = 8 const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" -const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" +// A container, so the label can leave when the bar is too narrow to hold it. +const BAR_CLASS = "@container absolute inset-y-1 overflow-hidden rounded-sm border text-left" /** How far a bar may grow to reach its floor: up to the next bar's start in the lane, or the range's end. */ export const barRoomPct = ( @@ -137,6 +165,7 @@ const barView = ( toMs: number, bar: TimelineBar, limitPct: number, + isTabStop: boolean, ): ReadonlyArray => Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { onNone: () => [], @@ -148,10 +177,35 @@ const barView = ( width: `calc(${widthPct}% - 2px)`, "min-width": `min(${MIN_BAR_PX}px, calc(${limitPct - leftPct}% - 2px))`, }) - const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ - bar.label, - ]) - const attributes = [h.Key(bar.key), style, ...tooltipTrigger(h, bar.title)] + // The label shows from 3.75rem of bar (3rem of text), the short one from two characters' + // room; the name stays in the tooltip. + const text = [ + typography( + h, + { size: "xs", truncate: true, className: "hidden px-1.5 leading-6 @min-[3.75rem]:block" }, + [bar.label], + ), + ...(bar.shortLabel === undefined + ? [] + : [ + typography( + h, + { + size: "xs", + className: + "hidden text-center leading-6 @min-[1.25rem]:block @min-[3.75rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [bar.shortLabel], + ), + ]), + ] + const attributes = [ + h.Key(bar.key), + style, + ...(bar.id === undefined ? [] : [h.Id(bar.id)]), + ...tooltipTrigger(h, bar.title), + ] return [ bar.onClick === undefined ? h.div( @@ -160,10 +214,10 @@ const barView = ( h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone], FOCUS_CLASS)), // Read-only bars still reach the keyboard and a screen reader, named by their tooltip. h.Role("img"), - h.Tabindex(0), + h.Tabindex(isTabStop ? 0 : -1), h.AriaLabel(bar.title), ], - [text], + text, ) : pressable( h, @@ -177,13 +231,14 @@ const barView = ( ), attributes, }, - [text], + text, ), ] }, }) -const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 sm:w-40" +// Sized by the timeline's own width, so it stays narrow in a side panel on a wide screen. +const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 @2xl:w-40" /** * The timeline: a header of days, then one row per lane (label column, bars track), ruled by day, @@ -200,6 +255,8 @@ export const shiftTimeline = ( ariaLabel: string /** Read out in place of the now line, which is drawn only ("Now, Tue 10 Nov, 11:30"). */ nowLabel?: string + /** Names the axis's zone in the label column's header ("Europe/Berlin"). */ + zoneLabel?: string /** A key under the timeline for the bar tones it uses. */ legend?: ReadonlyArray> }>, @@ -213,8 +270,14 @@ export const shiftTimeline = ( h.div( [ h.Key(`rule-${day.startMs}`), - h.Class("absolute inset-y-0 border-l border-border-subtle"), - h.Style({ left: `${day.leftPct}%` }), + ...(day.isWeekend ? [h.DataAttribute("weekend", "true")] : []), + h.Class( + cn( + "absolute inset-y-0 border-l border-border-subtle", + day.isWeekend && "bg-surface-sunken", + ), + ), + h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [], ), @@ -238,20 +301,37 @@ export const shiftTimeline = ( const header = h.div( [h.Class("flex border-b"), h.Role("row")], [ - h.div([h.Class(LABEL_COLUMN), h.Role("columnheader")], [h.span([h.Class("sr-only")], ["Lane"])]), + h.div( + [h.Class(cn(LABEL_COLUMN, "flex items-center")), h.Role("columnheader")], + [ + h.span([h.Class("sr-only")], ["Lane"]), + ...(options.zoneLabel === undefined + ? [] + : [ + typography( + h, + { size: "2xs", tone: "muted", truncate: true, className: "block" }, + [options.zoneLabel], + ), + ]), + ], + ), // One header for the track, as each row has one cell for it; the days label it visually. h.div( [h.Class("relative h-8 grow"), h.Role("columnheader"), h.AriaLabel("Shifts")], - Arr.map(days, (day) => + Arr.map(days, (day, index) => h.div( [ h.Key(`day-${day.startMs}`), h.Class( - "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", + "@container absolute inset-y-0 overflow-hidden border-l border-border-subtle", ), h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [ + // Read out in full; the two drawn forms below shorten or hide it by the day's width. + h.span([h.Class("sr-only")], [day.label]), + // "Tue 10" where a day is wide enough, else the weekday's letter over the date. typography( h, { @@ -259,10 +339,40 @@ export const shiftTimeline = ( tone: "muted", numeric: true, truncate: true, - className: "block", + className: "hidden px-1.5 py-1.5 @min-[3.5rem]:block", + attributes: [h.AriaHidden(true)], }, [`${day.weekday} ${day.dayOfMonth}`], ), + vstack( + h, + { + gap: "0", + align: "center", + justify: "center", + className: "h-full @min-[3.5rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [ + typography(h, { size: "2xs", tone: "faint", className: "leading-3" }, [ + Str.slice(0, 1)(day.weekday), + ]), + typography( + h, + { + size: "2xs", + tone: "muted", + numeric: true, + // Days too narrow for two digits date only the first day and each Monday. + className: + index === 0 || day.weekday === "Mon" + ? "leading-3" + : "invisible leading-3 @min-[1.25rem]:visible", + }, + [String(day.dayOfMonth)], + ), + ], + ), ], ), ), @@ -303,7 +413,14 @@ export const shiftTimeline = ( ), ] : Arr.flatMap(row.bars, (bar) => - barView(h, fromMs, toMs, bar, barRoomPct(fromMs, toMs, row.bars, bar)), + barView( + h, + fromMs, + toMs, + bar, + barRoomPct(fromMs, toMs, row.bars, bar), + row.isTabStop ?? true, + ), ), ), ], @@ -315,10 +432,15 @@ export const shiftTimeline = ( ]), ) const frame = h.div( - [h.Class("overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], + [h.Class("@container overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], [ h.div( - [h.Class("relative min-w-[720px]"), h.Role("table"), h.AriaLabel(options.ariaLabel)], + [ + // Fits a frame from 30rem; a narrower one (a phone) scrolls a 45rem track sideways. + h.Class("relative min-w-[45rem] @min-[30rem]:min-w-0"), + h.Role("table"), + h.AriaLabel(options.ariaLabel), + ], [ ...(options.nowLabel === undefined || Option.isNone(now) ? [] @@ -330,7 +452,7 @@ export const shiftTimeline = ( h.div( [ h.Class( - "pointer-events-none absolute inset-y-0 right-0 left-32 sm:left-40", + "pointer-events-none absolute inset-y-0 right-0 left-32 @2xl:left-40", ), ], [rules, ...nowLine],