I firmly believe in free software. The application I'm making here have capabilities that I've not seen in any system. It presents itself as an opportunity to collaborate on a tool that serves the people rather than corporations. Whose incentives are to help people rather, not make the most money. And whose terms ensure that these freedoms and incentives cannot be taken back or subverted. I license this software under the AGPL.
782 lines
22 KiB
Vue
782 lines
22 KiB
Vue
<!--
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
-->
|
|
<template>
|
|
<figure class="timetable">
|
|
<details>
|
|
<summary>Debug</summary>
|
|
<details>
|
|
<summary><b>Junctions</b></summary>
|
|
<div v-for="j in junctions" :key="j.ts">
|
|
{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
|
|
</div>
|
|
</details>
|
|
<details>
|
|
<summary><b>Stretches</b></summary>
|
|
<ol>
|
|
<li v-for="st in stretches" :key="st.start">
|
|
<p>Stretch from {{ st.start }} to {{ st.end }}.</p>
|
|
<p>Spans:</p>
|
|
<ul>
|
|
<li v-for="s in st.spans" :key="s.start.ts">
|
|
{{ s.start.ts }} - {{ s.end.ts }}:
|
|
<ul>
|
|
<li v-for="[id, slots] in s.locations" :key="id">
|
|
{{ id }}: {{ [...slots].map(s => s.id).join(", ") }}
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ol>
|
|
</details>
|
|
<p>
|
|
<label>
|
|
Timezone:
|
|
<input type="text" v-model="timezone">
|
|
</label>
|
|
</p>
|
|
</details>
|
|
<table>
|
|
<colgroup>
|
|
<col class="header" />
|
|
</colgroup>
|
|
<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>
|
|
<tr>
|
|
<th></th>
|
|
<th
|
|
v-for="day, index in dayHeaders"
|
|
:key="index"
|
|
:colSpan="day.span"
|
|
:class="{ break: day.isBreak }"
|
|
>
|
|
{{ day.content }}
|
|
</th>
|
|
</tr>
|
|
<tr class="hours">
|
|
<th>Location</th>
|
|
<th
|
|
v-for="hour, index in hourHeaders"
|
|
:key="index"
|
|
:colSpan="hour.span"
|
|
:class="{ break: hour.isBreak, dayShift: hour.isDayShift }"
|
|
>
|
|
<div v-if="hour.content">
|
|
{{ hour.content }}
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr class="overlay">
|
|
<th></th>
|
|
<td :colSpan="totalColumns">
|
|
<div
|
|
v-if="nowOffset !== undefined"
|
|
class="now"
|
|
:style="` --now-offset: ${nowOffset}`"
|
|
>
|
|
<div class="label">
|
|
now
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<template v-for="[id, locationGroup] in locationGroups" :key="id">
|
|
<tr
|
|
v-for="row, index in locationGroup"
|
|
:key="index"
|
|
>
|
|
<th
|
|
v-if="index === 0"
|
|
:rowSpan="locationGroup.length"
|
|
>
|
|
{{ schedule.locations.get(id!)?.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>
|
|
<template v-if="roleGroups.size">
|
|
<tr>
|
|
<th>Shifts</th>
|
|
<td :colSpan="totalColumns"></td>
|
|
</tr>
|
|
<template v-for="[id, roleGroup] in roleGroups" :key="id">
|
|
<tr
|
|
v-for="row, index in roleGroup"
|
|
:key="index"
|
|
>
|
|
<th
|
|
v-if="index === 0"
|
|
:rowSpan="roleGroup.length"
|
|
>
|
|
{{ schedule.roles.get(id!)?.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>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</figure>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { DateTime } from "~/shared/utils/luxon";
|
|
import type { Id } from "~/shared/types/common";
|
|
import { pairs, setEquals } from "~/shared/utils/functions";
|
|
|
|
const oneHourMs = 60 * 60 * 1000;
|
|
const oneMinMs = 60 * 1000;
|
|
|
|
// See timetable-terminology.png for an illustration of how these terms are related
|
|
|
|
/** Point in time where a time slots starts or ends. */
|
|
type Edge =
|
|
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
|
|
| { type: "start" | "end", source: "shift", roleId?: Id, slot: ClientScheduleShiftSlot }
|
|
;
|
|
|
|
/** Point in time where multiple edges meet. */
|
|
type Junction = { ts: number, edges: Edge[] };
|
|
|
|
/** Span of time between two adjacent junctions */
|
|
type Span = {
|
|
start: Junction;
|
|
end: Junction,
|
|
locations: Map<number | undefined, Set<ClientScheduleEventSlot>>,
|
|
roles: Map<number | undefined, Set<ClientScheduleShiftSlot>>,
|
|
};
|
|
|
|
/**
|
|
Collection of adjacent spans containing TimeSlots that are close to each
|
|
other in time. The start and end of the stretch is aligned to a whole hour
|
|
and the endpoint spans are always empty and at least one hour.
|
|
*/
|
|
type Stretch = {
|
|
start: number,
|
|
end: number,
|
|
spans: Span[];
|
|
}
|
|
|
|
function* edgesFromEvents(
|
|
events: Iterable<ClientScheduleEvent>,
|
|
filter = (slot: ClientScheduleEventSlot) => true,
|
|
): Generator<Edge> {
|
|
for (const event of events) {
|
|
if (event.deleted)
|
|
continue;
|
|
for (const slot of event.slots.values()) {
|
|
if (!filter(slot) || slot.deleted)
|
|
continue;
|
|
if (slot.start > slot.end) {
|
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
|
}
|
|
yield { type: "start", source: "event", slot }
|
|
yield { type: "end", source: "event", slot }
|
|
}
|
|
}
|
|
}
|
|
|
|
function* edgesFromShifts(
|
|
shifts: Iterable<ClientScheduleShift>,
|
|
filter = (slot: ClientScheduleShiftSlot) => true,
|
|
): Generator<Edge> {
|
|
for (const shift of shifts) {
|
|
if (shift.deleted)
|
|
continue;
|
|
for (const slot of shift.slots.values()) {
|
|
if (!filter(slot) || slot.deleted)
|
|
continue;
|
|
if (slot.start > slot.end) {
|
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
|
}
|
|
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
|
|
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
|
|
}
|
|
}
|
|
}
|
|
|
|
function junctionsFromEdges(edges: Iterable<Edge>) {
|
|
const junctions = new Map<number, Junction>();
|
|
for (const edge of edges) {
|
|
const ts = edge.slot[edge.type].toMillis();
|
|
const junction = junctions.get(ts);
|
|
if (junction) {
|
|
junction.edges.push(edge);
|
|
} else {
|
|
junctions.set(ts, { ts, edges: [edge] });
|
|
}
|
|
}
|
|
const keys = [...junctions.keys()].sort((a, b) => a - b);
|
|
return keys.map(key => junctions.get(key)!);
|
|
}
|
|
|
|
function* spansFromJunctions(
|
|
junctions: Iterable<Junction>,
|
|
locations: ClientMap<ClientScheduleLocation>,
|
|
roles: ClientMap<ClientScheduleRole>,
|
|
): Generator<Span> {
|
|
const activeLocations = new Map<number | undefined, Set<ClientScheduleEventSlot>>(
|
|
[...locations.keys()].map(id => [id, new Set()])
|
|
);
|
|
activeLocations.set(undefined, new Set());
|
|
const activeRoles = new Map<number | undefined, Set<ClientScheduleShiftSlot>>(
|
|
[...roles.keys()].map(id => [id, new Set()]),
|
|
);
|
|
activeRoles.set(undefined, new Set());
|
|
for (const [start, end] of pairs(junctions)) {
|
|
for (const edge of start.edges) {
|
|
if (edge.type === "start") {
|
|
if (edge.source === "event") {
|
|
for (const locationId of edge.slot.locationIds) {
|
|
activeLocations.get(locationId)?.add(edge.slot);
|
|
}
|
|
if (edge.slot.locationIds.size === 0) {
|
|
activeLocations.get(undefined)?.add(edge.slot);
|
|
}
|
|
} else if (edge.source === "shift") {
|
|
activeRoles.get(edge.roleId)?.add(edge.slot);
|
|
}
|
|
}
|
|
}
|
|
yield {
|
|
start,
|
|
end,
|
|
locations: new Map(
|
|
[...activeLocations]
|
|
.filter(([_, slots]) => slots.size)
|
|
.map(([location, slots]) => [location, new Set(slots)])
|
|
),
|
|
roles: new Map(
|
|
[...activeRoles]
|
|
.filter(([_, slots]) => slots.size)
|
|
.map(([role, slots]) => [role, new Set(slots)])
|
|
),
|
|
}
|
|
for (const edge of end.edges) {
|
|
if (edge.type === "end") {
|
|
if (edge.source === "event") {
|
|
for (const locationId of edge.slot.locationIds) {
|
|
activeLocations.get(locationId)?.delete(edge.slot);
|
|
}
|
|
if (edge.slot.locationIds.size === 0) {
|
|
activeLocations.get(undefined)?.delete(edge.slot);
|
|
}
|
|
} else if (edge.source === "shift") {
|
|
activeRoles.get(edge.roleId)?.delete(edge.slot);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createStretch(spans: Span[]): Stretch {
|
|
return {
|
|
spans,
|
|
start: spans[0].start.ts,
|
|
end: spans[spans.length - 1].end.ts,
|
|
}
|
|
}
|
|
|
|
function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Generator<Stretch> {
|
|
let currentSpans: Span[] = [];
|
|
for (const span of spans) {
|
|
// Based on how spans are generated I can assume that an empty span
|
|
// will only occur between two spans with timeslots in them.
|
|
if (
|
|
span.locations.size === 0
|
|
&& span.roles.size === 0
|
|
&& span.end.ts - span.start.ts >= minSeparation
|
|
) {
|
|
yield createStretch(currentSpans);
|
|
currentSpans = [];
|
|
} else {
|
|
currentSpans.push(span);
|
|
}
|
|
}
|
|
if (currentSpans.length)
|
|
yield createStretch(currentSpans);
|
|
}
|
|
|
|
/** Cuts up a span by whole hours that crosses it */
|
|
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
|
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: accountStore.activeLocale })
|
|
.startOf("hour")
|
|
;
|
|
const end = span.end.ts;
|
|
|
|
let currentStart = startHour;
|
|
let currentEnd = startHour.plus({ hours: 1 });
|
|
if (!startHour.isValid || currentEnd.toMillis() >= end) {
|
|
yield span;
|
|
return;
|
|
}
|
|
|
|
yield {
|
|
start: span.start,
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
|
locations: span.locations,
|
|
roles: span.roles,
|
|
}
|
|
|
|
while (true) {
|
|
currentStart = currentEnd;
|
|
currentEnd = currentEnd.plus({ hours: 1 });
|
|
if (currentEnd.toMillis() >= end) {
|
|
break;
|
|
}
|
|
yield {
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
|
locations: span.locations,
|
|
roles: span.roles,
|
|
}
|
|
}
|
|
|
|
yield {
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
|
end: span.end,
|
|
locations: span.locations,
|
|
roles: span.roles,
|
|
}
|
|
}
|
|
|
|
function padStretch(stretch: Stretch, timezone: string): Stretch {
|
|
// Pad by one hour and extend it to the nearest whole hour.
|
|
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale })
|
|
.minus(oneHourMs)
|
|
.startOf("hour")
|
|
;
|
|
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
|
|
.plus(2 * oneHourMs - 1)
|
|
.startOf("hour")
|
|
;
|
|
return {
|
|
spans: [
|
|
{
|
|
start: { ts: start.toMillis(), edges: [] },
|
|
end: stretch.spans[0].start,
|
|
locations: new Map(),
|
|
roles: new Map(),
|
|
},
|
|
...stretch.spans,
|
|
{
|
|
start: stretch.spans[stretch.spans.length - 1].end,
|
|
end: { ts: end.toMillis(), edges: [] },
|
|
locations: new Map(),
|
|
roles: new Map(),
|
|
},
|
|
],
|
|
start: start.toMillis(),
|
|
end: end.toMillis(),
|
|
}
|
|
}
|
|
|
|
function tableElementsFromStretches(
|
|
stretches: Iterable<Stretch>,
|
|
events: ClientMap<ClientScheduleEvent>,
|
|
locations: ClientMap<ClientScheduleLocation>,
|
|
shifts: ClientMap<ClientScheduleShift>,
|
|
roles: ClientMap<ClientScheduleRole>,
|
|
timezone: string,
|
|
) {
|
|
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, 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 locationGroups = new Map<number | undefined, LocationRow[]>([...locations.keys()].map(id => [id, []]));
|
|
locationGroups.set(undefined, []);
|
|
const roleGroups = new Map<number | undefined, RoleRow[]>([...roles.keys()].map(id => [id, []]));
|
|
roleGroups.set(undefined, []);
|
|
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, isBreak: boolean) {
|
|
columnGroups.push({ start, end, width, isBreak, cols: []})
|
|
}
|
|
function startDay(isBreak: boolean, content?: string) {
|
|
dayHeaders.push({ span: 0, isBreak, content })
|
|
}
|
|
function startHour(isBreak: boolean, content?: string, isDayShift = false) {
|
|
hourHeaders.push({ span: 0, isBreak, isDayShift, content })
|
|
}
|
|
function startLocation(id: number | undefined, 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) });
|
|
}
|
|
}
|
|
function startRole(id: number | undefined, 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) });
|
|
}
|
|
}
|
|
function pushColumn(minutes?: number) {
|
|
totalColumns += 1;
|
|
columnGroups[columnGroups.length - 1].cols.push({ minutes })
|
|
dayHeaders[dayHeaders.length - 1].span += 1;
|
|
hourHeaders[hourHeaders.length - 1].span += 1;
|
|
for (const locationGroup of locationGroups.values()) {
|
|
for (const row of locationGroup) {
|
|
row[row.length - 1].span += 1;
|
|
}
|
|
}
|
|
for (const roleGroup of roleGroups.values()) {
|
|
for (const row of roleGroup) {
|
|
row[row.length - 1].span += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let lastStretch: Stretch | undefined;
|
|
for (let stretch of stretches) {
|
|
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, false);
|
|
startDay(false, startDate.toFormat("yyyy-LL-dd"));
|
|
startHour(false, startDate.toFormat("HH:mm"));
|
|
for (const locationId of locationGroups.keys()) {
|
|
startLocation(locationId, false);
|
|
}
|
|
for (const roleId of roleGroups.keys()) {
|
|
startRole(roleId, false);
|
|
}
|
|
} else {
|
|
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;
|
|
if (!sameDay)
|
|
startDay(true);
|
|
startHour(true, "break");
|
|
for (const locationId of locationGroups.keys()) {
|
|
startLocation(locationId, true);
|
|
}
|
|
for (const roleId of roleGroups.keys()) {
|
|
startRole(roleId, true);
|
|
}
|
|
pushColumn();
|
|
|
|
startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
|
|
if (!sameDay)
|
|
startDay(false, dayName);
|
|
startHour(false, startDate.toFormat("HH:mm"));
|
|
for (const locationId of locationGroups.keys()) {
|
|
startLocation(locationId, false);
|
|
}
|
|
for (const roleId of roleGroups.keys()) {
|
|
startRole(roleId, false);
|
|
}
|
|
}
|
|
|
|
for (const span of stretch.spans) {
|
|
for (const cutSpan of cutSpansByHours(span, timezone)) {
|
|
const end = cutSpan.end.ts;
|
|
const durationMs = end - cutSpan.start.ts;
|
|
|
|
for (const locationId of locationGroups.keys()) {
|
|
const slots = cutSpan.locations.get(locationId) ?? new Set();
|
|
const group = locationGroups.get(locationId)!;
|
|
const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
|
|
if (!setEquals(slots, existing)) {
|
|
startLocation(locationId, false, slots);
|
|
}
|
|
}
|
|
for (const roleId of roleGroups.keys()) {
|
|
const slots = cutSpan.roles.get(roleId) ?? new Set();
|
|
const group = roleGroups.get(roleId)!;
|
|
const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
|
|
if (!setEquals(slots, existing)) {
|
|
startRole(roleId, false, slots);
|
|
}
|
|
}
|
|
|
|
pushColumn(durationMs / oneMinMs);
|
|
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: accountStore.activeLocale });
|
|
const isDayShift = end === endDate.startOf("day").toMillis();
|
|
if (isDayShift) {
|
|
startDay(
|
|
false,
|
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
|
.toFormat("yyyy-LL-dd")
|
|
);
|
|
}
|
|
if (end === endDate.startOf("hour").toMillis()) {
|
|
startHour(
|
|
false,
|
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
|
.toFormat("HH:mm"),
|
|
isDayShift,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
lastStretch = stretch;
|
|
}
|
|
|
|
return {
|
|
totalColumns,
|
|
columnGroups,
|
|
dayHeaders: dayHeaders.filter(day => day.span),
|
|
hourHeaders: hourHeaders.filter(hour => hour.span),
|
|
locationGroups: new Map([...locationGroups]
|
|
.filter(([_, rows]) => rows.length)
|
|
.map(([id, rows]) => [
|
|
id, rows.map(row => row.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,
|
|
};
|
|
}
|
|
|
|
const props = defineProps<{
|
|
schedule: ClientSchedule,
|
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
|
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
|
|
}>();
|
|
const schedule = computed(() => props.schedule);
|
|
const junctions = computed(() => {
|
|
return junctionsFromEdges([
|
|
...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
|
|
...edgesFromShifts(schedule.value.shifts.values(), props.shiftSlotFilter),
|
|
])
|
|
});
|
|
const stretches = computed(() => {
|
|
return [
|
|
...stretchesFromSpans(
|
|
spansFromJunctions(
|
|
junctions.value,
|
|
schedule.value.locations,
|
|
schedule.value.roles,
|
|
),
|
|
oneHourMs * 5
|
|
)
|
|
]
|
|
})
|
|
|
|
const accountStore = useAccountStore();
|
|
const timezone = computed({
|
|
get: () => accountStore.activeTimezone,
|
|
set: (value: string) => { accountStore.timezone = value },
|
|
});
|
|
|
|
const elements = computed(() => {
|
|
return tableElementsFromStretches(
|
|
stretches.value,
|
|
schedule.value.events,
|
|
schedule.value.locations,
|
|
schedule.value.shifts,
|
|
schedule.value.roles,
|
|
accountStore.activeTimezone
|
|
);
|
|
});
|
|
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 locationGroups = computed(() => elements.value.locationGroups);
|
|
const roleGroups = computed(() => elements.value.roleGroups);
|
|
|
|
const now = useState(() => Math.round(Date.now() / oneMinMs) * oneMinMs);
|
|
const interval = ref<any>();
|
|
onMounted(() => {
|
|
interval.value = setInterval(() => {
|
|
const newNow = Math.round(Date.now() / oneMinMs) * oneMinMs;
|
|
if (now.value !== newNow)
|
|
now.value = newNow;
|
|
}, 1000);
|
|
});
|
|
onUnmounted(() => {
|
|
clearInterval(interval.value);
|
|
});
|
|
const nowOffset = computed(() => {
|
|
let offset = 0;
|
|
for (let group of columnGroups.value) {
|
|
if (group.start <= now.value && now.value < group.end) {
|
|
return offset + (now.value - group.start) / (group.end - group.start) * group.width;
|
|
}
|
|
offset += group.width;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.timetable {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.timetable table {
|
|
border-spacing: 0;
|
|
table-layout: fixed;
|
|
width: 100%;
|
|
font-size: 0.8rem;
|
|
--row-header-width: 6rem;
|
|
--cell-size: 3rem;
|
|
}
|
|
|
|
.timetable col {
|
|
width: calc(var(--cell-size) * var(--minutes, 60) / 60);
|
|
}
|
|
|
|
.timetable col.header {
|
|
width: var(--row-header-width);
|
|
}
|
|
|
|
.timetable th:first-child {
|
|
background-color: var(--background);
|
|
position: sticky;
|
|
left: 0;
|
|
}
|
|
|
|
.timetable tr:not(.overlay) :is(td, th) {
|
|
overflow: hidden;
|
|
white-space: pre;
|
|
text-overflow: ellipsis;
|
|
padding: 0.1rem;
|
|
border-top: 1px solid var(--foreground);
|
|
border-right: 1px solid var(--foreground);
|
|
}
|
|
.timetable tr th:first-child {
|
|
border-left: 1px solid var(--foreground);
|
|
}
|
|
.timetable tbody tr:last-child :is(td, th) {
|
|
border-bottom: 1px solid var(--foreground);
|
|
}
|
|
|
|
.timetable tbody {
|
|
position: relative;
|
|
}
|
|
.timetable tr.overlay .now {
|
|
background-color: #f008;
|
|
position: absolute;
|
|
top: 0;
|
|
left: calc(var(--row-header-width) + var(--cell-size) * var(--now-offset) - 1px);
|
|
bottom: 0;
|
|
width: 2px;
|
|
scroll-margin-inline-start: calc(var(--row-header-width) + 2rem);
|
|
}
|
|
.now .label {
|
|
position: absolute;
|
|
top: 0;
|
|
color: white;
|
|
background: red;
|
|
border-radius: 0.2rem;
|
|
font-size: 0.5rem;
|
|
line-height: 1.1;
|
|
padding-inline: 0.1rem;
|
|
translate: calc(-50% + 0.5px) -50%;
|
|
}
|
|
|
|
colgroup.break {
|
|
background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
|
|
}
|
|
|
|
tr.hours>th:is(.break, :first-child) + th div {
|
|
visibility: hidden;
|
|
}
|
|
|
|
tr.hours>th:first-child {
|
|
z-index: 1;
|
|
}
|
|
tr.hours>th + th:not(.break) {
|
|
overflow: visible;
|
|
padding-top: 0;
|
|
vertical-align: top;
|
|
}
|
|
tr.hours>th + th:not(.break) div {
|
|
font-variant-numeric: tabular-nums;
|
|
padding-top: 0.2rem;
|
|
background-color: Canvas;
|
|
translate: calc(-0.5 * var(--cell-size)) 0;
|
|
}
|
|
tr.hours>th + th.dayShift div {
|
|
padding-top: 0;
|
|
margin-top: 0.2rem;
|
|
}
|
|
|
|
.event, .shift {
|
|
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
|
|
}
|
|
.event.crew {
|
|
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
|
|
}
|
|
</style>
|