Array.sort() sorts by UTF-16 code points even when the items in the array are numbers. Fix the schedule breaking when events cross different powers of 10 in Unix time which caused the junctions to no longer be sorted by the numeric value of their Unix time.
772 lines
22 KiB
Vue
772 lines
22 KiB
Vue
<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="location in schedule.locations.values()" :key="location.id">
|
|
<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"
|
|
>
|
|
{{ 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>
|
|
<template v-if="schedule.roles.size">
|
|
<tr>
|
|
<th>Shifts</th>
|
|
<td :colSpan="totalColumns"></td>
|
|
</tr>
|
|
<template v-for="role in schedule.roles.values()" :key="role.id">
|
|
<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"
|
|
>
|
|
{{ 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>
|
|
</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, Set<ClientScheduleEventSlot>>,
|
|
roles: Map<number, 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(
|
|
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
|
|
);
|
|
const activeRoles = new Map(
|
|
[...roles.keys()].map(id => [id, new Set<ClientScheduleShiftSlot>()])
|
|
);
|
|
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)
|
|
}
|
|
} 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)
|
|
}
|
|
} 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, 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, 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, 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, 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 location of locations.values()) {
|
|
const group = locationGroups.get(location.id)!;
|
|
for (const row of group) {
|
|
row[row.length - 1].span += 1;
|
|
}
|
|
}
|
|
for(const role of roles.values()) {
|
|
const group = roleGroups.get(role.id)!;
|
|
for (const row of group) {
|
|
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 location of locations.values()) {
|
|
startLocation(location.id, false);
|
|
}
|
|
for (const role of roles.values()) {
|
|
startRole(role.id, 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 location of locations.values()) {
|
|
startLocation(location.id, true);
|
|
}
|
|
for(const role of roles.values()) {
|
|
startRole(role.id, 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 location of locations.values()) {
|
|
startLocation(location.id, false);
|
|
}
|
|
for(const role of roles.values()) {
|
|
startRole(role.id, 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 location of locations.values()) {
|
|
const slots = cutSpan.locations.get(location.id) ?? new Set();
|
|
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 slots = cutSpan.roles.get(role.id) ?? new Set();
|
|
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);
|
|
}
|
|
}
|
|
|
|
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>
|