From 016930f93361563eadf8d70e41d561b55e28abe8 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Wed, 25 Jun 2025 15:38:47 +0200 Subject: [PATCH] Render overlapping events/shifts in separate rows 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. --- components/Timetable.vue | 204 +++++++++++++++++++++++++-------------- 1 file changed, 134 insertions(+), 70 deletions(-) diff --git a/components/Timetable.vue b/components/Timetable.vue index 989086c..78faac4 100644 --- a/components/Timetable.vue +++ b/components/Timetable.vue @@ -38,7 +38,7 @@ - + @@ -83,16 +83,25 @@ @@ -102,16 +111,25 @@ @@ -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, title: string, crew?: boolean } - type RoleCell = { span: number, slots: Set, 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([...locations.keys()].map(id => [id, []])); - const roleRows = new Map([...roles.keys()].map(id => [id, []])); + const locationGroups = new Map([...locations.keys()].map(id => [id, []])); + const roleGroups = new Map([...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()) { - 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()) { + 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()) { - 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()) { + 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();