mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
Merge branch 'fix/oncall-ux-schedule' into fix/oncall-ux-polish
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user