2025-06-30 18:58:24 +02:00
|
|
|
<!--
|
|
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-->
|
2025-03-05 15:36:50 +01:00
|
|
|
<template>
|
2025-06-14 19:22:53 +02:00
|
|
|
<figure class="timetable">
|
2025-03-05 15:36:50 +01:00
|
|
|
<details>
|
|
|
|
<summary>Debug</summary>
|
2025-03-09 18:35:38 +01:00
|
|
|
<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>
|
2025-03-05 15:36:50 +01:00
|
|
|
</details>
|
|
|
|
<table>
|
|
|
|
<colgroup>
|
|
|
|
<col class="header" />
|
|
|
|
</colgroup>
|
2025-06-25 15:38:47 +02:00
|
|
|
<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="{ break: group.isBreak }">
|
2025-03-05 15:36:50 +01:00
|
|
|
<col v-for="col, index in group.cols" :key="index" :style='{"--minutes": col.minutes}' />
|
|
|
|
</colgroup>
|
|
|
|
<thead>
|
|
|
|
<tr>
|
|
|
|
<th></th>
|
2025-06-18 15:13:18 +02:00
|
|
|
<th
|
|
|
|
v-for="day, index in dayHeaders"
|
|
|
|
:key="index"
|
|
|
|
:colSpan="day.span"
|
|
|
|
:class="{ break: day.isBreak }"
|
|
|
|
>
|
2025-03-05 15:36:50 +01:00
|
|
|
{{ day.content }}
|
|
|
|
</th>
|
|
|
|
</tr>
|
2025-06-18 15:13:18 +02:00
|
|
|
<tr class="hours">
|
2025-03-05 15:36:50 +01:00
|
|
|
<th>Location</th>
|
2025-06-18 15:13:18 +02:00
|
|
|
<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>
|
2025-03-05 15:36:50 +01:00
|
|
|
</th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
2025-06-18 18:17:03 +02:00
|
|
|
<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>
|
2025-06-30 16:39:51 +02:00
|
|
|
<template v-for="[id, locationGroup] in locationGroups" :key="id">
|
2025-06-25 15:38:47 +02:00
|
|
|
<tr
|
2025-06-30 16:39:51 +02:00
|
|
|
v-for="row, index in locationGroup"
|
2025-06-25 15:38:47 +02:00
|
|
|
:key="index"
|
|
|
|
>
|
|
|
|
<th
|
|
|
|
v-if="index === 0"
|
2025-06-30 16:39:51 +02:00
|
|
|
:rowSpan="locationGroup.length"
|
2025-06-25 15:38:47 +02:00
|
|
|
>
|
2025-06-30 16:39:51 +02:00
|
|
|
{{ schedule.locations.get(id!)?.name }}
|
2025-06-25 15:38:47 +02:00
|
|
|
</th>
|
2025-03-10 20:58:33 +01:00
|
|
|
<td
|
2025-06-25 15:38:47 +02:00
|
|
|
v-for="cell, index in row"
|
2025-03-10 20:58:33 +01:00
|
|
|
:key="index"
|
2025-06-25 15:38:47 +02:00
|
|
|
:colSpan="cell.span"
|
|
|
|
:class='{"event": cell.slot, "crew": cell.event?.crew }'
|
|
|
|
:title="cell.event?.name"
|
2025-03-10 20:58:33 +01:00
|
|
|
>
|
2025-06-25 15:38:47 +02:00
|
|
|
{{ cell.event?.name }}
|
2025-03-10 20:58:33 +01:00
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
</template>
|
2025-06-30 16:14:40 +02:00
|
|
|
<template v-if="roleGroups.size">
|
2025-03-15 22:47:32 +01:00
|
|
|
<tr>
|
|
|
|
<th>Shifts</th>
|
|
|
|
<td :colSpan="totalColumns"></td>
|
|
|
|
</tr>
|
2025-06-30 16:14:40 +02:00
|
|
|
<template v-for="[id, roleGroup] in roleGroups" :key="id">
|
2025-06-25 15:38:47 +02:00
|
|
|
<tr
|
2025-06-30 16:14:40 +02:00
|
|
|
v-for="row, index in roleGroup"
|
2025-06-25 15:38:47 +02:00
|
|
|
:key="index"
|
|
|
|
>
|
|
|
|
<th
|
|
|
|
v-if="index === 0"
|
2025-06-30 16:14:40 +02:00
|
|
|
:rowSpan="roleGroup.length"
|
2025-06-25 15:38:47 +02:00
|
|
|
>
|
2025-06-30 16:14:40 +02:00
|
|
|
{{ schedule.roles.get(id!)?.name }}
|
2025-06-25 15:38:47 +02:00
|
|
|
</th>
|
2025-03-15 22:47:32 +01:00
|
|
|
<td
|
2025-06-25 15:38:47 +02:00
|
|
|
v-for="cell, index in row"
|
2025-03-15 22:47:32 +01:00
|
|
|
:key="index"
|
2025-06-25 15:38:47 +02:00
|
|
|
:colSpan="cell.span"
|
|
|
|
:class='{"shift": cell.slot }'
|
|
|
|
:title="cell.shift?.name"
|
2025-03-15 22:47:32 +01:00
|
|
|
>
|
2025-06-25 15:38:47 +02:00
|
|
|
{{ cell.shift?.name }}
|
2025-03-15 22:47:32 +01:00
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
</template>
|
|
|
|
</template>
|
2025-03-05 15:36:50 +01:00
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</figure>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2025-06-23 12:48:09 +02:00
|
|
|
import { DateTime } from "~/shared/utils/luxon";
|
2025-06-11 21:05:17 +02:00
|
|
|
import type { Id } from "~/shared/types/common";
|
2025-03-12 14:44:06 +01:00
|
|
|
import { pairs, setEquals } from "~/shared/utils/functions";
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
const oneHourMs = 60 * 60 * 1000;
|
|
|
|
const oneMinMs = 60 * 1000;
|
|
|
|
|
2025-02-27 15:01:30 +01:00
|
|
|
// See timetable-terminology.png for an illustration of how these terms are related
|
|
|
|
|
2025-02-26 22:53:56 +01:00
|
|
|
/** Point in time where a time slots starts or ends. */
|
2025-03-10 20:58:33 +01:00
|
|
|
type Edge =
|
2025-06-14 19:22:53 +02:00
|
|
|
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
|
2025-06-30 16:14:40 +02:00
|
|
|
| { type: "start" | "end", source: "shift", roleId?: Id, slot: ClientScheduleShiftSlot }
|
2025-03-10 20:58:33 +01:00
|
|
|
;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
/** Point in time where multiple edges meet. */
|
2025-03-09 16:49:57 +01:00
|
|
|
type Junction = { ts: number, edges: Edge[] };
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
/** Span of time between two adjacent junctions */
|
|
|
|
type Span = {
|
|
|
|
start: Junction;
|
|
|
|
end: Junction,
|
2025-06-30 16:39:51 +02:00
|
|
|
locations: Map<number | undefined, Set<ClientScheduleEventSlot>>,
|
2025-06-30 16:14:40 +02:00
|
|
|
roles: Map<number | undefined, Set<ClientScheduleShiftSlot>>,
|
2025-02-26 22:53:56 +01:00
|
|
|
};
|
|
|
|
|
2025-02-27 15:01:30 +01:00
|
|
|
/**
|
|
|
|
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.
|
|
|
|
*/
|
2025-02-26 22:53:56 +01:00
|
|
|
type Stretch = {
|
2025-03-09 16:49:57 +01:00
|
|
|
start: number,
|
|
|
|
end: number,
|
2025-02-26 22:53:56 +01:00
|
|
|
spans: Span[];
|
|
|
|
}
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
function* edgesFromEvents(
|
2025-06-14 19:22:53 +02:00
|
|
|
events: Iterable<ClientScheduleEvent>,
|
|
|
|
filter = (slot: ClientScheduleEventSlot) => true,
|
2025-06-11 21:05:17 +02:00
|
|
|
): Generator<Edge> {
|
2025-02-26 22:53:56 +01:00
|
|
|
for (const event of events) {
|
2025-06-14 19:22:53 +02:00
|
|
|
if (event.deleted)
|
|
|
|
continue;
|
|
|
|
for (const slot of event.slots.values()) {
|
|
|
|
if (!filter(slot) || slot.deleted)
|
|
|
|
continue;
|
2025-02-26 22:53:56 +01:00
|
|
|
if (slot.start > slot.end) {
|
|
|
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
yield { type: "start", source: "event", slot }
|
|
|
|
yield { type: "end", source: "event", slot }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
function* edgesFromShifts(
|
2025-06-14 19:22:53 +02:00
|
|
|
shifts: Iterable<ClientScheduleShift>,
|
|
|
|
filter = (slot: ClientScheduleShiftSlot) => true,
|
2025-06-11 21:05:17 +02:00
|
|
|
): Generator<Edge> {
|
2025-03-10 20:58:33 +01:00
|
|
|
for (const shift of shifts) {
|
2025-06-14 19:22:53 +02:00
|
|
|
if (shift.deleted)
|
|
|
|
continue;
|
|
|
|
for (const slot of shift.slots.values()) {
|
|
|
|
if (!filter(slot) || slot.deleted)
|
|
|
|
continue;
|
2025-03-10 20:58:33 +01:00
|
|
|
if (slot.start > slot.end) {
|
|
|
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
|
|
|
}
|
2025-06-23 22:46:39 +02:00
|
|
|
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
|
|
|
|
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function junctionsFromEdges(edges: Iterable<Edge>) {
|
2025-03-09 16:49:57 +01:00
|
|
|
const junctions = new Map<number, Junction>();
|
2025-02-26 22:53:56 +01:00
|
|
|
for (const edge of edges) {
|
2025-06-14 19:22:53 +02:00
|
|
|
const ts = edge.slot[edge.type].toMillis();
|
2025-02-26 22:53:56 +01:00
|
|
|
const junction = junctions.get(ts);
|
|
|
|
if (junction) {
|
|
|
|
junction.edges.push(edge);
|
|
|
|
} else {
|
|
|
|
junctions.set(ts, { ts, edges: [edge] });
|
|
|
|
}
|
|
|
|
}
|
2025-06-29 20:30:39 +02:00
|
|
|
const keys = [...junctions.keys()].sort((a, b) => a - b);
|
2025-02-26 22:53:56 +01:00
|
|
|
return keys.map(key => junctions.get(key)!);
|
|
|
|
}
|
|
|
|
|
2025-02-26 23:56:19 +01:00
|
|
|
function* spansFromJunctions(
|
2025-06-11 21:05:17 +02:00
|
|
|
junctions: Iterable<Junction>,
|
2025-06-23 22:46:39 +02:00
|
|
|
locations: ClientMap<ClientScheduleLocation>,
|
|
|
|
roles: ClientMap<ClientScheduleRole>,
|
2025-02-26 23:56:19 +01:00
|
|
|
): Generator<Span> {
|
2025-06-30 16:39:51 +02:00
|
|
|
const activeLocations = new Map<number | undefined, Set<ClientScheduleEventSlot>>(
|
|
|
|
[...locations.keys()].map(id => [id, new Set()])
|
2025-02-26 22:53:56 +01:00
|
|
|
);
|
2025-06-30 16:39:51 +02:00
|
|
|
activeLocations.set(undefined, new Set());
|
2025-06-30 16:14:40 +02:00
|
|
|
const activeRoles = new Map<number | undefined, Set<ClientScheduleShiftSlot>>(
|
|
|
|
[...roles.keys()].map(id => [id, new Set()]),
|
2025-03-10 20:58:33 +01:00
|
|
|
);
|
2025-06-30 16:14:40 +02:00
|
|
|
activeRoles.set(undefined, new Set());
|
2025-02-26 22:53:56 +01:00
|
|
|
for (const [start, end] of pairs(junctions)) {
|
|
|
|
for (const edge of start.edges) {
|
|
|
|
if (edge.type === "start") {
|
2025-03-10 20:58:33 +01:00
|
|
|
if (edge.source === "event") {
|
2025-06-23 22:46:39 +02:00
|
|
|
for (const locationId of edge.slot.locationIds) {
|
2025-06-30 16:39:51 +02:00
|
|
|
activeLocations.get(locationId)?.add(edge.slot);
|
|
|
|
}
|
|
|
|
if (edge.slot.locationIds.size === 0) {
|
|
|
|
activeLocations.get(undefined)?.add(edge.slot);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
|
|
|
} else if (edge.source === "shift") {
|
2025-06-30 16:14:40 +02:00
|
|
|
activeRoles.get(edge.roleId)?.add(edge.slot);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
yield {
|
|
|
|
start,
|
|
|
|
end,
|
|
|
|
locations: new Map(
|
|
|
|
[...activeLocations]
|
|
|
|
.filter(([_, slots]) => slots.size)
|
|
|
|
.map(([location, slots]) => [location, new Set(slots)])
|
|
|
|
),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(
|
|
|
|
[...activeRoles]
|
|
|
|
.filter(([_, slots]) => slots.size)
|
|
|
|
.map(([role, slots]) => [role, new Set(slots)])
|
|
|
|
),
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
for (const edge of end.edges) {
|
|
|
|
if (edge.type === "end") {
|
2025-03-10 20:58:33 +01:00
|
|
|
if (edge.source === "event") {
|
2025-06-23 22:46:39 +02:00
|
|
|
for (const locationId of edge.slot.locationIds) {
|
2025-06-30 16:39:51 +02:00
|
|
|
activeLocations.get(locationId)?.delete(edge.slot);
|
|
|
|
}
|
|
|
|
if (edge.slot.locationIds.size === 0) {
|
|
|
|
activeLocations.get(undefined)?.delete(edge.slot);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
|
|
|
} else if (edge.source === "shift") {
|
2025-06-11 21:05:17 +02:00
|
|
|
activeRoles.get(edge.roleId)?.delete(edge.slot);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function createStretch(spans: Span[]): Stretch {
|
|
|
|
return {
|
2025-03-09 18:35:38 +01:00
|
|
|
spans,
|
|
|
|
start: spans[0].start.ts,
|
|
|
|
end: spans[spans.length - 1].end.ts,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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.
|
2025-03-10 20:58:33 +01:00
|
|
|
if (
|
|
|
|
span.locations.size === 0
|
|
|
|
&& span.roles.size === 0
|
2025-03-09 16:49:57 +01:00
|
|
|
&& span.end.ts - span.start.ts >= minSeparation
|
2025-02-26 22:53:56 +01:00
|
|
|
) {
|
|
|
|
yield createStretch(currentSpans);
|
|
|
|
currentSpans = [];
|
|
|
|
} else {
|
|
|
|
currentSpans.push(span);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (currentSpans.length)
|
|
|
|
yield createStretch(currentSpans);
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Cuts up a span by whole hours that crosses it */
|
2025-03-09 18:35:38 +01:00
|
|
|
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
2025-06-14 19:22:53 +02:00
|
|
|
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: accountStore.activeLocale })
|
2025-03-09 18:35:38 +01:00
|
|
|
.startOf("hour")
|
|
|
|
;
|
|
|
|
const end = span.end.ts;
|
|
|
|
|
2025-02-26 22:53:56 +01:00
|
|
|
let currentStart = startHour;
|
2025-03-09 18:35:38 +01:00
|
|
|
let currentEnd = startHour.plus({ hours: 1 });
|
|
|
|
if (!startHour.isValid || currentEnd.toMillis() >= end) {
|
2025-02-26 22:53:56 +01:00
|
|
|
yield span;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
yield {
|
|
|
|
start: span.start,
|
2025-03-09 18:35:38 +01:00
|
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
2025-03-09 18:35:38 +01:00
|
|
|
while (true) {
|
|
|
|
currentStart = currentEnd;
|
|
|
|
currentEnd = currentEnd.plus({ hours: 1 });
|
|
|
|
if (currentEnd.toMillis() >= end) {
|
|
|
|
break;
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
yield {
|
2025-03-09 18:35:38 +01:00
|
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
|
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
yield {
|
2025-03-09 18:35:38 +01:00
|
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
end: span.end,
|
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-09 18:35:38 +01:00
|
|
|
function padStretch(stretch: Stretch, timezone: string): Stretch {
|
|
|
|
// Pad by one hour and extend it to the nearest whole hour.
|
2025-06-14 19:22:53 +02:00
|
|
|
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale })
|
2025-03-09 18:35:38 +01:00
|
|
|
.minus(oneHourMs)
|
|
|
|
.startOf("hour")
|
|
|
|
;
|
2025-06-14 19:22:53 +02:00
|
|
|
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
|
2025-03-09 18:35:38 +01:00
|
|
|
.plus(2 * oneHourMs - 1)
|
|
|
|
.startOf("hour")
|
|
|
|
;
|
|
|
|
return {
|
|
|
|
spans: [
|
|
|
|
{
|
|
|
|
start: { ts: start.toMillis(), edges: [] },
|
|
|
|
end: stretch.spans[0].start,
|
|
|
|
locations: new Map(),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(),
|
2025-03-09 18:35:38 +01:00
|
|
|
},
|
|
|
|
...stretch.spans,
|
|
|
|
{
|
|
|
|
start: stretch.spans[stretch.spans.length - 1].end,
|
|
|
|
end: { ts: end.toMillis(), edges: [] },
|
|
|
|
locations: new Map(),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(),
|
2025-03-09 18:35:38 +01:00
|
|
|
},
|
|
|
|
],
|
|
|
|
start: start.toMillis(),
|
|
|
|
end: end.toMillis(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-26 23:56:19 +01:00
|
|
|
function tableElementsFromStretches(
|
2025-03-10 14:40:02 +01:00
|
|
|
stretches: Iterable<Stretch>,
|
2025-06-23 22:46:39 +02:00
|
|
|
events: ClientMap<ClientScheduleEvent>,
|
|
|
|
locations: ClientMap<ClientScheduleLocation>,
|
|
|
|
shifts: ClientMap<ClientScheduleShift>,
|
|
|
|
roles: ClientMap<ClientScheduleRole>,
|
2025-03-10 14:40:02 +01:00
|
|
|
timezone: string,
|
2025-02-26 23:56:19 +01:00
|
|
|
) {
|
2025-02-26 22:53:56 +01:00
|
|
|
type Col = { minutes?: number };
|
2025-06-18 15:13:18 +02:00
|
|
|
type DayHead = { span: number, isBreak: boolean, content?: string }
|
|
|
|
type HourHead = { span: number, isBreak: boolean, isDayShift: boolean, content?: string }
|
2025-06-25 15:38:47 +02:00
|
|
|
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[] };
|
2025-06-18 18:17:03 +02:00
|
|
|
const columnGroups: ColumnGroup[] = [];
|
2025-02-26 22:53:56 +01:00
|
|
|
const dayHeaders: DayHead[] = [];
|
|
|
|
const hourHeaders: HourHead[]= [];
|
2025-06-30 16:39:51 +02:00
|
|
|
const locationGroups = new Map<number | undefined, LocationRow[]>([...locations.keys()].map(id => [id, []]));
|
|
|
|
locationGroups.set(undefined, []);
|
2025-06-30 16:14:40 +02:00
|
|
|
const roleGroups = new Map<number | undefined, RoleRow[]>([...roles.keys()].map(id => [id, []]));
|
|
|
|
roleGroups.set(undefined, []);
|
2025-06-14 19:22:53 +02:00
|
|
|
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])));
|
2025-03-10 20:58:33 +01:00
|
|
|
let totalColumns = 0;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
2025-06-25 15:38:47 +02:00
|
|
|
function startColumnGroup(start: number, end: number, width: number, isBreak: boolean) {
|
|
|
|
columnGroups.push({ start, end, width, isBreak, cols: []})
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-18 15:13:18 +02:00
|
|
|
function startDay(isBreak: boolean, content?: string) {
|
|
|
|
dayHeaders.push({ span: 0, isBreak, content })
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-18 15:13:18 +02:00
|
|
|
function startHour(isBreak: boolean, content?: string, isDayShift = false) {
|
|
|
|
hourHeaders.push({ span: 0, isBreak, isDayShift, content })
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:39:51 +02:00
|
|
|
function startLocation(id: number | undefined, isBreak: boolean, newSlots = new Set<ClientScheduleEventSlot>()) {
|
2025-06-25 15:38:47 +02:00
|
|
|
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) });
|
2025-03-10 14:40:02 +01:00
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
function startRole(id: number | undefined, isBreak: boolean, newSlots = new Set<ClientScheduleShiftSlot>()) {
|
2025-06-25 15:38:47 +02:00
|
|
|
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) });
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
function pushColumn(minutes?: number) {
|
2025-03-10 20:58:33 +01:00
|
|
|
totalColumns += 1;
|
2025-02-26 22:53:56 +01:00
|
|
|
columnGroups[columnGroups.length - 1].cols.push({ minutes })
|
|
|
|
dayHeaders[dayHeaders.length - 1].span += 1;
|
|
|
|
hourHeaders[hourHeaders.length - 1].span += 1;
|
2025-06-30 16:39:51 +02:00
|
|
|
for (const locationGroup of locationGroups.values()) {
|
|
|
|
for (const row of locationGroup) {
|
2025-06-25 15:38:47 +02:00
|
|
|
row[row.length - 1].span += 1;
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
for (const roleGroup of roleGroups.values()) {
|
|
|
|
for (const row of roleGroup) {
|
2025-06-25 15:38:47 +02:00
|
|
|
row[row.length - 1].span += 1;
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
2025-06-18 18:17:03 +02:00
|
|
|
let lastStretch: Stretch | undefined;
|
2025-03-09 18:35:38 +01:00
|
|
|
for (let stretch of stretches) {
|
|
|
|
stretch = padStretch(stretch, timezone);
|
2025-06-14 19:22:53 +02:00
|
|
|
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale });
|
2025-06-18 18:17:03 +02:00
|
|
|
if (!lastStretch) {
|
2025-06-25 15:38:47 +02:00
|
|
|
startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
|
2025-06-18 15:13:18 +02:00
|
|
|
startDay(false, startDate.toFormat("yyyy-LL-dd"));
|
|
|
|
startHour(false, startDate.toFormat("HH:mm"));
|
2025-06-30 16:39:51 +02:00
|
|
|
for (const locationId of locationGroups.keys()) {
|
|
|
|
startLocation(locationId, false);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
for (const roleId of roleGroups.keys()) {
|
|
|
|
startRole(roleId, false);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
} else {
|
2025-06-25 15:38:47 +02:00
|
|
|
startColumnGroup(lastStretch.end, stretch.start, 1, true);
|
2025-03-09 18:35:38 +01:00
|
|
|
const dayName = startDate.toFormat("yyyy-LL-dd");
|
2025-03-06 00:37:40 +01:00
|
|
|
const lastDayHeader = dayHeaders[dayHeaders.length - 1]
|
|
|
|
const sameDay = dayName === lastDayHeader.content && lastDayHeader.span;
|
2025-02-26 22:53:56 +01:00
|
|
|
if (!sameDay)
|
2025-06-18 15:13:18 +02:00
|
|
|
startDay(true);
|
|
|
|
startHour(true, "break");
|
2025-06-30 16:39:51 +02:00
|
|
|
for (const locationId of locationGroups.keys()) {
|
|
|
|
startLocation(locationId, true);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
for (const roleId of roleGroups.keys()) {
|
|
|
|
startRole(roleId, true);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
pushColumn();
|
|
|
|
|
2025-06-25 15:38:47 +02:00
|
|
|
startColumnGroup(stretch.start, stretch.end, (stretch.end - stretch.start) / oneHourMs, false);
|
2025-02-26 22:53:56 +01:00
|
|
|
if (!sameDay)
|
2025-06-18 15:13:18 +02:00
|
|
|
startDay(false, dayName);
|
|
|
|
startHour(false, startDate.toFormat("HH:mm"));
|
2025-06-30 16:39:51 +02:00
|
|
|
for (const locationId of locationGroups.keys()) {
|
|
|
|
startLocation(locationId, false);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
for (const roleId of roleGroups.keys()) {
|
|
|
|
startRole(roleId, false);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
for (const span of stretch.spans) {
|
2025-03-09 18:35:38 +01:00
|
|
|
for (const cutSpan of cutSpansByHours(span, timezone)) {
|
2025-03-09 16:49:57 +01:00
|
|
|
const end = cutSpan.end.ts;
|
|
|
|
const durationMs = end - cutSpan.start.ts;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
2025-06-30 16:39:51 +02:00
|
|
|
for (const locationId of locationGroups.keys()) {
|
|
|
|
const slots = cutSpan.locations.get(locationId) ?? new Set();
|
|
|
|
const group = locationGroups.get(locationId)!;
|
2025-06-25 15:38:47 +02:00
|
|
|
const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
|
|
|
|
if (!setEquals(slots, existing)) {
|
2025-06-30 16:39:51 +02:00
|
|
|
startLocation(locationId, false, slots);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
2025-06-30 16:14:40 +02:00
|
|
|
for (const roleId of roleGroups.keys()) {
|
|
|
|
const slots = cutSpan.roles.get(roleId) ?? new Set();
|
|
|
|
const group = roleGroups.get(roleId)!;
|
2025-06-25 15:38:47 +02:00
|
|
|
const existing = new Set(group.map(row => row[row.length - 1].slot).filter(slot => slot));
|
|
|
|
if (!setEquals(slots, existing)) {
|
2025-06-30 16:14:40 +02:00
|
|
|
startRole(roleId, false, slots);
|
2025-03-10 20:58:33 +01:00
|
|
|
}
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
pushColumn(durationMs / oneMinMs);
|
2025-06-14 19:22:53 +02:00
|
|
|
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: accountStore.activeLocale });
|
2025-06-18 15:13:18 +02:00
|
|
|
const isDayShift = end === endDate.startOf("day").toMillis();
|
|
|
|
if (isDayShift) {
|
2025-03-09 18:35:38 +01:00
|
|
|
startDay(
|
2025-06-18 15:13:18 +02:00
|
|
|
false,
|
2025-06-14 19:22:53 +02:00
|
|
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
2025-03-09 18:35:38 +01:00
|
|
|
.toFormat("yyyy-LL-dd")
|
|
|
|
);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-03-09 18:35:38 +01:00
|
|
|
if (end === endDate.startOf("hour").toMillis()) {
|
|
|
|
startHour(
|
2025-06-18 15:13:18 +02:00
|
|
|
false,
|
2025-06-14 19:22:53 +02:00
|
|
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
2025-06-18 15:13:18 +02:00
|
|
|
.toFormat("HH:mm"),
|
|
|
|
isDayShift,
|
2025-03-09 18:35:38 +01:00
|
|
|
);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2025-06-18 18:17:03 +02:00
|
|
|
|
|
|
|
lastStretch = stretch;
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2025-03-10 20:58:33 +01:00
|
|
|
totalColumns,
|
2025-02-26 22:53:56 +01:00
|
|
|
columnGroups,
|
|
|
|
dayHeaders: dayHeaders.filter(day => day.span),
|
|
|
|
hourHeaders: hourHeaders.filter(hour => hour.span),
|
2025-06-25 15:38:47 +02:00
|
|
|
locationGroups: new Map([...locationGroups]
|
|
|
|
.filter(([_, rows]) => rows.length)
|
|
|
|
.map(([id, rows]) => [
|
|
|
|
id, rows.map(row => row.filter(cell => cell.span))
|
|
|
|
]))
|
2025-03-15 22:47:32 +01:00
|
|
|
,
|
2025-06-25 15:38:47 +02:00
|
|
|
roleGroups: new Map([...roleGroups]
|
|
|
|
.filter(([_, rows]) => rows.length)
|
|
|
|
.map(([id, rows]) => [
|
|
|
|
id, rows.map(row => row.filter(cell => cell.span))
|
|
|
|
]))
|
2025-03-15 22:47:32 +01:00
|
|
|
,
|
2025-03-10 14:40:02 +01:00
|
|
|
eventBySlotId,
|
2025-02-26 22:53:56 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-03-14 17:38:01 +01:00
|
|
|
const props = defineProps<{
|
2025-06-14 19:22:53 +02:00
|
|
|
schedule: ClientSchedule,
|
|
|
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
|
|
|
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
|
2025-03-14 17:38:01 +01:00
|
|
|
}>();
|
|
|
|
const schedule = computed(() => props.schedule);
|
2025-06-11 21:05:17 +02:00
|
|
|
const junctions = computed(() => {
|
|
|
|
return junctionsFromEdges([
|
2025-06-14 19:22:53 +02:00
|
|
|
...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
|
|
|
|
...edgesFromShifts(schedule.value.shifts.values(), props.shiftSlotFilter),
|
2025-06-11 21:05:17 +02:00
|
|
|
])
|
|
|
|
});
|
|
|
|
const stretches = computed(() => {
|
|
|
|
return [
|
|
|
|
...stretchesFromSpans(
|
|
|
|
spansFromJunctions(
|
|
|
|
junctions.value,
|
2025-06-14 19:22:53 +02:00
|
|
|
schedule.value.locations,
|
|
|
|
schedule.value.roles,
|
2025-06-11 21:05:17 +02:00
|
|
|
),
|
|
|
|
oneHourMs * 5
|
|
|
|
)
|
|
|
|
]
|
|
|
|
})
|
2025-03-09 18:35:38 +01:00
|
|
|
|
2025-05-24 20:01:23 +02:00
|
|
|
const accountStore = useAccountStore();
|
2025-03-09 18:35:38 +01:00
|
|
|
const timezone = computed({
|
2025-05-24 20:01:23 +02:00
|
|
|
get: () => accountStore.activeTimezone,
|
|
|
|
set: (value: string) => { accountStore.timezone = value },
|
2025-03-09 18:35:38 +01:00
|
|
|
});
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
const elements = computed(() => {
|
|
|
|
return tableElementsFromStretches(
|
|
|
|
stretches.value,
|
2025-06-14 19:22:53 +02:00
|
|
|
schedule.value.events,
|
|
|
|
schedule.value.locations,
|
|
|
|
schedule.value.shifts,
|
|
|
|
schedule.value.roles,
|
2025-06-11 21:05:17 +02:00
|
|
|
accountStore.activeTimezone
|
|
|
|
);
|
|
|
|
});
|
2025-03-10 20:58:33 +01:00
|
|
|
const totalColumns = computed(() => elements.value.totalColumns);
|
2025-03-05 15:36:50 +01:00
|
|
|
const columnGroups = computed(() => elements.value.columnGroups);
|
|
|
|
const dayHeaders = computed(() => elements.value.dayHeaders);
|
|
|
|
const hourHeaders = computed(() => elements.value.hourHeaders);
|
2025-06-25 15:38:47 +02:00
|
|
|
const locationGroups = computed(() => elements.value.locationGroups);
|
|
|
|
const roleGroups = computed(() => elements.value.roleGroups);
|
2025-06-18 18:17:03 +02:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
});
|
2025-03-05 15:36:50 +01:00
|
|
|
</script>
|
2025-02-26 22:53:56 +01:00
|
|
|
|
2025-03-05 15:36:50 +01:00
|
|
|
<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;
|
|
|
|
}
|
|
|
|
|
2025-06-18 18:17:03 +02:00
|
|
|
.timetable tr:not(.overlay) :is(td, th) {
|
2025-03-10 14:40:02 +01:00
|
|
|
overflow: hidden;
|
|
|
|
white-space: pre;
|
|
|
|
text-overflow: ellipsis;
|
2025-03-05 15:36:50 +01:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2025-06-18 18:17:03 +02:00
|
|
|
.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%;
|
|
|
|
}
|
|
|
|
|
2025-06-18 15:13:18 +02:00
|
|
|
colgroup.break {
|
2025-03-05 15:36:50 +01:00
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
|
|
|
|
}
|
|
|
|
|
2025-06-18 15:13:18 +02:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2025-03-10 20:58:33 +01:00
|
|
|
.event, .shift {
|
2025-03-05 15:36:50 +01:00
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-03-10 14:40:02 +01:00
|
|
|
.event.crew {
|
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
|
|
|
|
}
|
2025-03-05 15:36:50 +01:00
|
|
|
</style>
|