owltide/components/Timetable.vue

794 lines
22 KiB
Vue
Raw Permalink Normal View History

<!--
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"
ref="nowLine"
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>
2025-03-10 20:58:33 +01:00
<td
v-for="cell, index in row"
2025-03-10 20:58:33 +01:00
:key="index"
:colSpan="cell.span"
:class='{"event": cell.slot, "crew": cell.event?.crew }'
:title="cell.event?.name"
2025-03-10 20:58:33 +01:00
>
{{ cell.event?.name }}
2025-03-10 20:58:33 +01:00
</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;
2025-02-27 15:01:30 +01:00
// See timetable-terminology.png for an illustration of how these terms are related
/** Point in time where a time slots starts or ends. */
2025-03-10 20:58:33 +01:00
type Edge =
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId?: Id, slot: ClientScheduleShiftSlot }
2025-03-10 20:58:33 +01:00
;
/** 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>>,
};
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.
*/
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.`);
}
2025-03-10 20:58:33 +01:00
yield { type: "start", source: "event", slot }
yield { type: "end", source: "event", slot }
}
}
}
function* edgesFromShifts(
shifts: Iterable<ClientScheduleShift>,
filter = (slot: ClientScheduleShiftSlot) => true,
): Generator<Edge> {
2025-03-10 20:58:33 +01:00
for (const shift of shifts) {
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.`);
}
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)!);
}
2025-02-26 23:56:19 +01:00
function* spansFromJunctions(
junctions: Iterable<Junction>,
locations: ClientMap<ClientScheduleLocation>,
roles: ClientMap<ClientScheduleRole>,
2025-02-26 23:56:19 +01:00
): 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()]),
2025-03-10 20:58:33 +01:00
);
activeRoles.set(undefined, new Set());
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") {
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);
2025-03-10 20:58:33 +01:00
}
} 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)])
),
2025-03-10 20:58:33 +01:00
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") {
2025-03-10 20:58:33 +01:00
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);
2025-03-10 20:58:33 +01:00
}
} 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.
2025-03-10 20:58:33 +01:00
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,
2025-03-10 20:58:33 +01:00
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,
2025-03-10 20:58:33 +01:00
roles: span.roles,
}
}
yield {
start: { ts: currentStart.toMillis(), edges: [] },
end: span.end,
locations: span.locations,
2025-03-10 20:58:33 +01:00
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(),
2025-03-10 20:58:33 +01:00
roles: new Map(),
},
...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(),
},
],
start: start.toMillis(),
end: end.toMillis(),
}
}
2025-02-26 23:56:19 +01:00
function tableElementsFromStretches(
stretches: Iterable<Stretch>,
events: ClientMap<ClientScheduleEvent>,
locations: ClientMap<ClientScheduleLocation>,
shifts: ClientMap<ClientScheduleShift>,
roles: ClientMap<ClientScheduleRole>,
timezone: string,
2025-02-26 23:56:19 +01:00
) {
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])));
2025-03-10 20:58:33 +01:00
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) });
2025-03-10 20:58:33 +01:00
}
}
function pushColumn(minutes?: number) {
2025-03-10 20:58:33 +01:00
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;
}
2025-03-10 20:58:33 +01:00
}
}
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);
2025-03-10 20:58:33 +01:00
}
} 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);
2025-03-10 20:58:33 +01:00
}
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);
2025-03-10 20:58:33 +01:00
}
}
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);
2025-03-10 20:58:33 +01:00
}
}
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 {
2025-03-10 20:58:33 +01:00
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,
};
}
2025-03-14 17:38:01 +01:00
const props = defineProps<{
schedule: ClientSchedule,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
2025-03-14 17:38:01 +01:00
}>();
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
);
});
2025-03-10 20:58:33 +01:00
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;
}
});
const nowLine = useTemplateRef("nowLine"); // If I name the ref now, the element disappears?!
function scrollToNow() {
if (nowLine.value) {
nowLine.value.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
}
}
defineExpose({
scrollToNow,
});
</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;
}
2025-03-10 20:58:33 +01:00
.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>