(() =>
+ props.pages.length > rows.value.length
+ ? t("oncall.teamRecentPagesWindowOf", {
+ shown: rows.value.length,
+ total: props.pages.length,
+ days: props.windowDays,
+ })
+ : t("oncall.teamRecentPagesWindow", { count: props.pages.length, days: props.windowDays }),
+);
diff --git a/web/src/components/oncall/OnCallScheduleEditor.spec.ts b/web/src/components/oncall/OnCallScheduleEditor.spec.ts
index f7c47a2370..c74913dd40 100644
--- a/web/src/components/oncall/OnCallScheduleEditor.spec.ts
+++ b/web/src/components/oncall/OnCallScheduleEditor.spec.ts
@@ -558,6 +558,45 @@ describe("OnCallScheduleEditor", () => {
).toBeUndefined();
});
+ // Handover and active hours are wall times on the team's clock, so both say which.
+ it("names the team's zone beside the handover time and the active hours", async () => {
+ const wrapper = render({
+ schedule: schedule([rota("Primary")]),
+ timezone: "Asia/Kolkata",
+ drawerOnly: true,
+ intent: { mode: "edit", id: "rot_primary" },
+ });
+ await flushPromises();
+ expect(wrapper.find('[data-test="oncall-schedule-handover-0-zone"]').text()).toBe(
+ "Asia/Kolkata",
+ );
+ expect(wrapper.find('[data-test="oncall-schedule-covers-0-zone"]').text()).toBe("Asia/Kolkata");
+ });
+
+ /// A name two rotations share makes every menu, cover and level naming it ambiguous, so it cannot be saved.
+ it("blocks saving a rotation renamed to another rotation's name, and says why", async () => {
+ const wrapper = render({
+ schedule: schedule([rota("Primary"), rota("Secondary")]),
+ drawerOnly: true,
+ intent: { mode: "edit", id: "rot_secondary" },
+ });
+ await flushPromises();
+ const done = () => wrapper.find('[data-test="oncall-rotation-done"]');
+ expect(done().attributes("disabled")).toBeUndefined();
+
+ await wrapper.findComponent({ name: "OInput" }).vm.$emit("update:modelValue", " primary ");
+ await flushPromises();
+ expect(done().attributes("disabled")).toBeDefined();
+ expect(wrapper.find('[data-test="oncall-schedule-name-clash"]').text()).toBe(
+ "Another rotation is already called Primary. Pick a different name to save.",
+ );
+
+ await wrapper.findComponent({ name: "OInput" }).vm.$emit("update:modelValue", "Backup");
+ await flushPromises();
+ expect(done().attributes("disabled")).toBeUndefined();
+ expect(wrapper.find('[data-test="oncall-schedule-name-clash"]').exists()).toBe(false);
+ });
+
/// Nothing in this drawer can add a person, so the empty picker has to hand
/// the reader to the one screen that can.
it("sends the reader to the roster from inside the drawer", async () => {
diff --git a/web/src/components/oncall/OnCallScheduleEditor.vue b/web/src/components/oncall/OnCallScheduleEditor.vue
index c1accadac7..07deb8c333 100644
--- a/web/src/components/oncall/OnCallScheduleEditor.vue
+++ b/web/src/components/oncall/OnCallScheduleEditor.vue
@@ -172,7 +172,8 @@
{{ 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(() => {
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.
diff --git a/web/src/components/oncall/OnCallScheduleTimeline.spec.ts b/web/src/components/oncall/OnCallScheduleTimeline.spec.ts
index e4a5c9b5f0..621d8d6d1d 100644
--- a/web/src/components/oncall/OnCallScheduleTimeline.spec.ts
+++ b/web/src/components/oncall/OnCallScheduleTimeline.spec.ts
@@ -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", () => {
diff --git a/web/src/components/oncall/OnCallScheduleTimeline.vue b/web/src/components/oncall/OnCallScheduleTimeline.vue
index f89f52e802..33b7385b32 100644
--- a/web/src/components/oncall/OnCallScheduleTimeline.vue
+++ b/web/src/components/oncall/OnCallScheduleTimeline.vue
@@ -99,18 +99,8 @@ along with this program. If not, see .
-
-
- {{ t("oncall.addRotation") }}
-
-
+
+
.
data-test="oncall-timeline-add-template"
@select="emit('presets')"
>
- {{ t("oncall.addRotationFromTemplate") }}
+ {{ rotations.length ? t("oncall.presetsTitle") : t("oncall.addRotationFromTemplate") }}
formatInZone(micros, props.timezone, opts);
const rangeLabel = computed(() =>
- 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(() => {
+ 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(() => {
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(() => {
+ 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(() =>
diff --git a/web/src/components/oncall/OnCallShiftRuleFields.vue b/web/src/components/oncall/OnCallShiftRuleFields.vue
index 5d320fc53c..c7f92818b8 100644
--- a/web/src/components/oncall/OnCallShiftRuleFields.vue
+++ b/web/src/components/oncall/OnCallShiftRuleFields.vue
@@ -101,18 +101,26 @@ along with this program. If not, see .
:data-test="`oncall-schedule-handover-${ruleIndex}-time`"
@update:model-value="(v: unknown) => moveHandover(handover.weekday, Number(v))"
/>
+
+ {{ raw(timezone) }}
+