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