Render overlapping events/shifts in separate rows
All checks were successful
/ build (push) Successful in 1m33s
/ deploy (push) Successful in 15s

Instead of merging overlapping events and shifts when displaying them in
the timetable which causes a very confusing display, add new rows when
events overlap so that each event can be fully displayed without any
overlapping in the table.
This commit is contained in:
Hornwitser 2025-06-25 15:38:47 +02:00
parent 5662b890de
commit 016930f933

View file

@ -38,7 +38,7 @@
<colgroup> <colgroup>
<col class="header" /> <col class="header" />
</colgroup> </colgroup>
<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="group.className"> <colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="{ break: group.isBreak }">
<col v-for="col, index in group.cols" :key="index" :style='{"--minutes": col.minutes}' /> <col v-for="col, index in group.cols" :key="index" :style='{"--minutes": col.minutes}' />
</colgroup> </colgroup>
<thead> <thead>
@ -83,16 +83,25 @@
</td> </td>
</tr> </tr>
<template v-for="location in schedule.locations.values()" :key="location.id"> <template v-for="location in schedule.locations.values()" :key="location.id">
<tr v-if="locationRows.has(location.id)"> <tr
<th>{{ location.name }}</th> v-if="locationGroups.has(location.id)"
<td v-for="row, index in locationGroups.get(location.id)"
v-for="row, index in locationRows.get(location.id)" :key="index"
:key="index" >
:colSpan="row.span" <th
:class='{"event": row.slots.size, "crew": row.crew }' v-if="index === 0"
:title="row.title" :rowSpan="locationGroups.get(location.id)!.length"
> >
{{ row.title }} {{ location.name }}
</th>
<td
v-for="cell, index in row"
:key="index"
:colSpan="cell.span"
:class='{"event": cell.slot, "crew": cell.event?.crew }'
:title="cell.event?.name"
>
{{ cell.event?.name }}
</td> </td>
</tr> </tr>
</template> </template>
@ -102,16 +111,25 @@
<td :colSpan="totalColumns"></td> <td :colSpan="totalColumns"></td>
</tr> </tr>
<template v-for="role in schedule.roles.values()" :key="role.id"> <template v-for="role in schedule.roles.values()" :key="role.id">
<tr v-if="roleRows.has(role.id)"> <tr
<th>{{ role.name }}</th> v-if="roleGroups.has(role.id)"
<td v-for="row, index in roleGroups.get(role.id)"
v-for="row, index in roleRows.get(role.id)" :key="index"
:key="index" >
:colSpan="row.span" <th
:class='{"shift": row.slots.size }' v-if="index === 0"
:title="row.title" :rowSpan="roleGroups.get(role.id)!.length"
> >
{{ row.title }} {{ role.name }}
</th>
<td
v-for="cell, index in row"
:key="index"
:colSpan="cell.span"
:class='{"shift": cell.slot }'
:title="cell.shift?.name"
>
{{ cell.shift?.name }}
</td> </td>
</tr> </tr>
</template> </template>
@ -376,20 +394,22 @@ function tableElementsFromStretches(
type Col = { minutes?: number }; type Col = { minutes?: number };
type DayHead = { span: number, isBreak: boolean, content?: string } type DayHead = { span: number, isBreak: boolean, content?: string }
type HourHead = { span: number, isBreak: boolean, isDayShift: boolean, content?: string } type HourHead = { span: number, isBreak: boolean, isDayShift: boolean, content?: string }
type LocationCell = { span: number, slots: Set<ClientScheduleEventSlot>, title: string, crew?: boolean } type LocationCell = { span: number, isBreak: boolean, slot?: ClientScheduleEventSlot, event?: ClientScheduleEvent };
type RoleCell = { span: number, slots: Set<ClientScheduleShiftSlot>, title: string }; type LocationRow = LocationCell[];
type ColumnGroup = { start: number, end: number, width: number, className?: string, cols: Col[] }; type RoleCell = { span: number, isBreak: boolean, slot?: ClientScheduleShiftSlot, shift?: ClientScheduleShift };
type RoleRow = RoleCell[];
type ColumnGroup = { start: number, end: number, width: number, isBreak: boolean, cols: Col[] };
const columnGroups: ColumnGroup[] = []; const columnGroups: ColumnGroup[] = [];
const dayHeaders: DayHead[] = []; const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= []; const hourHeaders: HourHead[]= [];
const locationRows = new Map<number, LocationCell[]>([...locations.keys()].map(id => [id, []])); const locationGroups = new Map<number, LocationRow[]>([...locations.keys()].map(id => [id, []]));
const roleRows = new Map<number, RoleCell[]>([...roles.keys()].map(id => [id, []])); const roleGroups = new Map<number, RoleRow[]>([...roles.keys()].map(id => [id, []]));
const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event]))); const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift]))); const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift])));
let totalColumns = 0; let totalColumns = 0;
function startColumnGroup(start: number, end: number, width: number, className?: string) { function startColumnGroup(start: number, end: number, width: number, isBreak: boolean) {
columnGroups.push({ start, end, width, className, cols: []}) columnGroups.push({ start, end, width, isBreak, cols: []})
} }
function startDay(isBreak: boolean, content?: string) { function startDay(isBreak: boolean, content?: string) {
dayHeaders.push({ span: 0, isBreak, content }) dayHeaders.push({ span: 0, isBreak, content })
@ -397,22 +417,58 @@ function tableElementsFromStretches(
function startHour(isBreak: boolean, content?: string, isDayShift = false) { function startHour(isBreak: boolean, content?: string, isDayShift = false) {
hourHeaders.push({ span: 0, isBreak, isDayShift, content }) hourHeaders.push({ span: 0, isBreak, isDayShift, content })
} }
function startLocation(id: number, slots = new Set<ClientScheduleEventSlot>()) { function startLocation(id: number, isBreak: boolean, newSlots = new Set<ClientScheduleEventSlot>()) {
const rows = locationRows.get(id)!; const group = locationGroups.get(id)!;
if (rows.length) { // Remove all slots that are no longer in the new slots.
const row = rows[rows.length - 1]; for (const row of group) {
row.title = [...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", "); const cell = row[row.length - 1];
row.crew = [...row.slots].every(slot => eventBySlotId.get(slot.id)!.crew); if (cell.isBreak !== isBreak || cell.slot && !newSlots.has(cell.slot))
row.push({ span: 0, isBreak, slot: undefined, event: undefined });
}
const existingSlots = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
// Add all new slots that do not already exist.
for (const slot of newSlots.difference(existingSlots)) {
let row = group.find(row => !row[row.length - 1].slot);
if (!row) {
row = columnGroups.map(
colGroup => ({
span: colGroup.cols.length,
isBreak: colGroup.isBreak,
slot: undefined,
event: undefined
})
);
group.push(row);
}
row.push({ span: 0, isBreak, slot, event: eventBySlotId.get(slot.id) });
} }
rows.push({ span: 0, slots, title: "" });
} }
function startRole(id: number, slots = new Set<ClientScheduleShiftSlot>()) { function startRole(id: number, isBreak: boolean, newSlots = new Set<ClientScheduleShiftSlot>()) {
const rows = roleRows.get(id)!; const group = roleGroups.get(id)!;
if (rows.length) { // Remove all slots that are no longer in the new slots.
const row = rows[rows.length - 1]; for (const row of group) {
row.title = [...row.slots].map(slot => shiftBySlotId.get(slot.id)!.name).join(", "); const cell = row[row.length - 1];
if (cell.isBreak !== isBreak || cell.slot && !newSlots.has(cell.slot)) {
row.push({ span: 0, isBreak, slot: undefined, shift: undefined });
}
}
const existingSlots = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
// Add all new slots that do not already exist.
for (const slot of newSlots.difference(existingSlots)) {
let row = group.find(row => !row[row.length - 1].slot);
if (!row) {
row = columnGroups.map(
colGroup => ({
span: colGroup.cols.length,
isBreak: colGroup.isBreak,
slot: undefined,
shift: undefined
})
);
group.push(row);
}
row.push({ span: 0, isBreak, slot, shift: shiftBySlotId.get(slot.id) });
} }
rows.push({ span: 0, slots, title: "" });
} }
function pushColumn(minutes?: number) { function pushColumn(minutes?: number) {
totalColumns += 1; totalColumns += 1;
@ -420,12 +476,16 @@ function tableElementsFromStretches(
dayHeaders[dayHeaders.length - 1].span += 1; dayHeaders[dayHeaders.length - 1].span += 1;
hourHeaders[hourHeaders.length - 1].span += 1; hourHeaders[hourHeaders.length - 1].span += 1;
for(const location of locations.values()) { for(const location of locations.values()) {
const row = locationRows.get(location.id)!; const group = locationGroups.get(location.id)!;
row[row.length - 1].span += 1; for (const row of group) {
row[row.length - 1].span += 1;
}
} }
for(const role of roles.values()) { for(const role of roles.values()) {
const row = roleRows.get(role.id)!; const group = roleGroups.get(role.id)!;
row[row.length - 1].span += 1; for (const row of group) {
row[row.length - 1].span += 1;
}
} }
} }
@ -434,17 +494,17 @@ function tableElementsFromStretches(
stretch = padStretch(stretch, timezone); stretch = padStretch(stretch, timezone);
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale }); const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale });
if (!lastStretch) { if (!lastStretch) {
startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs); startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
startDay(false, startDate.toFormat("yyyy-LL-dd")); startDay(false, startDate.toFormat("yyyy-LL-dd"));
startHour(false, startDate.toFormat("HH:mm")); startHour(false, startDate.toFormat("HH:mm"));
for(const location of locations.values()) { for (const location of locations.values()) {
startLocation(location.id); startLocation(location.id, false);
} }
for(const role of roles.values()) { for (const role of roles.values()) {
startRole(role.id); startRole(role.id, false);
} }
} else { } else {
startColumnGroup(lastStretch.end, stretch.start, 1, "break"); startColumnGroup(lastStretch.end, stretch.start, 1, true);
const dayName = startDate.toFormat("yyyy-LL-dd"); const dayName = startDate.toFormat("yyyy-LL-dd");
const lastDayHeader = dayHeaders[dayHeaders.length - 1] const lastDayHeader = dayHeaders[dayHeaders.length - 1]
const sameDay = dayName === lastDayHeader.content && lastDayHeader.span; const sameDay = dayName === lastDayHeader.content && lastDayHeader.span;
@ -452,22 +512,22 @@ function tableElementsFromStretches(
startDay(true); startDay(true);
startHour(true, "break"); startHour(true, "break");
for(const location of locations.values()) { for(const location of locations.values()) {
startLocation(location.id); startLocation(location.id, true);
} }
for(const role of roles.values()) { for(const role of roles.values()) {
startRole(role.id); startRole(role.id, true);
} }
pushColumn(); pushColumn();
startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs); startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
if (!sameDay) if (!sameDay)
startDay(false, dayName); startDay(false, dayName);
startHour(false, startDate.toFormat("HH:mm")); startHour(false, startDate.toFormat("HH:mm"));
for(const location of locations.values()) { for(const location of locations.values()) {
startLocation(location.id); startLocation(location.id, false);
} }
for(const role of roles.values()) { for(const role of roles.values()) {
startRole(role.id); startRole(role.id, false);
} }
} }
@ -477,19 +537,19 @@ function tableElementsFromStretches(
const durationMs = end - cutSpan.start.ts; const durationMs = end - cutSpan.start.ts;
for (const location of locations.values()) { for (const location of locations.values()) {
const rows = locationRows.get(location.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.locations.get(location.id) ?? new Set(); const slots = cutSpan.locations.get(location.id) ?? new Set();
if (!setEquals(slots, row.slots)) { const group = locationGroups.get(location.id)!;
startLocation(location.id, slots); const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
if (!setEquals(slots, existing)) {
startLocation(location.id, false, slots)
} }
} }
for (const role of roles.values()) { for (const role of roles.values()) {
const rows = roleRows.get(role.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.roles.get(role.id) ?? new Set(); const slots = cutSpan.roles.get(role.id) ?? new Set();
if (!setEquals(slots, row.slots)) { const group = roleGroups.get(role.id)!;
startRole(role.id, slots); const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
if (!setEquals(slots, existing)) {
startRole(role.id, false, slots);
} }
} }
@ -522,13 +582,17 @@ function tableElementsFromStretches(
columnGroups, columnGroups,
dayHeaders: dayHeaders.filter(day => day.span), dayHeaders: dayHeaders.filter(day => day.span),
hourHeaders: hourHeaders.filter(hour => hour.span), hourHeaders: hourHeaders.filter(hour => hour.span),
locationRows: new Map([...locationRows] locationGroups: new Map([...locationGroups]
.filter(([_, cells]) => cells.some(cell => cell.slots.size)) .filter(([_, rows]) => rows.length)
.map(([id, cells]) => [id, cells.filter(cell => cell.span)])) .map(([id, rows]) => [
id, rows.map(row => row.filter(cell => cell.span))
]))
, ,
roleRows: new Map([...roleRows] roleGroups: new Map([...roleGroups]
.filter(([_, cells]) => cells.some(cell => cell.slots.size)) .filter(([_, rows]) => rows.length)
.map(([id, cells]) => [id, cells.filter(cell => cell.span)])) .map(([id, rows]) => [
id, rows.map(row => row.filter(cell => cell.span))
]))
, ,
eventBySlotId, eventBySlotId,
}; };
@ -579,8 +643,8 @@ const totalColumns = computed(() => elements.value.totalColumns);
const columnGroups = computed(() => elements.value.columnGroups); const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders); const dayHeaders = computed(() => elements.value.dayHeaders);
const hourHeaders = computed(() => elements.value.hourHeaders); const hourHeaders = computed(() => elements.value.hourHeaders);
const locationRows = computed(() => elements.value.locationRows); const locationGroups = computed(() => elements.value.locationGroups);
const roleRows = computed(() => elements.value.roleRows); const roleGroups = computed(() => elements.value.roleGroups);
const now = useState(() => Math.round(Date.now() / oneMinMs) * oneMinMs); const now = useState(() => Math.round(Date.now() / oneMinMs) * oneMinMs);
const interval = ref<any>(); const interval = ref<any>();