Merge branch 'fix/oncall-ux-schedule' into fix/oncall-ux-polish

This commit is contained in:
ktx-abhay
2026-11-04 18:15:05 +05:30
23 changed files with 897 additions and 220 deletions
@@ -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
@@ -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 });
}
@@ -125,9 +125,17 @@ const stubs = {
<slot name="above" />
<button data-test="window-pick" @click="set(PICKED)">{{ error }}</button>
<button data-test="window-clear" @click="set(undefined)"></button>
<button data-test="window-round" @click="round()"></button>
</span>`,
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<typeof renderCover>) =>
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<string, unknown>;
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<string, unknown>).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<string, unknown>).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 });
+86 -31
View File
@@ -113,6 +113,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-cover-rotation"
/>
<i18n-t
v-if="holder"
keypath="oncall.coverHolderLine"
tag="p"
scope="global"
class="text-text-secondary text-sm"
data-test="oncall-cover-holder"
>
<template #name><OnCallPersonName :email="holder" /></template>
</i18n-t>
<!-- Absolute only: a relative period resolves against the moment it is read, and a cover is forward-looking. -->
<!-- A plain block: straight in the form's flex column, the picker's border stretches past its buttons. -->
<div>
@@ -163,6 +174,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #name><OnCallPersonName :email="summary.who" /></template>
<template #team>{{ summary.team }}</template>
</i18n-t>
{{ raw(" ") }}
<span class="text-text-secondary">{{ t("oncall.coverSummaryAfter") }}</span>
</OBanner>
</OForm>
@@ -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<string>(() => {
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<string, { from: number; to: number }> = {
function presetRanges(now: number): Record<PresetKey, { from: number; to: number }> {
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<string | null>(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<PresetKey | null>(() => {
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<string>(() => {
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 } : {}),
});
}
</script>
@@ -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" })]),
@@ -29,9 +29,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<!-- Nothing at all without covers: the timeline and its legend already say there are none. -->
<section v-if="covers.length" class="flex flex-col gap-2" data-test="oncall-cover-list">
<OText variant="section" as="h3" data-test="oncall-cover-list-title">
{{ t("oncall.coverListCount", { count: covers.length }) }}
</OText>
<span class="flex flex-wrap items-baseline gap-x-2">
<OText variant="section" as="h3" data-test="oncall-cover-list-title">
{{ t("oncall.coverListCount", { count: covers.length }) }}
</OText>
<OText variant="meta" data-test="oncall-cover-list-zone">
{{ t("oncall.timesInZone", { zone: raw(timezone) }) }}
</OText>
</span>
<OTable
:data="covers"
@@ -85,6 +85,20 @@ describe("OnCallCoverageStrip", () => {
mockViewport.lgUp = true;
});
// The axis dates are team days, so the strip says which zone they are on.
it("names the team's zone under the strip", () => {
const wrapper = mount(OnCallCoverageStrip, {
props: {
rotations: [rotation(["ana@o2.ai"])],
timezone: "Asia/Kolkata",
days: 14,
fromMicros: FROM,
},
global: { plugins: [i18n], stubs },
});
expect(wrapper.find('[data-test="oncall-coverage-zone"]').text()).toBe("Times in Asia/Kolkata");
});
/// A covered fortnight is ONE band, not 336 hourly slivers the browser has to
/// lay out.
it("merges an unbroken stretch of cover into a single band", () => {
@@ -114,7 +114,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template #legend>
<span class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="flex w-full flex-wrap items-center gap-x-4 gap-y-1">
<span
v-for="entry in legend"
:key="entry.key"
@@ -123,6 +123,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="size-2 rounded-full" :class="entry.swatch" aria-hidden="true" />
{{ entry.label }}
</span>
<OText variant="meta" class="ms-auto" data-test="oncall-coverage-zone">
{{ t("oncall.timesInZone", { zone: raw(timezone) }) }}
</OText>
</span>
</template>
</OScheduleTimeline>
@@ -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
@@ -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
@@ -200,9 +200,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ raw(seat) }}
</OTag>
</template>
<span v-if="group.count > 1" class="text-text-secondary text-xs">
{{ t("oncall.ladderRepeatsUntil", { to: durationLabel(group.lastMicros) }) }}
</span>
<!-- Only when something is wrong, as a badge; the server's sentence is one hover away. -->
<OTag
v-if="problems[group.firstMicros]"
@@ -479,50 +476,18 @@ function delayLabel(afterMicros: number): I18nText {
interface RungGroup {
key: string;
/** The first rung in the run — every rung it absorbs reads identically. */
rung: PreviewRung;
count: number;
firstMicros: number;
lastMicros: number;
}
/// What makes two rungs the same finding: who they aim at, who that resolves
/// to, and whether each of those people is reachable. Delay is deliberately
/// absent — that is the axis being folded, not part of the match.
function rungSignature(rung: PreviewRung): string {
const people = rung.recipients
.map((one) => `${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<RungGroup[]>(() => {
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<RungGroup[]>(() =>
(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 {
@@ -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", () => {
@@ -26,6 +26,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1 px-4 py-3">
<OText variant="panel-title">{{ t("oncall.teamRecentPages") }}</OText>
<!-- Not while loading: a count under a skeleton reads as an answer. -->
<OText v-if="!loading" variant="meta" data-test="oncall-recent-pages-window">
{{ windowLabel }}
</OText>
<OButton
variant="ghost-primary"
size="xs"
@@ -121,7 +125,7 @@ import OCodeCell from "@/lib/core/Table/cells/OCodeCell.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OText from "@/lib/core/Typography/OText.vue";
import type { OnCallPolicy, OnCallResponse } from "@/ts/interfaces/oncall";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatMicrosDuration } from "@/utils/formatters";
import { reachedStepOf } from "@/utils/oncall";
@@ -137,8 +141,10 @@ const props = withDefaults(
/** How many rows to show before deferring to the full list. */
limit?: number;
loading?: boolean;
/** How many days `pages` spans, for the header's count. */
windowDays?: number;
}>(),
{ 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<OTableColumnDef<OnCallResponse>[]>(() => [
size: 160,
},
]);
/// A capped list that does not say it is capped reads as the team's whole history.
const windowLabel = computed<I18nText>(() =>
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 }),
);
</script>
@@ -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 () => {
@@ -172,7 +172,8 @@
<p
v-if="nameClash"
class="text-status-warning-text text-xs"
class="text-status-error-text text-xs"
role="alert"
data-test="oncall-schedule-name-clash"
>
{{ nameClash }}
@@ -649,9 +650,7 @@ const wouldDiscardEdits = computed(() => {
return JSON.stringify(rotation) !== openedAs.value;
});
/// Two rotations with one name is a screen where "page the Secondary" names
/// two things. The server keys on the id, so this is a legibility rule rather
/// than a storage one — which is why it warns rather than blocks.
/// Blocks Save: two rotations with one name make every level, menu and cover that names one ambiguous.
const nameClash = computed<I18nText | "">(() => {
const rotation = active.value;
if (!rotation) return "";
@@ -677,6 +676,7 @@ const scheduleWrite = useMutation(() => setTeamScheduleMutation(orgId.value, pro
const activeIsSavable = computed(() => {
const rotation = active.value;
if (!rotation?.name?.trim()) return false;
if (nameClash.value) return false;
if (!rotation.shift_rules.length) return false;
if (rotation.shift_rules.some((rule) => !rule.members.length)) return false;
// The server refuses a From == To window outright.
@@ -178,21 +178,16 @@ describe("OnCallScheduleTimeline", () => {
});
/// One "Cover a shift" lives in the team header, and templates replace a schedule, so neither sits in the toolbar.
it("keeps only Add rotation in the toolbar, with a leading icon", () => {
it("labels Add rotation with a leading icon", () => {
const wrapper = render();
const add = wrapper.find('[data-test="oncall-timeline-add"]');
const actions = Array.from(add.element.parentElement?.children ?? []);
expect(actions.map((el) => el.getAttribute("data-test"))).toEqual([
"oncall-timeline-range-group",
"oncall-timeline-add",
]);
expect(add.text()).toBe("Add rotation");
expect(add.attributes("data-icon")).toBe("add");
});
// A template rewrites the first rotation, so a staffed team's Add rotation adds from scratch and offers no template.
it("offers a template or scratch from Add rotation only before any rotation exists", async () => {
// A staffed team still finds the templates where it adds a rotation, not only in the first lane's menu.
it("offers a template or scratch from Add rotation, with or without rotations", async () => {
const empty = render({ rotations: [], segments: [] });
expect(empty.find('[data-test="oncall-timeline-add"]').text()).toBe("Add rotation");
expect(empty.find('[data-test="oncall-timeline-add-template"]').text()).toBe("From a template");
@@ -202,20 +197,21 @@ describe("OnCallScheduleTimeline", () => {
expect(empty.emitted("add")).toHaveLength(1);
const staffed = render();
expect(staffed.find('[data-test="oncall-timeline-add-template"]').exists()).toBe(false);
expect(staffed.find('[data-test="oncall-timeline-add-scratch"]').exists()).toBe(false);
const add = staffed.find('[data-test="oncall-timeline-add"]');
expect(add.attributes("data-icon")).toBe("add");
await add.trigger("click");
const template = staffed.find('[data-test="oncall-timeline-add-template"]');
expect(template.text()).toBe("Start from a template");
await template.trigger("click");
expect(staffed.emitted("presets")).toHaveLength(1);
await staffed.find('[data-test="oncall-timeline-add-scratch"]').trigger("click");
expect(staffed.emitted("add")).toHaveLength(1);
expect(staffed.emitted("presets")).toBeUndefined();
expect(staffed.find('[data-test^="oncall-lane-presets-"]').exists()).toBe(true);
});
// An open shift or lane menu holds the pointer, so the hover line would freeze under it.
it("hides the hover line while a shift or lane menu is open", async () => {
const wrapper = render();
const chart = wrapper.findComponent({ name: "OScheduleTimeline" });
const menus = wrapper.findAllComponents({ name: "ODropdown" });
// The first is the toolbar's Add rotation menu, which sits off the chart.
const menus = wrapper.findAllComponents({ name: "ODropdown" }).slice(1);
expect(chart.props("hideHover")).toBe(false);
menus[0].vm.$emit("update:open", true);
@@ -249,7 +245,7 @@ describe("OnCallScheduleTimeline", () => {
const tip = wrapper
.findAllComponents({ name: "OTooltip" })
.find((one) => one.attributes("data-test") === `oncall-shift-tip-${band.key}`);
expect(String(tip!.props("content"))).toMatch(/^ana@o2\.ai · .+ – .+/);
expect(String(tip!.props("content"))).toMatch(/^ana@o2\.ai · .+ – .+ \(UTC\)$/);
});
it("draws one lane per rotation", () => {
@@ -762,6 +758,86 @@ describe("OnCallScheduleTimeline", () => {
"oncall-shift-menu-cover",
]);
});
it("offers no swap on a cover's span, which is not the roster's shift", () => {
const cover = seg({ override_id: "ov1" });
expect(menuIds(render({ segments: [cover], swapRotations: [rid("Primary")] }))).toEqual([
"oncall-shift-menu-cover",
"oncall-shift-menu-away",
]);
});
/// Two rotations may share a name; a band keyed by name acted on the other one's person.
it("keys bands by rotation id, so same-named rotations act on their own shifts", async () => {
const first: Rotation = { ...rotation("Secondary"), id: "rot_a" };
const second: Rotation = { ...rotation("Secondary"), id: "rot_b" };
const wrapper = render({
rotations: [first, second],
segments: [
seg({ rotation_id: "rot_a", user_email: "raj@o2.ai" }),
seg({ rotation_id: "rot_b", user_email: "sam@o2.ai" }),
],
});
const [a, b] = tracksOf(wrapper).map((track) => track.bands[0].key);
expect(a).not.toBe(b);
const away = wrapper.findAll('[data-test="oncall-shift-menu-away"]');
expect(away.map((item) => item.text())).toEqual([
"Mark raj@o2.ai away",
"Mark sam@o2.ai away",
]);
await wrapper.findAll('[data-test="oncall-shift-menu-cover"]')[0].trigger("click");
const [, segment] = wrapper.emitted("shift-action")![0] as [string, ResolvedSegment];
expect(segment).toMatchObject({ rotation_id: "rot_a", user_email: "raj@o2.ai" });
});
});
describe("the day view", () => {
const toDay = async (wrapper: VueWrapper) => {
wrapper.findComponent({ name: "OToggleGroup" }).vm.$emit("update:modelValue", "day");
await nextTick();
};
it("labels the axis with hours, not one date", async () => {
const wrapper = render();
await toDay(wrapper);
const ticks = wrapper
.findComponent({ name: "OScheduleTimeline" })
.props("axisTicks") as ScheduleAxisTick[];
expect(ticks.map((tick) => String(tick.label))).toEqual([
"00:00",
"03:00",
"06:00",
"09:00",
"12:00",
"15:00",
"18:00",
"21:00",
]);
});
it("thins the hour ticks on a phone", async () => {
mockViewport.mdUp = false;
const wrapper = render();
await toDay(wrapper);
const ticks = wrapper
.findComponent({ name: "OScheduleTimeline" })
.props("axisTicks") as ScheduleAxisTick[];
expect(ticks).toHaveLength(4);
});
it("names a single day once, with its weekday and year", async () => {
const wrapper = render();
await toDay(wrapper);
const label = wrapper.find('[data-test="oncall-timeline-range"]').text();
expect(label).not.toContain("–");
expect(label).toMatch(/\d{4}/);
expect(label).toContain(
new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(
new Date(start / 1000),
),
);
});
});
describe("the away lane", () => {
@@ -99,18 +99,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OToggleGroupItem>
</OToggleGroup>
<!-- A template only rewrites the first rotation, so once one exists Add rotation adds from scratch; Start over lives in that lane's menu. -->
<OButton
v-if="rotations.length"
variant="outline"
size="sm-action"
icon-left="add"
data-test="oncall-timeline-add"
@click="emit('add')"
>
{{ t("oncall.addRotation") }}
</OButton>
<ODropdown v-else align="end">
<!-- A template rewrites the first rotation; the drawer it opens names which one. -->
<ODropdown align="end">
<template #trigger>
<OButton
variant="outline"
@@ -127,7 +117,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-timeline-add-template"
@select="emit('presets')"
>
{{ t("oncall.addRotationFromTemplate") }}
{{ rotations.length ? t("oncall.presetsTitle") : t("oncall.addRotationFromTemplate") }}
</ODropdownItem>
<ODropdownItem
icon-left="edit"
@@ -545,13 +535,15 @@ const fmt = (micros: number, opts: Intl.DateTimeFormatOptions) =>
formatInZone(micros, props.timezone, opts);
const rangeLabel = computed<I18nText>(() =>
raw(
`${fmt(from.value, { day: "numeric", month: "short" })} – ${fmt(to.value - 1, {
day: "numeric",
month: "short",
year: "numeric",
})}`,
),
days.value === 1
? raw(fmt(from.value, { weekday: "short", day: "numeric", month: "short", year: "numeric" }))
: raw(
`${fmt(from.value, { day: "numeric", month: "short" })} – ${fmt(to.value - 1, {
day: "numeric",
month: "short",
year: "numeric",
})}`,
),
);
const localZone = computed(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
@@ -723,7 +715,8 @@ function emptyLine(track: ScheduleTrack): I18nText {
return t("oncall.laneEmptyNeverWins");
}
const bandKeyOf = (lane: Lane, segment: ResolvedSegment) => `${lane.name}-${segment.from}`;
// By id: two rotations may share a name, and a name-keyed band acted on the other one.
const bandKeyOf = (lane: Lane, segment: ResolvedSegment) => `${lane.id}-${segment.from}`;
const AWAY_TRACK = "__away";
@@ -830,9 +823,10 @@ const segmentByBand = computed(() => {
});
const tipRange = (start: number, end: number) =>
t("oncall.shiftBandTipRange", {
from: raw(fmt(start, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
to: raw(fmt(end, { weekday: "short", hour: "2-digit", minute: "2-digit" })),
t("oncall.shiftBandTipRangeZoned", {
from: raw(fmt(start, { weekday: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })),
to: raw(fmt(end, { weekday: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })),
zone: raw(props.timezone),
});
/// Name and email for a band's person, because the band itself only has room for a name and two people can share one.
@@ -867,7 +861,13 @@ function shiftActions(bandKey: string): ShiftMenuItem[] {
if (props.canCover) {
items.push({ id: "cover", icon: "person-add", label: t("oncall.shiftMenuCover") });
}
if (who && props.canCover && props.swapRotations.includes(segment.rotation_id)) {
// A cover's span is not the roster's, so it has no shift of its own to trade.
if (
who &&
!segment.override_id &&
props.canCover &&
props.swapRotations.includes(segment.rotation_id)
) {
items.push({ id: "swap", icon: "swap-horiz", label: t("oncall.shiftMenuSwap") });
}
if (who && (props.canMarkAway || who.toLowerCase() === props.selfEmail.toLowerCase())) {
@@ -892,6 +892,7 @@ function onShiftAction(action: ShiftAction, bandKey: string) {
/// reader scanning for "which day is that band on" reads the number. Today is
/// called out, because every other instant on the chart is read relative to it.
const axisTicks = computed<ScheduleAxisTick[]>(() => {
if (days.value === 1) return hourTicks.value;
const step = days.value > 7 ? 2 : 1;
const today = zonedMidnight(nowMicros.value, props.timezone);
const ticks: ScheduleAxisTick[] = [];
@@ -911,6 +912,21 @@ const axisTicks = computed<ScheduleAxisTick[]>(() => {
return ticks;
});
const HOUR_MICROS = MICROS_PER_DAY / 24;
// Stepped from the window's start rather than per wall hour, so a clock-change day keeps evenly spaced ticks.
const hourTicks = computed<ScheduleAxisTick[]>(() => {
const every = isMobile.value ? 6 : 3;
const ticks: ScheduleAxisTick[] = [];
for (let at = from.value; at < to.value; at += every * HOUR_MICROS) {
ticks.push({
offset: share(at),
label: raw(fmt(at, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" })),
});
}
return ticks;
});
/// Null when the window does not contain the present, which is the common case
/// once somebody pages forward.
const nowOffset = computed(() =>
@@ -101,18 +101,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`oncall-schedule-handover-${ruleIndex}-time`"
@update:model-value="(v: unknown) => moveHandover(handover.weekday, Number(v))"
/>
<OText variant="meta" as="span" :data-test="`oncall-schedule-handover-${ruleIndex}-zone`">
{{ raw(timezone) }}
</OText>
</div>
<OText variant="body-strong" class="pt-1.5">{{ t("oncall.ruleCovers") }}</OText>
<div class="flex min-w-0 flex-col gap-2">
<OSelect
:model-value="covers"
width="md"
:aria-label="t('oncall.ruleCovers')"
:options="coversOptions"
:data-test="`oncall-schedule-covers-${ruleIndex}`"
@update:model-value="(v: unknown) => setCovers(v as CoversPreset)"
/>
<span class="flex flex-wrap items-center gap-2">
<OSelect
:model-value="covers"
width="md"
:aria-label="t('oncall.ruleCovers')"
:options="coversOptions"
:data-test="`oncall-schedule-covers-${ruleIndex}`"
@update:model-value="(v: unknown) => setCovers(v as CoversPreset)"
/>
<OText variant="meta" as="span" :data-test="`oncall-schedule-covers-${ruleIndex}-zone`">
{{ raw(timezone) }}
</OText>
</span>
<template v-if="covers === 'custom'">
<div
@@ -0,0 +1,92 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
SHIFT_LOOKBACK_MICROS,
SHIFT_LOOKUP_MICROS,
shiftAround,
shiftLookupWindow,
swappableRuns,
} from "@/components/oncall/resolvedShifts";
import type { ResolvedSegment } from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
const DAY = MICROS_PER_DAY;
const seg = (over: Partial<ResolvedSegment> = {}): ResolvedSegment => ({
rotation_id: "rot_1",
from: 10 * DAY,
to: 12 * DAY,
user_email: "ana@o2.ai",
...over,
});
describe("shiftLookupWindow", () => {
it("needs no read for a band inside the window", () => {
expect(shiftLookupWindow(seg(), { from: 9 * DAY, to: 16 * DAY })).toBeNull();
});
it("reads forward from a band cut at the window's end", () => {
expect(shiftLookupWindow(seg({ to: 16 * DAY }), { from: 9 * DAY, to: 16 * DAY })).toEqual({
from: 10 * DAY,
to: 10 * DAY + SHIFT_LOOKUP_MICROS,
});
});
it("reaches back for a band cut at the window's start", () => {
const lookup = shiftLookupWindow(seg({ from: 9 * DAY }), { from: 9 * DAY, to: 16 * DAY });
expect(lookup?.from).toBe(9 * DAY - SHIFT_LOOKBACK_MICROS);
expect(lookup!.to - lookup!.from).toBeLessThanOrEqual(31 * DAY);
});
});
describe("shiftAround", () => {
it("returns the whole run the band's person holds", () => {
const runs = [seg({ from: 8 * DAY, to: 15 * DAY })];
expect(shiftAround(seg(), runs)).toEqual({ from: 8 * DAY, to: 15 * DAY });
});
it("ignores a run held by somebody else, or by a cover", () => {
const runs = [
seg({ from: 8 * DAY, to: 15 * DAY, user_email: "bo@o2.ai" }),
seg({ from: 8 * DAY, to: 15 * DAY, override_id: "ov" }),
];
expect(shiftAround(seg(), runs)).toEqual({ from: 10 * DAY, to: 12 * DAY });
});
});
describe("swappableRuns", () => {
it("keeps the roster's turns from the anchor on, dropping gaps and covers", () => {
const runs = [
seg({ from: 0, to: 10 * DAY, user_email: "old@o2.ai" }),
seg(),
seg({ from: 12 * DAY, to: 13 * DAY, user_email: null }),
seg({ from: 13 * DAY, to: 14 * DAY, override_id: "ov" }),
seg({ from: 14 * DAY, to: 16 * DAY, user_email: "bo@o2.ai" }),
];
const shifts = swappableRuns(runs, { id: "rot_1", name: "Primary" }, 10 * DAY, 8);
expect(shifts.map((one) => [one.member, one.startMicros])).toEqual([
["ana@o2.ai", 10 * DAY],
["bo@o2.ai", 14 * DAY],
]);
expect(shifts[0]).toMatchObject({ rotationId: "rot_1", rotationName: "Primary" });
});
it("caps the list", () => {
const runs = Array.from({ length: 12 }, (_, i) => seg({ from: i * DAY, to: (i + 1) * DAY }));
expect(swappableRuns(runs, { id: "rot_1", name: "Primary" }, 0, 8)).toHaveLength(8);
});
});
@@ -0,0 +1,71 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { UpcomingShift } from "@/components/oncall/OnCallCoverForm.vue";
import type { ResolvedSegment } from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
/** How far a lookup reads past a drawn band; the server caps one read at 31 days. */
export const SHIFT_LOOKUP_MICROS = 28 * MICROS_PER_DAY;
/** How far before a band cut at the window's start a lookup reaches back for its real start. */
export const SHIFT_LOOKBACK_MICROS = 7 * MICROS_PER_DAY;
/** The window to read so a band cut at either edge of `window` comes back whole. [pure] */
export function shiftLookupWindow(
segment: Pick<ResolvedSegment, "from" | "to">,
window: { from: number; to: number },
): { from: number; to: number } | null {
const cutStart = segment.from <= window.from;
const cutEnd = segment.to >= window.to;
if (!cutStart && !cutEnd) return null;
const from = cutStart ? segment.from - SHIFT_LOOKBACK_MICROS : segment.from;
return { from, to: from + SHIFT_LOOKUP_MICROS };
}
/** The resolved run holding `segment`'s person at its start, or the segment itself when none does. [pure] */
export function shiftAround(
segment: ResolvedSegment,
runs: readonly ResolvedSegment[],
): { from: number; to: number } {
const who = segment.user_email ?? null;
const run = runs.find(
(one) =>
one.from <= segment.from &&
one.to > segment.from &&
(one.user_email ?? null) === who &&
(one.override_id ?? null) === (segment.override_id ?? null),
);
return run ? { from: run.from, to: run.to } : { from: segment.from, to: segment.to };
}
/** The roster's own turns from `from` on, as swappable shifts: gaps and covers trade nothing. [pure] */
export function swappableRuns(
runs: readonly ResolvedSegment[],
rotation: { id: string; name: string },
from: number,
max: number,
): UpcomingShift[] {
return runs
.filter((run) => !!run.user_email && !run.override_id && run.to > from)
.slice(0, max)
.map((run) => ({
startMicros: run.from,
endMicros: run.to,
member: run.user_email ?? "",
rule: run.rotation ?? undefined,
rotationId: rotation.id,
rotationName: rotation.name,
}));
}
+1
View File
@@ -289,6 +289,7 @@ export const BADGE_GROUPS = {
covered: { variant: "success-soft", labelKey: "oncall.coverage_covered" },
gap: { variant: "error-soft", labelKey: "oncall.coverage_gap" },
unreachable: { variant: "warning-soft", labelKey: "oncall.coverage_cantReach" },
allunreachable: { variant: "error-soft", labelKey: "oncall.coverage_unreachable" },
},
fallback: { variant: "default-soft" },
},
+6 -1
View File
@@ -26026,7 +26026,7 @@
"rotationRetiredTag": "Retired",
"rotationSectionWhen": "When it turns over",
"rotationName": "Rotation name",
"rotationNameClash": "{name} already uses this name.",
"rotationNameClash": "Another rotation is already called {name}. Pick a different name to save.",
"rotationSectionRules": "Shift rules",
"rotationNeedsRules": "Add a shift rule — a rotation with none pages nobody.",
"rotationRuleCount": "{count} rule | {count} rules",
@@ -26301,6 +26301,8 @@
"coverageCantReachTip": "{count} of {total} on call can't be reached",
"shiftBandTip": "{person} · {range}",
"shiftBandTipRange": "{from} – {to}",
"shiftBandTipRangeZoned": "{from} – {to} ({zone})",
"timesInZone": "Times in {zone}",
"policyDestinationsHintShort": "Any alert destination with a URL — Slack, Teams, PagerDuty.",
"addRotationFromTemplate": "From a template",
"addRotationFromScratch": "From scratch",
@@ -26680,6 +26682,7 @@
"coverSummary": "{name} covers {team} {range}.",
"coverSummaryWho": "{name} covers {team}.",
"coverSummaryAfter": "Outside that window the rotation resolves as normal.",
"coverHolderLine": "On call: {name}",
"coverInvalidRange": "The end must be after the start.",
"coverWindowPast": "That window has already ended. Pick hours that are still ahead.",
"coverListCount": "Covers ({count})",
@@ -26805,6 +26808,8 @@
"attentionNoMembersDetail": "A page routed here would reach no one, whatever the schedule says.",
"timelineGapUnowned": "Gap — nobody is on call {range}",
"teamRecentPages": "Recent pages",
"teamRecentPagesWindow": "{count} in the last {days} days",
"teamRecentPagesWindowOf": "{shown} of {total} in the last {days} days",
"teamAckedByIn": "acked by {who} · {took}",
"teamNeverAcked": "Never acked",
"teamOpenInOnCall": "View all in On-Call",
+164 -47
View File
@@ -26,6 +26,9 @@ import oncallService from "@/services/oncall";
import { oncallKeys } from "@/services/oncall.querykeys";
import { serviceStreamKeys } from "@/services/service_streams.querykeys";
import { userKeys } from "@/services/users.querykeys";
import { orgUsersQuery } from "@/services/users.queries";
import { resolveBadge } from "@/lib/core/Badge/badgeGroups";
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
import { ok } from "@/test/unit/helpers/mockService";
import store from "@/test/unit/helpers/store";
import type { UpcomingShift } from "@/components/oncall/OnCallCoverForm.vue";
@@ -85,7 +88,6 @@ vi.mock("vue-router", () => ({
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn() }));
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
import analytics from "@/services/product_analytics";
import shiftParity from "../../../../src/config/src/meta/oncall/shift_parity.json";
const service = vi.mocked(oncallService);
@@ -404,7 +406,12 @@ describe("OnCallTeamDetail", () => {
const wrapper = render();
await flushPromises();
expect(chip(wrapper)).toContain("On call but can't be reached");
expect(chip(wrapper)).toContain("Rostered, unreachable");
const tag = wrapper
.findAllComponents({ name: "OTag" })
.find((one) => one.props("type") === "oncallCoverage")!;
expect(tag.props("value")).toBe("allunreachable");
expect(resolveBadge("oncallCoverage", "allunreachable").variant).toBe("error-soft");
});
// Green only when every holder can be paged, so the chip agrees with the banner naming the one who cannot.
@@ -667,45 +674,54 @@ describe("OnCallTeamDetail", () => {
expect(service.listOverrides.mock.calls.length).toBeGreaterThan(before);
});
// Swap covers are stored at the handovers the server counts in the team's zone.
it("offers swap shifts at the team-zone handovers the server uses", async () => {
const c = shiftParity[0];
const now = vi.spyOn(Date, "now").mockReturnValue(c.at_micros / 1000);
service.getTeam.mockResolvedValue(
ok({ id: "team_1", name: "Platform", timezone: c.timezone }),
);
// The resolved schedule already drops hours outside a rule's active window and the covers on top.
it("offers swap shifts from the resolved schedule, without gaps or covers", async () => {
const HOUR = 3_600_000_000;
const base = Math.floor((Date.now() * 1000) / HOUR) * HOUR;
service.getSchedule.mockResolvedValue(
ok({
timezone: c.timezone,
rotations: [
{
id: "rot_1",
name: "Primary",
shift_rules: [
ok({ timezone: "UTC", rotations: [rotation("rot_1", "Primary")] }),
);
service.resolvedSchedule.mockImplementation(async (args) =>
ok(
args.from === base
? [
{
name: "Base",
members: c.members,
shift_micros: c.shift_micros,
anchor_micros: c.anchor_micros,
rotation_id: "rot_1",
from: base,
to: base + 8 * HOUR,
user_email: "ana@corp.com",
},
],
},
],
}),
{ rotation_id: "rot_1", from: base + 8 * HOUR, to: base + 24 * HOUR },
{
rotation_id: "rot_1",
from: base + 24 * HOUR,
to: base + 32 * HOUR,
user_email: "bo@corp.com",
override_id: "ov_9",
},
{
rotation_id: "rot_1",
from: base + 48 * HOUR,
to: base + 56 * HOUR,
user_email: "bo@corp.com",
},
]
: [],
),
);
const wrapper = render();
await flushPromises();
wrapper.findComponent({ name: "OTabPanels" }).vm.$emit("update:modelValue", "schedule");
await wrapper.find('[data-test="oncall-team-override-btn"]').trigger("click");
await flushPromises();
const [first] = wrapper
const shifts = wrapper
.findComponent({ name: "OnCallCoverForm" })
.props("shifts") as UpcomingShift[];
now.mockRestore();
expect(first).toMatchObject({
startMicros: c.shift_start,
endMicros: c.next_handover,
member: c.holder,
});
expect(shifts.map((one) => [one.member, one.startMicros, one.endMicros])).toEqual([
["ana@corp.com", base, base + 8 * HOUR],
["bo@corp.com", base + 48 * HOUR, base + 56 * HOUR],
]);
service.resolvedSchedule.mockReset();
});
/// A swap is two covers written back to back, so it invalidates exactly
@@ -749,6 +765,34 @@ describe("OnCallTeamDetail", () => {
expect(message).not.toMatch(/^\s*covers\s/);
});
it("names the person by display name in the success toast", async () => {
queryClient.setQueryData(orgUsersQuery("default").queryKey, [
{ email: "bo@corp.com", first_name: "Bo", last_name: "Lin" },
]);
service.createOverride.mockResolvedValue(ok({ id: "ov_1" }));
const wrapper = render();
await flushPromises();
await save(wrapper);
const message = String(toasted.mock.calls.at(-1)?.[0]?.message ?? "");
expect(message).toMatch(/^Bo Lin covers Platform/);
queryClient.removeQueries({ queryKey: orgUsersQuery("default").queryKey });
});
it("passes covering_for through to the write", async () => {
service.createOverride.mockResolvedValue(ok({ id: "ov_1" }));
const wrapper = render();
await flushPromises();
wrapper
.findComponent({ name: "OnCallCoverForm" })
.vm.$emit("save", { ...COVER, covering_for: "ana@corp.com" });
await flushPromises();
expect(service.createOverride).toHaveBeenCalledWith(
expect.objectContaining({ data: { ...COVER, covering_for: "ana@corp.com" } }),
);
});
it("tracks oncall_override_created as a cover once it is saved", async () => {
service.createOverride.mockResolvedValue(ok({ id: "ov_1" }));
const wrapper = render();
@@ -1738,6 +1782,7 @@ describe("OnCallTeamDetail", () => {
describe("acting on a shift bar", () => {
const WEEK = 604_800_000_000;
const DAY_MICROS = 86_400_000_000;
const DialogStub = {
name: "ODialog",
props: ["open"],
@@ -1814,30 +1859,76 @@ describe("OnCallTeamDetail", () => {
]);
});
/// The real form, not a stub: the swap it opens on is the clicked shift's week.
it("opens the real cover form on Swap with the clicked week chosen", async () => {
/// The real form, not a stub: a shift far beyond the next few still opens the swap on itself.
it("opens the real cover form on Swap with a far-off clicked shift chosen", async () => {
const { wrapper, timeline } = await onSchedule({
OnCallCoverForm: false,
ODialog: DialogStub,
});
const [shift] = wrapper
.findComponent({ name: "OnCallCoverForm" })
.props("shifts") as UpcomingShift[];
expect(timeline.props("swapRotations")).toEqual(["rot_1"]);
timeline.vm.$emit(
"shift-action",
"swap",
future({ from: shift.startMicros, to: shift.endMicros }),
const far = future({ from: Date.now() * 1000 + 15 * DAY_MICROS });
far.to = far.from + DAY_MICROS;
const next = { ...far, from: far.to, to: far.to + DAY_MICROS, user_email: "bo@o2.ai" };
service.resolvedSchedule.mockImplementation(async (args) =>
ok(args.from === far.from ? [far, next] : []),
);
timeline.vm.$emit("shift-action", "swap", far);
await flushPromises();
expect(wrapper.find('[data-test="oncall-swap-form"]').exists()).toBe(true);
expect(
(wrapper.findComponent('[data-test="oncall-swap-a"]') as AnyPropsWrapper).props(
"modelValue",
),
).toBe(`rot_1:${shift.startMicros}`);
const first = wrapper.findComponent('[data-test="oncall-swap-a"]') as AnyPropsWrapper;
expect(first.props("modelValue")).toBe(`rot_1:${far.from}`);
const options = first.props("options") as { value: string; label: string }[];
expect(options.map((one) => one.value)).toEqual([`rot_1:${far.from}`, `rot_1:${next.from}`]);
expect(String(options[0].label)).toMatch(/\d{2}:\d{2}/);
service.resolvedSchedule.mockReset();
service.resolvedSchedule.mockResolvedValue(ok([]));
});
/// The calendar's read stops at its window, so a band touching the edge is a cut, not the shift's end.
it.each([
["week", 7],
["day", 1],
])("covers a shift cut at the %s view's edge until its real end", async (_view, days) => {
const { wrapper, timeline } = await onSchedule();
const now = Date.now() * 1000;
const windowTo = now + days * DAY_MICROS;
timeline.vm.$emit("update:window", { from: now - DAY_MICROS, to: windowTo });
await flushPromises();
const drawn = future({ to: windowTo });
const realEnd = drawn.from + 7 * DAY_MICROS;
service.resolvedSchedule.mockImplementation(async (args) =>
ok(args.from === drawn.from ? [{ ...drawn, to: realEnd }] : []),
);
timeline.vm.$emit("shift-action", "cover", drawn);
await flushPromises();
expect(wrapper.findComponent({ name: "OnCallCoverForm" }).props("gap")).toEqual({
from: drawn.from,
to: realEnd,
});
service.resolvedSchedule.mockReset();
service.resolvedSchedule.mockResolvedValue(ok([]));
});
it("hands the cover form the clicked shift's holder and everyone on call now", async () => {
service.whoIsOnCall.mockResolvedValue(
ok([
{ rotation_id: "rot_1", rotation_name: "Primary", rule: "Base", user_email: "bo@o2.ai" },
]),
);
const { wrapper, timeline } = await onSchedule();
timeline.vm.$emit("shift-action", "cover", future());
await flushPromises();
const cover = wrapper.findComponent({ name: "OnCallCoverForm" });
expect(cover.props("currentHolder")).toBe("ana@o2.ai");
expect(cover.props("positions")).toMatchObject([{ user_email: "bo@o2.ai" }]);
await wrapper.find('[data-test="oncall-team-override-btn"]').trigger("click");
await flushPromises();
expect(cover.props("currentHolder")).toBe(null);
});
/// The real away dialog: opened on the shift's person and window, and saved against them.
@@ -1946,6 +2037,32 @@ describe("OnCallTeamDetail", () => {
});
});
/// The card shows three; its link must land on this team's pages, not the whole org's.
it("opens the Pages list filtered to this team from the Recent pages card", async () => {
const opened = Date.now() * 1000;
service.listResponses.mockResolvedValue(
ok(
Array.from({ length: 5 }, (_, i) => ({
id: `r${i}`,
opened_at: opened - i,
subject: { source_id: `a${i}` },
state: "open",
priority: 1,
})),
),
);
const wrapper = render();
await flushPromises();
expect(wrapper.find('[data-test="oncall-recent-pages-window"]').text()).toBe(
"3 of 5 in the last 7 days",
);
await wrapper.find('[data-test="oncall-recent-pages-view-all"]').trigger("click");
expect(router.push).toHaveBeenCalledWith(expect.objectContaining({ name: "onCallResponses" }));
expect(useListViewState("oncall-responses", { teamFilter: "all" }).teamFilter).toBe("team_1");
resetListViewState("oncall-responses");
});
it("counts the same routing rules in the header as the Routing tab lists", async () => {
service.listOwnershipRules.mockResolvedValue(ok([{ id: "r1" }, { id: "r2" }]));
service.teamOverview.mockResolvedValue(ok({ alerts_assigned: 0, members: 1 }));
+90 -19
View File
@@ -221,6 +221,7 @@
:pages="recentPages"
:policy="policy"
:loading="pagesLoading"
:window-days="ACTIVITY_WINDOW_DAYS"
@open="openPage"
@view-all="openOnCallList"
/>
@@ -433,10 +434,11 @@
:viewer-timezone="store.state.timezone"
:team-name="team?.name ?? ''"
:saving="coverSaving"
:current-holder="coverHolder ?? onCallNow[0]?.user_email ?? null"
:current-holder="coverHolder"
:positions="onCallNow"
:gap="coverGap"
:default-user="coverDefaultUser"
:shifts="swappableShifts"
:shifts="swapShifts"
:rotations="teamRotations"
:default-rotation="coverRotation"
:default-mode="coverMode"
@@ -491,6 +493,13 @@ import OnCallTeamDetailSkeleton from "@/views/OnCall/OnCallTeamDetailSkeleton.vu
import { useOnCallPermissions } from "@/composables/useOnCallPermissions";
import { useOnCallDefaultTeam } from "@/composables/useOnCallDefaultTeam";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import { useListViewState } from "@/composables/useListViewState";
import {
shiftAround,
shiftLookupWindow,
SHIFT_LOOKUP_MICROS,
swappableRuns,
} from "@/components/oncall/resolvedShifts";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OText from "@/lib/core/Typography/OText.vue";
@@ -554,7 +563,7 @@ import type {
OwnershipRule,
Unavailability,
} from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY } from "@/ts/interfaces/oncall";
import { MICROS_PER_DAY, MICROS_PER_HOUR } from "@/ts/interfaces/oncall";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import {
channelList,
@@ -778,7 +787,15 @@ function openPage(row: OnCallResponse) {
});
}
// The Pages list keeps its filters in shared view state, not the URL, so the team is set there before the jump.
function openOnCallList() {
const view = useListViewState("oncall-responses", { teamFilter: "all" });
Object.assign(view, {
teamFilter: teamId.value,
mineOnly: false,
activeFilter: "all",
currentPage: 1,
});
router.push({ name: "onCallResponses", query: { org_identifier: orgId.value } });
}
@@ -1010,14 +1027,19 @@ const unreachableHolders = computed(() => {
return { total: holders.length, unreachable: known.filter((m) => !m.would_a_page_land).length };
});
const coverageState = computed<"covered" | "gap" | "unreachable">(() => {
const coverageState = computed<"covered" | "gap" | "unreachable" | "allunreachable">(() => {
const covered = overview.value?.covered_now ?? onCallNow.value.length > 0;
if (!covered) return "gap";
return unreachableHolders.value.unreachable ? "unreachable" : "covered";
const { unreachable, total } = unreachableHolders.value;
if (!unreachable) return "covered";
// Nobody holding the pager would receive a page, which is the coverage bar's red, not a partial warning.
return unreachable === total ? "allunreachable" : "unreachable";
});
const coverageTip = computed<I18nText | undefined>(() => {
if (coverageState.value !== "unreachable") return undefined;
if (coverageState.value !== "unreachable" && coverageState.value !== "allunreachable") {
return undefined;
}
const { unreachable, total } = unreachableHolders.value;
return t("oncall.coverageCantReachTip", { count: unreachable, total });
});
@@ -1229,16 +1251,16 @@ function openPolicyFromRemove() {
/// Somebody else covers a window nobody holds: the hole is known, the person
/// is the open question.
function onFillGap(gap: ResolvedSegment) {
openCoverDialog({ gap: { from: gap.from, to: gap.to }, rotation: gap.rotation_id });
async function onFillGap(gap: ResolvedSegment) {
await openCoverDialog({ gap: fromNow(await realSpan(gap)), rotation: gap.rotation_id });
}
/// A lane's own "Add a cover": the rotation is decided, the window and person are not.
function openCoverFor(rotationId: string) {
openCoverDialog({ rotation: rotationId });
void openCoverDialog({ rotation: rotationId });
}
function openCoverDialog(opts: {
async function openCoverDialog(opts: {
gap?: { from: number; to: number } | null;
user?: string;
rotation?: string;
@@ -1246,6 +1268,7 @@ function openCoverDialog(opts: {
swapFrom?: { rotationId: string; at: number } | null;
holder?: string | null;
}) {
swapShifts.value = await loadSwapShifts(opts.swapFrom ?? null);
coverGap.value = opts.gap ?? null;
coverDefaultUser.value = opts.user ?? "";
coverRotation.value = opts.rotation ?? "";
@@ -1256,23 +1279,39 @@ function openCoverDialog(opts: {
}
/// From now, never from a shift's past: a cover or an absence over hours that are gone pages nobody.
function fromNow(segment: ResolvedSegment): { from: number; to: number } {
return { from: Math.max(segment.from, Date.now() * 1000), to: segment.to };
function fromNow(span: { from: number; to: number }): { from: number; to: number } {
return { from: Math.max(span.from, Date.now() * 1000), to: span.to };
}
function onShiftAction(action: ShiftAction, segment: ResolvedSegment) {
/// The whole shift behind a drawn band: the calendar's read stops at its edges, so a band there is cut.
async function realSpan(segment: ResolvedSegment): Promise<{ from: number; to: number }> {
const lookup = shiftLookupWindow(segment, scheduleWindow.value);
if (!lookup) return { from: segment.from, to: segment.to };
try {
const runs = await read<ResolvedSegment[]>(
resolvedScheduleQuery(orgId.value, teamId.value, lookup.from, lookup.to, segment.rotation_id),
false,
);
return shiftAround(segment, runs);
} catch {
return { from: segment.from, to: segment.to };
}
}
async function onShiftAction(action: ShiftAction, segment: ResolvedSegment) {
const who = segment.user_email ?? "";
const span = await realSpan(segment);
if (action === "away") {
awayEmail.value = who;
awayWindow.value = fromNow(segment);
awayWindow.value = fromNow(span);
awayOpen.value = true;
return;
}
openCoverDialog({
gap: action === "cover" ? fromNow(segment) : null,
await openCoverDialog({
gap: action === "cover" ? fromNow(span) : null,
rotation: segment.rotation_id,
mode: action === "swap" ? "swap" : "cover",
swapFrom: action === "swap" ? { rotationId: segment.rotation_id, at: segment.from } : null,
swapFrom: action === "swap" ? { rotationId: segment.rotation_id, at: span.from } : null,
holder: who || null,
});
}
@@ -1289,7 +1328,7 @@ async function onAwaySaved() {
/// pre-fill when the reader is not on this team, which is why the picker is
/// still a picker.
function openTakeOverride() {
openCoverDialog({ user: selfEmail.value });
void openCoverDialog({ user: selfEmail.value });
}
/// The weeks a swap can trade: the next few shifts of the rotation actually in
@@ -1343,6 +1382,37 @@ const swapRotationIds = computed(() => [
...new Set(swappableShifts.value.map((s) => s.rotationId)),
]);
/// What the swap picker offers, read from the resolved schedule so active hours and covers are already applied.
const swapShifts = ref<UpcomingShift[]>([]);
/// From the clicked shift when there is one, otherwise from the current hour, which keeps repeat opens on one cached read.
async function loadSwapShifts(
anchor: { rotationId: string; at: number } | null,
): Promise<UpcomingShift[]> {
const now = Date.now() * 1000;
const start = anchor ? anchor.at : Math.floor(now / MICROS_PER_HOUR) * MICROS_PER_HOUR;
const rotations = teamRotations.value.filter(
(rotation) =>
swapRotationIds.value.includes(rotation.id) && (!anchor || rotation.id === anchor.rotationId),
);
const lanes = await Promise.allSettled(
rotations.map(async (rotation) => {
const runs = await read<ResolvedSegment[]>(
resolvedScheduleQuery(
orgId.value,
teamId.value,
start,
start + SHIFT_LOOKUP_MICROS,
rotation.id,
),
false,
);
return swappableRuns(runs, rotation, anchor ? start : now, SWAPPABLE_SHIFTS);
}),
);
return lanes.flatMap((lane) => (lane.status === "fulfilled" ? lane.value : []));
}
/// Two covers, one each way.
///
/// Written in sequence rather than in parallel: if the second is refused — the
@@ -1411,7 +1481,7 @@ function coverSavedMessage(value: { user_email: string; start_at: number; end_at
timeZoneName: "short",
});
return t("oncall.coverSaved", {
name: raw(value.user_email),
name: raw(nameOf(value.user_email)),
team: raw(team.value?.name ?? ""),
range: raw(`${at(value.start_at)} – ${at(value.end_at)}`),
});
@@ -1424,6 +1494,7 @@ async function saveCover(value: {
start_at: number;
end_at: number;
rotation_id?: string;
covering_for?: string;
}) {
coverSaving.value = true;
try {