diff --git a/tests/ui-testing/pages/oncallPages/oncallCoveragePage.js b/tests/ui-testing/pages/oncallPages/oncallCoveragePage.js index 1687d19eb8..16c4df33a0 100644 --- a/tests/ui-testing/pages/oncallPages/oncallCoveragePage.js +++ b/tests/ui-testing/pages/oncallPages/oncallCoveragePage.js @@ -64,6 +64,7 @@ export class OnCallCoveragePage { rotation: '[data-test="oncall-cover-rotation"]', window: '[data-test="oncall-cover-window"]', summary: '[data-test="oncall-cover-summary"]', + holder: '[data-test="oncall-cover-holder"]', swapA: '[data-test="oncall-swap-a"]', swapB: '[data-test="oncall-swap-b"]', swapSummary: '[data-test="oncall-swap-summary"]', @@ -72,6 +73,7 @@ export class OnCallCoveragePage { // the cover list coverList: '[data-test="oncall-cover-list"]', coverTable: '[data-test="oncall-cover-table"]', + coverListZone: '[data-test="oncall-cover-list-zone"]', coverRemoveDialog: '[data-test="oncall-cover-remove-dialog"]', // the schedule bar and its plain-words answer diff --git a/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js b/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js index 0d943fde60..61438262d0 100644 --- a/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js +++ b/tests/ui-testing/pages/oncallPages/oncallTeamDetailPage.js @@ -74,13 +74,16 @@ export class OnCallTeamDetailPage { configRisks: '[data-test="oncall-attention-expand"]', coverageCard: '[data-test="oncall-team-coverage-card"]', openSchedule: '[data-test="oncall-team-open-schedule"]', + coverageZone: '[data-test="oncall-coverage-zone"]', + recentPagesWindow: '[data-test="oncall-recent-pages-window"]', + recentPagesViewAll: '[data-test="oncall-recent-pages-view-all"]', // Schedule tab — the read surface. timeline: '[data-test="oncall-schedule-timeline"]', timelineChart: '[data-test="oncall-timeline-chart"]', timelineEmpty: '[data-test="oncall-timeline-empty"]', timelineAdd: '[data-test="oncall-timeline-add"]', - // A team with no rotations yet gets a From a template / From scratch menu on Add rotation. + // Add rotation is a menu: a template or from scratch, with or without rotations. timelineAddScratch: '[data-test="oncall-timeline-add-scratch"]', timelineAddTemplate: '[data-test="oncall-timeline-add-template"]', laneMenus: '[data-test^="oncall-lane-menu-"]', @@ -371,7 +374,8 @@ export class OnCallTeamDetailPage { async openRotationEditor() { await this.page.locator(this.locators.timelineAdd).click(); const scratch = this.page.locator(this.locators.timelineAddScratch); - if (await scratch.isVisible({ timeout: 1500 }).catch(() => false)) await scratch.click(); + await scratch.waitFor({ state: 'visible', timeout: 10000 }); + await scratch.click(); await expect(this.page.locator(this.locators.rotationDrawer)).toBeVisible({ timeout: 20000 }); } diff --git a/web/src/components/oncall/OnCallCoverForm.spec.ts b/web/src/components/oncall/OnCallCoverForm.spec.ts index 6d16b5ac13..91936bd92c 100644 --- a/web/src/components/oncall/OnCallCoverForm.spec.ts +++ b/web/src/components/oncall/OnCallCoverForm.spec.ts @@ -125,9 +125,17 @@ const stubs = { + `, setup(props: { name: string }) { - return { ...useStubbedField(props.name), PICKED }; + const field = useStubbedField(props.name); + // What the real picker does to a range it is handed: it keeps whole minutes only. + const toMinute = (micros: number) => Math.floor(micros / 60_000_000) * 60_000_000; + const round = () => { + const range = field.value.value as { from: number; to: number }; + field.set({ from: toMinute(range.from), to: toMinute(range.to) }); + }; + return { ...field, PICKED, round }; }, }, OButton: { @@ -309,6 +317,23 @@ describe("OnCallCoverForm — which clock the times are on", () => { await flushPromises(); expect(tonight().attributes("aria-pressed")).toBe("false"); }); + + /// "Rest of today" and "Next 7 days" start at now, which the picker reads back to the minute. + it.each(["rest-of-day", "tonight", "tomorrow", "next-7-days"])( + "keeps %s lit after the picker rounds its range to the minute", + async (key) => { + const wrapper = renderCover(); + const button = () => wrapper.findComponent(`[data-test="oncall-cover-preset-${key}"]`); + await wrapper.find(`[data-test="oncall-cover-preset-${key}"]`).trigger("click"); + await wrapper.find('[data-test="window-round"]').trigger("click"); + await flushPromises(); + expect(button().attributes("aria-pressed")).toBe("true"); + const lit = wrapper + .findAll('[data-test^="oncall-cover-preset-"]') + .filter((one) => one.attributes("aria-pressed") === "true"); + expect(lit).toHaveLength(1); + }, + ); }); /// The header's *Cover a shift* opens this dialog on the reader — the answer @@ -401,6 +426,16 @@ describe("OnCallCoverForm — swapping", () => { /// Slots do not compete — both are on call at the same instant — so trading /// across them staffs one pool twice instead of exchanging anything. + it("labels each shift with its start and end time, not only the dates", async () => { + const wrapper = render(); + await intoSwapMode(wrapper); + const labels = wrapper + .findAll("select")[0] + .findAll("option") + .map((o) => o.text()); + expect(labels[0]).toMatch(/\d{2}:\d{2}.* – .*\d{2}:\d{2}/); + }); + it("refuses a swap across two rotations", async () => { const wrapper = render([ { ...shift("ana@o2.ai", 0), rotationId: "rot_primary", rotationName: "Primary" }, @@ -770,6 +805,67 @@ describe("OnCallCoverForm — taking a cover", () => { /// Reopening on a different gap must not inherit the last one's window or /// its complaints. + it("puts a space between the summary's two sentences", async () => { + const wrapper = renderCover({ gap: GAP, teamName: "Platform" }); + await flushPromises(); + expect(wrapper.find('[data-test="oncall-cover-summary"]').element.textContent).toMatch( + /Platform\.\s+Outside that window/, + ); + }); + + describe("whose shift it is", () => { + const ROTATIONS = [ + { id: "rot_p", name: "Primary", shift_rules: [] }, + { id: "rot_s", name: "Secondary", shift_rules: [] }, + ]; + const POSITIONS = [ + { rotation_id: "rot_p", rotation_name: "Primary", rule: "Base", user_email: "ana@o2.ai" }, + { rotation_id: "rot_s", rotation_name: "Secondary", rule: "Base", user_email: "bo@o2.ai" }, + ]; + const holderLine = (wrapper: ReturnType) => + wrapper.find('[data-test="oncall-cover-holder"]'); + + it("names the clicked shift's person and sends them as covering_for", async () => { + const wrapper = renderCover({ + gap: GAP, + rotations: ROTATIONS, + positions: POSITIONS, + defaultRotation: "rot_s", + currentHolder: "raj@o2.ai", + defaultUser: "ana@o2.ai", + }); + await flushPromises(); + expect(holderLine(wrapper).text()).toBe("On call: raj@o2.ai"); + + const saved = (await save(wrapper)) as Record; + expect(saved).toMatchObject({ rotation_id: "rot_s", covering_for: "raj@o2.ai" }); + }); + + it("names the picked rotation's holder now, without claiming whose hours they are", async () => { + const wrapper = renderCover({ + gap: GAP, + rotations: ROTATIONS, + positions: POSITIONS, + defaultRotation: "rot_s", + }); + await flushPromises(); + expect(holderLine(wrapper).text()).toBe("On call: bo@o2.ai"); + expect(((await save(wrapper)) as Record).covering_for).toBeUndefined(); + }); + + it("sends no covering_for when the holder covers their own shift", async () => { + const wrapper = renderCover({ gap: GAP, currentHolder: "ana@o2.ai" }); + await flushPromises(); + expect(((await save(wrapper)) as Record).covering_for).toBeUndefined(); + }); + + it("shows no holder line when nobody is known to be on call", async () => { + const wrapper = renderCover({ gap: GAP }); + await flushPromises(); + expect(holderLine(wrapper).exists()).toBe(false); + }); + }); + it("re-seeds from the gap it was reopened on", async () => { const wrapper = renderCover({ gap: GAP }); expect(await save(wrapper)).toMatchObject({ start_at: GAP.from }); diff --git a/web/src/components/oncall/OnCallCoverForm.vue b/web/src/components/oncall/OnCallCoverForm.vue index 90ac58d21b..b7d3e667a3 100644 --- a/web/src/components/oncall/OnCallCoverForm.vue +++ b/web/src/components/oncall/OnCallCoverForm.vue @@ -113,6 +113,17 @@ along with this program. If not, see . data-test="oncall-cover-rotation" /> + + + +
@@ -163,6 +174,7 @@ along with this program. If not, see . + {{ raw(" ") }} {{ t("oncall.coverSummaryAfter") }} @@ -184,7 +196,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue"; import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue"; import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue"; import ODialog from "@/lib/overlay/Dialog/ODialog.vue"; -import type { OnCallTeamMember } from "@/ts/interfaces/oncall"; +import type { OnCallPosition, OnCallTeamMember } from "@/ts/interfaces/oncall"; import { MICROS_PER_DAY, MICROS_PER_HOUR } from "@/ts/interfaces/oncall"; import type { I18nKey, I18nText } from "@/types/i18n"; import { raw, useI18nTyped } from "@/types/i18n"; @@ -233,8 +245,10 @@ const props = withDefaults( members?: OnCallTeamMember[]; timezone?: string; saving?: boolean; - /** Who currently holds the shift; hosts still pass it, nothing on the form names them. */ + /** Whose shift a cover opened from a clicked shift relieves; sent as `covering_for`. */ currentHolder?: string | null; + /** Who each rotation puts on call now, for the holder line when no shift was clicked. */ + positions?: OnCallPosition[]; /** The reader's own clock; the zone is named in the field, and the window restated in the tooltip, when it is not the team's. */ viewerTimezone?: string; /** Named in the confirmation, because a cover is always a cover OF a team. */ @@ -275,6 +289,7 @@ const props = withDefaults( viewerTimezone: "UTC", saving: false, currentHolder: null, + positions: () => [], teamName: "", gap: null, defaultUser: "", @@ -290,7 +305,13 @@ const emit = defineEmits<{ (e: "update:open", open: boolean): void; ( e: "save", - value: { user_email: string; start_at: number; end_at: number; rotation_id?: string }, + value: { + user_email: string; + start_at: number; + end_at: number; + rotation_id?: string; + covering_for?: string; + }, ): void; /** * Two covers, one each way — the caller writes both and owns what happens if @@ -418,6 +439,21 @@ const windowValue = form.useStore( const userValue = form.useStore( (state: { values: OnCallCoverFormValues }) => state.values.user_email, ); +const rotationValue = form.useStore( + (state: { values: OnCallCoverFormValues }) => state.values.rotation_id, +); + +/// The clicked shift's person while its rotation is still the one picked; otherwise whoever that rotation has on now. +const holder = computed(() => { + const rotation = String(rotationValue.value ?? ""); + if (props.currentHolder && (!props.defaultRotation || rotation === props.defaultRotation)) { + return props.currentHolder; + } + const position = + props.positions.find((one) => one.rotation_id === rotation) ?? + (props.rotations.length <= 1 ? props.positions[0] : undefined); + return position?.user_email ?? ""; +}); const PRESETS = [ { key: "rest-of-day", labelKey: "oncall.coverRestOfDay" }, @@ -446,29 +482,45 @@ function zonedInstant(micros: number, zone: string, addDays: number, time = "00: /// This used to say so while flooring the instant to a multiple of a day, which /// is midnight UTC — so an IST team's "tonight" began at 23:30 and its "rest of /// today" ran to half past five the following morning. -function applyPreset(key: (typeof PRESETS)[number]["key"]) { - const now = Date.now() * 1000; - const zone = props.timezone; - const at = (addDays: number, time?: string) => zonedInstant(now, zone, addDays, time); +type PresetKey = (typeof PRESETS)[number]["key"]; - const ranges: Record = { +function presetRanges(now: number): Record { + const at = (addDays: number, time?: string) => zonedInstant(now, props.timezone, addDays, time); + return { "rest-of-day": { from: now, to: at(1) }, tonight: { from: at(0, "18:00"), to: at(1, "06:00") }, tomorrow: { from: at(1), to: at(2) }, "next-7-days": { from: now, to: now + 7 * MICROS_PER_DAY }, }; +} + +function applyPreset(key: PresetKey) { + const ranges = presetRanges(Date.now() * 1000); // `type` travels with every write into this field, not just the seed: the // control is re-read whenever the value changes from outside it, and a range // with no type is re-read as a relative period and resolved against `now` — // which threw the preset away and put the past half hour back. form.setFieldValue("window", { type: "absolute", ...ranges[key] }); presetApplied = { key, ...ranges[key] }; - activePreset.value = key; } -/// The preset whose range the field still holds; any other edit to the window unlights it. -const activePreset = ref(null); -let presetApplied: { key: string; from: number; to: number } | null = null; +/// The picker stores whole minutes, so a preset starting "now" comes back up to a minute off what was written. +const PRESET_MATCH_MICROS = 60 * 1_000_000; + +let presetApplied: { key: PresetKey; from: number; to: number } | null = null; + +const near = (a: number, b: number) => Math.abs(a - b) <= PRESET_MATCH_MICROS; + +/// Derived from the range the field holds, against the last preset written and each preset as of now. +const activePreset = computed(() => { + const value = windowValue.value; + if (typeof value?.from !== "number" || typeof value?.to !== "number") return null; + const matches = (range: { from: number; to: number }) => + near(value.from as number, range.from) && near(value.to as number, range.to); + if (presetApplied && matches(presetApplied)) return presetApplied.key; + const fresh = presetRanges(Date.now() * 1000); + return PRESETS.find((preset) => matches(fresh[preset.key]))?.key ?? null; +}); /// The zone the picker reads its fields in: the team's when it opens, and the reader may pick another. const pickedZone = ref(props.timezone); @@ -485,12 +537,6 @@ function calendarDay(aheadMicros: number): string { return toZonedInputValue(at, pickedZone.value).slice(0, 10).replace(/-/g, "/"); } -watch(windowValue, (value) => { - const applied = presetApplied; - activePreset.value = - applied && value?.from === applied.from && value?.to === applied.to ? applied.key : null; -}); - /// The field states the window on the team's clock, naming the zone once, at the end, when the picker is on another one. const windowLabel = computed(() => { const window = windowValue.value; @@ -542,16 +588,24 @@ const rotationOptions = computed(() => /// weeks with the same dates and different positions are otherwise /// indistinguishable in the picker, and picking the wrong one writes a cover on /// the wrong rotation. +const SHIFT_STAMP: Intl.DateTimeFormatOptions = { + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", +}; + const shiftLabel = (shift: UpcomingShift): I18nText => t(props.rotations.length > 1 ? "oncall.swapShiftOptionInRotation" : "oncall.swapShiftOption", { name: raw(nameOf(shift.member)), rotation: raw(shift.rotationName), range: raw( - `${formatInZone(shift.startMicros, props.timezone, { - weekday: "short", - day: "numeric", - month: "short", - })} – ${formatInZone(shift.endMicros, props.timezone, { day: "numeric", month: "short" })}`, + `${formatInZone(shift.startMicros, props.timezone, SHIFT_STAMP)} – ${formatInZone( + shift.endMicros, + props.timezone, + SHIFT_STAMP, + )}`, ), }); @@ -606,13 +660,7 @@ const swapSummary = computed<{ const pair = swapPair.value; if (!pair || swapProblem.value) return null; const when = (shift: UpcomingShift) => - raw( - formatInZone(shift.startMicros, props.timezone, { - weekday: "short", - day: "numeric", - month: "short", - }), - ); + raw(formatInZone(shift.startMicros, props.timezone, SHIFT_STAMP)); return { first: pair.first.member, second: pair.second.member, @@ -659,11 +707,18 @@ function close() { function onSubmit(value: OnCallCoverFormValues) { const window = value.window; if (typeof window?.from !== "number" || typeof window?.to !== "number") return; + const user = String(value.user_email ?? ""); + // Only a clicked shift says whose shift this is; the rotation's holder now may not hold the picked hours. + const coveringFor = + props.currentHolder && holder.value === props.currentHolder && holder.value !== user + ? props.currentHolder + : undefined; emit("save", { - user_email: String(value.user_email ?? ""), + user_email: user, start_at: window.from, end_at: window.to, rotation_id: String(value.rotation_id ?? "") || undefined, + ...(coveringFor ? { covering_for: coveringFor } : {}), }); } diff --git a/web/src/components/oncall/OnCallCoverList.spec.ts b/web/src/components/oncall/OnCallCoverList.spec.ts index 90bcfb1ce5..cb8f65d51c 100644 --- a/web/src/components/oncall/OnCallCoverList.spec.ts +++ b/web/src/components/oncall/OnCallCoverList.spec.ts @@ -110,6 +110,16 @@ describe("OnCallCoverList", () => { expect(wrapper.findAll('[data-test="row"]')).toHaveLength(0); }); + // The covers' times are on the team's clock, which nothing else on the row says. + it("names the team's zone beside the list title", async () => { + service.listOverrides.mockResolvedValue(ok([cover()])); + const wrapper = render({ timezone: "Asia/Kolkata" }); + await flushPromises(); + expect(wrapper.find('[data-test="oncall-cover-list-zone"]').text()).toBe( + "Times in Asia/Kolkata", + ); + }); + it("lists the covers standing over this team's rotations", async () => { service.listOverrides.mockResolvedValue( ok([cover({ covering_for: "ana@o2.ai", reason: "dentist" })]), diff --git a/web/src/components/oncall/OnCallCoverList.vue b/web/src/components/oncall/OnCallCoverList.vue index 34a14344ce..afcc70bc24 100644 --- a/web/src/components/oncall/OnCallCoverList.vue +++ b/web/src/components/oncall/OnCallCoverList.vue @@ -29,9 +29,14 @@ along with this program. If not, see . @@ -147,6 +150,7 @@ import type { Rotation } from "@/ts/interfaces/oncall"; import { MICROS_PER_DAY } from "@/ts/interfaces/oncall"; import type { I18nText } from "@/types/i18n"; import { raw, useI18nTyped } from "@/types/i18n"; +import OText from "@/lib/core/Typography/OText.vue"; import { formatMicrosDuration } from "@/utils/formatters"; import { formatInZone, resolveHolder, wallTimeInZone } from "@/utils/oncall"; @@ -355,9 +359,11 @@ function tooltipFor(bandKey: string): TooltipSegment[] { const rows: TooltipSegment[] = shown.map((segment) => ({ key: `${segment.from}`, range: showRange - ? raw( - `${rangeStamp(Math.max(segment.from, run.from))} – ${rangeStamp(Math.min(segment.to, run.to))}`, - ) + ? t("oncall.shiftBandTipRangeZoned", { + from: raw(rangeStamp(Math.max(segment.from, run.from))), + to: raw(rangeStamp(Math.min(segment.to, run.to))), + zone: raw(props.timezone), + }) : null, primary: t("oncall.coveragePrimaryOnly", { primary: tooltipOf(segment.primary ?? "") }), secondary: segment.secondary diff --git a/web/src/components/oncall/OnCallEscalationLadder.spec.ts b/web/src/components/oncall/OnCallEscalationLadder.spec.ts index 3912fadcfb..ef9ad8fcea 100644 --- a/web/src/components/oncall/OnCallEscalationLadder.spec.ts +++ b/web/src/components/oncall/OnCallEscalationLadder.spec.ts @@ -179,7 +179,7 @@ describe("OnCallEscalationLadder", () => { }); /// The card folds a repeat into one row, but the chip counts every step, as the policy editor does. - it("counts every configured step, repeats included", () => { + it("draws one row per configured step, matching the chip's step count", () => { const whole = (after: number) => ({ after_micros: after, targets: ["the whole team"], @@ -201,8 +201,9 @@ describe("OnCallEscalationLadder", () => { }), }); - expect(wrapper.findAll('[data-test^="oncall-ladder-rung-time-"]')).toHaveLength(2); + expect(wrapper.findAll('[data-test^="oncall-ladder-rung-time-"]')).toHaveLength(3); expect(wrapper.find('[data-test="oncall-ladder-priority-p1"]').text()).toContain("3 steps"); + expect(wrapper.find('[data-test="oncall-ladder-rungs"]').text()).not.toContain("repeats"); }); /// An unselected priority has no preview, so the policy's steps are counted the same way. @@ -644,7 +645,7 @@ describe("OnCallEscalationLadder", () => { /// A ladder that pages the same unreachable people at every level is one /// finding, not four identical blocks a reader has to compare by eye. - it("folds consecutive rungs that resolve to the same people into one row", () => { + it("keeps consecutive rungs that resolve to the same people as separate rows", () => { const stale = (afterMicros: number) => ({ after_micros: afterMicros, targets: ["the whole team"], @@ -666,11 +667,10 @@ describe("OnCallEscalationLadder", () => { }), }); - // One folded row — not four rows saying the same thing. - expect(wrapper.findAll('[data-test^="oncall-ladder-rung-time-"]')).toHaveLength(1); - const row = wrapper.find('[data-test="oncall-ladder-rung-0"]'); - expect(row.text()).toContain("· repeats until 30 min"); - expect(row.text()).toContain("3 of 3 unreachable"); + expect(wrapper.findAll('[data-test^="oncall-ladder-rung-time-"]')).toHaveLength(4); + const last = wrapper.find(`[data-test="oncall-ladder-rung-${30 * MICROS_PER_MINUTE}"]`); + expect(last.text()).toContain("3 of 3 unreachable"); + expect(wrapper.text()).not.toContain("repeats until"); }); /// A rung that differs from its neighbour — even by one person's diff --git a/web/src/components/oncall/OnCallEscalationLadder.vue b/web/src/components/oncall/OnCallEscalationLadder.vue index 71f43bd0de..0955530d1b 100644 --- a/web/src/components/oncall/OnCallEscalationLadder.vue +++ b/web/src/components/oncall/OnCallEscalationLadder.vue @@ -200,9 +200,6 @@ along with this program. If not, see . {{ raw(seat) }} - - {{ t("oncall.ladderRepeatsUntil", { to: durationLabel(group.lastMicros) }) }} - `${one.user_email}:${one.would_a_page_land}:${one.why_not ?? ""}`) - .join(","); - return JSON.stringify([rung.targets, people, rung.resolves_to_nobody]); -} - -/// Consecutive rungs sharing a signature fold into one row. A ladder that -/// pages the same unreachable people every 5 minutes for half an hour is one -/// fact — "still nobody" — not six rows saying it again. -const rungGroups = computed(() => { - const rungs = preview.value?.rungs ?? []; - const out: RungGroup[] = []; - let previousSignature: string | null = null; - - for (const rung of rungs) { - const signature = rungSignature(rung); - const last = out[out.length - 1]; - if (last && signature === previousSignature) { - last.count += 1; - last.lastMicros = rung.after_micros; - } else { - out.push({ - key: `${rung.after_micros}`, - rung, - count: 1, - firstMicros: rung.after_micros, - lastMicros: rung.after_micros, - }); - } - previousSignature = signature; - } - return out; -}); +/// One row per configured step, so the rows always count what the priority chip says. +const rungGroups = computed(() => + (preview.value?.rungs ?? []).map((rung) => ({ + key: `${rung.after_micros}`, + rung, + firstMicros: rung.after_micros, + })), +); /// The seat a target names: a rotation's name for its on-call, the engine's own words otherwise. function seatOf(target: string): string { diff --git a/web/src/components/oncall/OnCallRecentPages.spec.ts b/web/src/components/oncall/OnCallRecentPages.spec.ts index cd8fcb57fe..3ea465cb8a 100644 --- a/web/src/components/oncall/OnCallRecentPages.spec.ts +++ b/web/src/components/oncall/OnCallRecentPages.spec.ts @@ -121,17 +121,24 @@ describe("OnCallRecentPages", () => { expect(subject(render([page()])).size).toBe(200); }); - it("shows the newest few with no count line beside the title", () => { + it("says how many of the window's pages the capped list shows", () => { const wrapper = render( Array.from({ length: 6 }, (_, i) => page({ id: `p${i}`, opened_at: OPENED - i * MINUTE })), ); expect(rows(wrapper)).toHaveLength(3); - const header = wrapper.find('[data-test="oncall-recent-pages"] > div'); - expect(header.findAll(":scope > *").map((el) => el.text())).toEqual([ - "Recent pages", - "View all in On-Call", - ]); + expect(wrapper.find('[data-test="oncall-recent-pages-window"]').text()).toBe( + "3 of 6 in the last 7 days", + ); + }); + + it("states the plain count when nothing is cut, and nothing while loading", async () => { + const wrapper = render([page({ id: "only" })], { windowDays: 14 }); + expect(wrapper.find('[data-test="oncall-recent-pages-window"]').text()).toBe( + "1 in the last 14 days", + ); + await wrapper.setProps({ loading: true }); + expect(wrapper.find('[data-test="oncall-recent-pages-window"]').exists()).toBe(false); }); it("orders newest first regardless of the order it was handed", () => { diff --git a/web/src/components/oncall/OnCallRecentPages.vue b/web/src/components/oncall/OnCallRecentPages.vue index 8ab23cca79..76fee04153 100644 --- a/web/src/components/oncall/OnCallRecentPages.vue +++ b/web/src/components/oncall/OnCallRecentPages.vue @@ -26,6 +26,10 @@ along with this program. If not, see . >
{{ t("oncall.teamRecentPages") }} + + + {{ windowLabel }} + (), - { pages: () => [], policy: null, limit: 3, loading: false }, + { pages: () => [], policy: null, limit: 3, loading: false, windowDays: 7 }, ); const emit = defineEmits<{ open: [page: OnCallResponse]; "view-all": [] }>(); @@ -200,4 +206,15 @@ const columns = computed[]>(() => [ size: 160, }, ]); + +/// A capped list that does not say it is capped reads as the team's whole history. +const windowLabel = computed(() => + props.pages.length > rows.value.length + ? t("oncall.teamRecentPagesWindowOf", { + shown: rows.value.length, + total: props.pages.length, + days: props.windowDays, + }) + : t("oncall.teamRecentPagesWindow", { count: props.pages.length, days: props.windowDays }), +); diff --git a/web/src/components/oncall/OnCallScheduleEditor.spec.ts b/web/src/components/oncall/OnCallScheduleEditor.spec.ts index f7c47a2370..c74913dd40 100644 --- a/web/src/components/oncall/OnCallScheduleEditor.spec.ts +++ b/web/src/components/oncall/OnCallScheduleEditor.spec.ts @@ -558,6 +558,45 @@ describe("OnCallScheduleEditor", () => { ).toBeUndefined(); }); + // Handover and active hours are wall times on the team's clock, so both say which. + it("names the team's zone beside the handover time and the active hours", async () => { + const wrapper = render({ + schedule: schedule([rota("Primary")]), + timezone: "Asia/Kolkata", + drawerOnly: true, + intent: { mode: "edit", id: "rot_primary" }, + }); + await flushPromises(); + expect(wrapper.find('[data-test="oncall-schedule-handover-0-zone"]').text()).toBe( + "Asia/Kolkata", + ); + expect(wrapper.find('[data-test="oncall-schedule-covers-0-zone"]').text()).toBe("Asia/Kolkata"); + }); + + /// A name two rotations share makes every menu, cover and level naming it ambiguous, so it cannot be saved. + it("blocks saving a rotation renamed to another rotation's name, and says why", async () => { + const wrapper = render({ + schedule: schedule([rota("Primary"), rota("Secondary")]), + drawerOnly: true, + intent: { mode: "edit", id: "rot_secondary" }, + }); + await flushPromises(); + const done = () => wrapper.find('[data-test="oncall-rotation-done"]'); + expect(done().attributes("disabled")).toBeUndefined(); + + await wrapper.findComponent({ name: "OInput" }).vm.$emit("update:modelValue", " primary "); + await flushPromises(); + expect(done().attributes("disabled")).toBeDefined(); + expect(wrapper.find('[data-test="oncall-schedule-name-clash"]').text()).toBe( + "Another rotation is already called Primary. Pick a different name to save.", + ); + + await wrapper.findComponent({ name: "OInput" }).vm.$emit("update:modelValue", "Backup"); + await flushPromises(); + expect(done().attributes("disabled")).toBeUndefined(); + expect(wrapper.find('[data-test="oncall-schedule-name-clash"]').exists()).toBe(false); + }); + /// Nothing in this drawer can add a person, so the empty picker has to hand /// the reader to the one screen that can. it("sends the reader to the roster from inside the drawer", async () => { diff --git a/web/src/components/oncall/OnCallScheduleEditor.vue b/web/src/components/oncall/OnCallScheduleEditor.vue index c1accadac7..07deb8c333 100644 --- a/web/src/components/oncall/OnCallScheduleEditor.vue +++ b/web/src/components/oncall/OnCallScheduleEditor.vue @@ -172,7 +172,8 @@