Port application from Next.js to Nuxt
Nuxt is based on Vue.js and I find their building blocks to be much neater compared to the React based Next.js.
This commit is contained in:
parent
8c8b561f1a
commit
250ca9a1ac
45 changed files with 662 additions and 1358 deletions
34
components/EventCard.vue
Normal file
34
components/EventCard.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<section class="event">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<p>{{ event.description ?? "No description provided" }}</p>
|
||||
<h4>Timeslots</h4>
|
||||
<ul>
|
||||
<li v-for="slot in event.slots" :key="slot.id">
|
||||
{{ slot.start }} - {{ slot.end }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ScheduleEvent } from '~/shared/types/schedule';
|
||||
|
||||
defineProps<{
|
||||
event: ScheduleEvent
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event {
|
||||
background: color-mix(in oklab, var(--background), grey 20%);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.event h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.event + .event {
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
</style>
|
111
components/EventsEdit.vue
Normal file
111
components/EventsEdit.vue
Normal file
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<details>
|
||||
<summary>Admin Edit</summary>
|
||||
<h3>Create Event</h3>
|
||||
<form method="post" action="/api/create-event">
|
||||
<label>
|
||||
Id:
|
||||
<input type="text" name="id" required />
|
||||
</label>
|
||||
<label>
|
||||
Name:
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
<label>
|
||||
Description:
|
||||
<textarea name="description" />
|
||||
</label>
|
||||
<label>
|
||||
Start:
|
||||
<input type="datetime-local" name="start" required />
|
||||
</label>
|
||||
<label>
|
||||
End:
|
||||
<input type="datetime-local" name="end" required/>
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<select name="location">
|
||||
<option
|
||||
v-for="location in schedule.locations"
|
||||
:key="location.id"
|
||||
:value="location.id"
|
||||
>{{ location.name }} </option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
<h3>Edit Event</h3>
|
||||
<form method="post" action="/api/modify-event">
|
||||
<label>
|
||||
Event
|
||||
<select name="id" @change="onChange" ref="eventSelect">
|
||||
<option
|
||||
v-for="event in schedule.events"
|
||||
:key="event.id"
|
||||
:value="event.id"
|
||||
>{{ event.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Name:
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
<label>
|
||||
Description:
|
||||
<textarea name="description" />
|
||||
</label>
|
||||
<label>
|
||||
Start:
|
||||
<input type="datetime-local" name="start" required />
|
||||
</label>
|
||||
<label>
|
||||
End:
|
||||
<input type="datetime-local" name="end" required />
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<select name="location">
|
||||
<option
|
||||
v-for="location in schedule.locations"
|
||||
:key="location.id"
|
||||
:value="location.id"
|
||||
>{{ location.name }} </option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Edit</button>
|
||||
<button type="submit" formaction="/api/delete-event">Delete</button>
|
||||
</form>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const schedule = useSchedule();
|
||||
const eventSelect = useTemplateRef("eventSelect");
|
||||
|
||||
function onChange(event: any) {
|
||||
const newEvent = schedule.value.events.find(e => e.id === event.target.value)!;
|
||||
const form = event.target.form!;
|
||||
for (const element of form.elements as any) {
|
||||
if (element.name === "name") {
|
||||
element.value = newEvent.name;
|
||||
} else if (element.name === "description") {
|
||||
element.value = newEvent.description;
|
||||
} else if (element.name === "start") {
|
||||
element.value = newEvent.slots[0].start.replace("Z", "");
|
||||
} else if (element.name === "end") {
|
||||
element.value = newEvent.slots[0].end.replace("Z", "");
|
||||
} else if (element.name === "location") {
|
||||
element.value = newEvent.slots[0].locations[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onChange({ target: eventSelect.value });
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
117
components/PushNotification.vue
Normal file
117
components/PushNotification.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<section>
|
||||
Notifications are: <b>{{ subscription ? "Enabled" : "Disabled" }}</b>
|
||||
<br />
|
||||
<button
|
||||
:disabled="unsupported"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ unsupported === undefined ? "Checking for support" : null }}
|
||||
{{ unsupported === true ? "Notifications are not supported :(." : null }}
|
||||
{{ unsupported === false ? (subscription ? "Disable notifications" : "Enable notifications") : null }}
|
||||
</button>
|
||||
<details>
|
||||
<summary>Debug</summary>
|
||||
<pre><code>{{ JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set" }}</code></pre>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function notificationUnsupported() {
|
||||
return (
|
||||
!("serviceWorker" in navigator)
|
||||
|| !("PushManager" in window)
|
||||
|| !("showNotification" in ServiceWorkerRegistration.prototype)
|
||||
)
|
||||
}
|
||||
|
||||
async function registerAndSubscribe(
|
||||
vapidPublicKey: string,
|
||||
onSubscribe: (subs: PushSubscription | null ) => void,
|
||||
) {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
await subscribe(vapidPublicKey, onSubscribe);
|
||||
} catch (err) {
|
||||
console.error("Failed to register service worker:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
vapidPublicKey: string,
|
||||
onSubscribe: (subs: PushSubscription | null) => void
|
||||
) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapidPublicKey,
|
||||
});
|
||||
console.log("Got subscription object", subscription.toJSON());
|
||||
await submitSubscription(subscription);
|
||||
onSubscribe(subscription);
|
||||
} catch (err) {
|
||||
console.error("Failed to subscribe:" , err);
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(
|
||||
subscription: PushSubscription,
|
||||
onUnsubscribed: () => void,
|
||||
) {
|
||||
const body = JSON.stringify({ subscription });
|
||||
await subscription.unsubscribe();
|
||||
const res = await fetch("/api/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log("/api/unsubscribe returned", result);
|
||||
onUnsubscribed();
|
||||
}
|
||||
|
||||
async function submitSubscription(subscription: PushSubscription) {
|
||||
const res = await fetch("/api/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ subscription }),
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log("/api/subscribe returned", result);
|
||||
}
|
||||
|
||||
async function getSubscription(
|
||||
onSubscribe: (subs: PushSubscription | null) => void,
|
||||
) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
onSubscribe(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
const unsupported = ref<boolean | undefined>(undefined);
|
||||
const subscription = ref<PushSubscription | null>(null);
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
|
||||
function onClick() {
|
||||
if (!subscription.value)
|
||||
registerAndSubscribe(runtimeConfig.public.vapidPublicKey, (subs) => { subscription.value = subs })
|
||||
else
|
||||
unsubscribe(subscription.value, () => { subscription.value = null })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
unsupported.value = notificationUnsupported()
|
||||
if (unsupported.value) {
|
||||
return;
|
||||
}
|
||||
getSubscription(subs => { subscription.value = subs });
|
||||
})
|
||||
</script>
|
449
components/Timetable.vue
Normal file
449
components/Timetable.vue
Normal file
|
@ -0,0 +1,449 @@
|
|||
<template>
|
||||
<figure class="timetable">
|
||||
<details>
|
||||
<summary>Debug</summary>
|
||||
<p><b>Junctions</b></p>
|
||||
<div v-for="j in junctions" :key="j.ts">
|
||||
{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
|
||||
</div>
|
||||
<p><b>Stretches</b></p>
|
||||
<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>
|
||||
<table>
|
||||
<colgroup>
|
||||
<col class="header" />
|
||||
</colgroup>
|
||||
<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="group.className">
|
||||
<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">
|
||||
{{ day.content }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<th v-for="hour, index in hourHeaders" :key="index" :colSpan="hour.span">
|
||||
{{ hour.content }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="location in schedule.locations" :key="location.id">
|
||||
<th>{{ location.name }}</th>
|
||||
<td
|
||||
v-for="row, index in locationRows.get(location.id)"
|
||||
:key="index"
|
||||
:colSpan="row.span"
|
||||
:class='{"event": row.slots.size }'
|
||||
>
|
||||
{{ [...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ScheduleEvent, ScheduleLocation, TimeSlot } from "~/shared/types/schedule";
|
||||
|
||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||
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", slot: TimeSlot };
|
||||
|
||||
/** Point in time where multiple edges meet. */
|
||||
type Junction = { ts: string, edges: Edge[] };
|
||||
|
||||
/** Span of time between two adjacent junctions */
|
||||
type Span = {
|
||||
start: Junction;
|
||||
end: Junction,
|
||||
locations: Map<string, Set<TimeSlot>>,
|
||||
};
|
||||
|
||||
/**
|
||||
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: string,
|
||||
end: string,
|
||||
spans: Span[];
|
||||
}
|
||||
|
||||
/** Returns a tuple consisting of a running index starting from 0, and the item of the iterable */
|
||||
function* enumerate<T>(iterable: Iterable<T>) {
|
||||
let index = 0;
|
||||
for (const item of iterable) {
|
||||
yield [index++, item] as [number, T];
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns adjacent pairs from iterable */
|
||||
function* pairs<T>(iterable: Iterable<T>) {
|
||||
let first;
|
||||
let second;
|
||||
for (const [index, item] of enumerate(iterable)) {
|
||||
[first, second] = [second, item];
|
||||
if (index >= 1) {
|
||||
yield [first, second] as [T, T];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns true if all sets are equal
|
||||
@param sets set to compare
|
||||
@returns true if all sets are the same size and have the same elements
|
||||
*/
|
||||
function setEquals<T>(...sets: Set<T>[]) {
|
||||
if (sets.length < 2) {
|
||||
throw TypeError("At least two sets must be passed to setEquals");
|
||||
}
|
||||
const ref = sets[0];
|
||||
const rest = sets.slice(1);
|
||||
if (rest.some(set => set.size !== ref.size)) {
|
||||
return false;
|
||||
}
|
||||
for (const set of rest) {
|
||||
for (const el of set) {
|
||||
if (!ref.has(el)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isoStringFromTs(ts: number) {
|
||||
return new Date(ts).toISOString().replace(":00.000Z", "Z");
|
||||
}
|
||||
|
||||
function* edgesFromEvents(events: Iterable<ScheduleEvent>): Generator<Edge> {
|
||||
for (const event of events) {
|
||||
for (const slot of event.slots) {
|
||||
if (slot.start > slot.end) {
|
||||
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
||||
}
|
||||
yield { type: "start", slot }
|
||||
yield { type: "end", slot }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function junctionsFromEdges(edges: Iterable<Edge>) {
|
||||
const junctions = new Map<string, Junction>();
|
||||
for (const edge of edges) {
|
||||
const ts = edge.slot[edge.type];
|
||||
const junction = junctions.get(ts);
|
||||
if (junction) {
|
||||
junction.edges.push(edge);
|
||||
} else {
|
||||
junctions.set(ts, { ts, edges: [edge] });
|
||||
}
|
||||
}
|
||||
const keys = [...junctions.keys()].sort();
|
||||
return keys.map(key => junctions.get(key)!);
|
||||
}
|
||||
|
||||
function* spansFromJunctions(
|
||||
junctions: Iterable<Junction>, locations: ScheduleLocation[]
|
||||
): Generator<Span> {
|
||||
const activeLocations = new Map(
|
||||
locations.map(location => [location.id, new Set<TimeSlot>()])
|
||||
);
|
||||
for (const [start, end] of pairs(junctions)) {
|
||||
for (const edge of start.edges) {
|
||||
if (edge.type === "start") {
|
||||
for (const location of edge.slot.locations) {
|
||||
activeLocations.get(location)!.add(edge.slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield {
|
||||
start,
|
||||
end,
|
||||
locations: new Map(
|
||||
[...activeLocations]
|
||||
.filter(([_, slots]) => slots.size)
|
||||
.map(([location, slots]) => [location, new Set(slots)])
|
||||
),
|
||||
}
|
||||
for (const edge of end.edges) {
|
||||
if (edge.type === "end") {
|
||||
for (const location of edge.slot.locations) {
|
||||
activeLocations.get(location)!.delete(edge.slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStretch(spans: Span[]): Stretch {
|
||||
let startTs = Date.parse(spans[0].start.ts) - oneHourMs;
|
||||
let endTs = Date.parse(spans[spans.length - 1].end.ts) + oneHourMs;
|
||||
// Extend stretch to nearest whole hours
|
||||
startTs = Math.floor(startTs / oneHourMs) * oneHourMs;
|
||||
endTs = Math.ceil(endTs / oneHourMs) * oneHourMs;
|
||||
// Convert back to ISO date string
|
||||
let start = isoStringFromTs(startTs);
|
||||
let end = isoStringFromTs(endTs);
|
||||
return {
|
||||
spans: [
|
||||
{
|
||||
start: { ts: start, edges: [] },
|
||||
end: spans[0].start,
|
||||
locations: new Map(),
|
||||
},
|
||||
...spans,
|
||||
{
|
||||
start: spans[spans.length - 1].end,
|
||||
end: { ts: end, edges: [] },
|
||||
locations: new Map(),
|
||||
},
|
||||
],
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
&& Date.parse(span.end.ts) - Date.parse(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): Generator<Span> {
|
||||
const startHour = Date.parse(span.start.ts) / oneHourMs;
|
||||
const endHour = Date.parse(span.end.ts) / oneHourMs;
|
||||
let currentStart = startHour;
|
||||
let currentEnd = Math.min(Math.floor(startHour + 1), endHour);
|
||||
if (currentEnd === endHour) {
|
||||
yield span;
|
||||
return;
|
||||
}
|
||||
|
||||
yield {
|
||||
start: span.start,
|
||||
end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
|
||||
locations: span.locations,
|
||||
}
|
||||
|
||||
currentStart = currentEnd;
|
||||
while (++currentEnd < endHour) {
|
||||
yield {
|
||||
start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
|
||||
end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
|
||||
locations: span.locations,
|
||||
}
|
||||
currentStart += 1;
|
||||
}
|
||||
|
||||
yield {
|
||||
start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
|
||||
end: span.end,
|
||||
locations: span.locations,
|
||||
}
|
||||
}
|
||||
|
||||
function tableElementsFromStretches(
|
||||
stretches: Iterable<Stretch>, locations: ScheduleLocation[]
|
||||
) {
|
||||
type Col = { minutes?: number };
|
||||
type DayHead = { span: number, content?: string }
|
||||
type HourHead = { span: number, content?: string }
|
||||
type LocationCell = { span: number, slots: Set<TimeSlot> }
|
||||
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
||||
const dayHeaders: DayHead[] = [];
|
||||
const hourHeaders: HourHead[]= [];
|
||||
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
|
||||
|
||||
function startColumnGroup(className?: string) {
|
||||
columnGroups.push({ className, cols: []})
|
||||
}
|
||||
function startDay(content?: string) {
|
||||
dayHeaders.push({ span: 0, content })
|
||||
}
|
||||
function startHour(content?: string) {
|
||||
hourHeaders.push({ span: 0, content })
|
||||
}
|
||||
function startLocation(id: string, slots = new Set<TimeSlot>()) {
|
||||
locationRows.get(id)!.push({ span: 0, slots });
|
||||
}
|
||||
function pushColumn(minutes?: number) {
|
||||
columnGroups[columnGroups.length - 1].cols.push({ minutes })
|
||||
dayHeaders[dayHeaders.length - 1].span += 1;
|
||||
hourHeaders[hourHeaders.length - 1].span += 1;
|
||||
for(const location of locations) {
|
||||
const row = locationRows.get(location.id)!;
|
||||
row[row.length - 1].span += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const stretch of stretches) {
|
||||
if (first) {
|
||||
first = false;
|
||||
startColumnGroup();
|
||||
startDay(stretch.start.slice(0, 10));
|
||||
startHour(stretch.start.slice(11, 16));
|
||||
for(const location of locations) {
|
||||
startLocation(location.id);
|
||||
}
|
||||
} else {
|
||||
startColumnGroup("break");
|
||||
const dayName = stretch.start.slice(0, 10)
|
||||
const sameDay = dayName === dayHeaders[dayHeaders.length - 1].content;
|
||||
if (!sameDay)
|
||||
startDay();
|
||||
startHour("break");
|
||||
for(const location of locations) {
|
||||
startLocation(location.id);
|
||||
}
|
||||
pushColumn();
|
||||
|
||||
startColumnGroup();
|
||||
if (!sameDay)
|
||||
startDay(dayName);
|
||||
startHour(stretch.start.slice(11, 16));
|
||||
for(const location of locations) {
|
||||
startLocation(location.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const span of stretch.spans) {
|
||||
for (const cutSpan of cutSpansByHours(span)) {
|
||||
const startTs = Date.parse(cutSpan.start.ts);
|
||||
const endTs = Date.parse(cutSpan.end.ts);
|
||||
const durationMs = endTs - startTs;
|
||||
|
||||
for (const location of locations) {
|
||||
const rows = locationRows.get(location.id)!;
|
||||
const row = rows[rows.length - 1];
|
||||
const slots = cutSpan.locations.get(location.id) ?? new Set();
|
||||
if (!setEquals(slots, row.slots)) {
|
||||
startLocation(location.id, slots);
|
||||
}
|
||||
}
|
||||
|
||||
pushColumn(durationMs / oneMinMs);
|
||||
if (endTs % oneDayMs === 0) {
|
||||
startDay(cutSpan.end.ts.slice(0, 10));
|
||||
}
|
||||
if (endTs % oneHourMs === 0) {
|
||||
startHour(cutSpan.end.ts.slice(11, 16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
columnGroups,
|
||||
dayHeaders: dayHeaders.filter(day => day.span),
|
||||
hourHeaders: hourHeaders.filter(hour => hour.span),
|
||||
locationRows: new Map([...locationRows].map(([id, cells]) => [id, cells.filter(cell => cell.span)])),
|
||||
};
|
||||
}
|
||||
|
||||
const schedule = useSchedule();
|
||||
const junctions = computed(() => junctionsFromEdges(edgesFromEvents(schedule.value.events)));
|
||||
const stretches = computed(() => [
|
||||
...stretchesFromSpans(spansFromJunctions(junctions.value, schedule.value.locations), oneHourMs * 5)
|
||||
])
|
||||
const elements = computed(() => tableElementsFromStretches(stretches.value, schedule.value.locations));
|
||||
const columnGroups = computed(() => elements.value.columnGroups);
|
||||
const dayHeaders = computed(() => elements.value.dayHeaders);
|
||||
const hourHeaders = computed(() => elements.value.hourHeaders);
|
||||
const locationRows = computed(() => elements.value.locationRows);
|
||||
const eventBySlotId = computed(() => new Map(
|
||||
schedule.value.events.flatMap(
|
||||
event => event.slots.map(slot => [slot.id, event])
|
||||
)
|
||||
));
|
||||
</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 :is(td, th) {
|
||||
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);
|
||||
}
|
||||
|
||||
.break {
|
||||
background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
|
||||
}
|
||||
|
||||
.event {
|
||||
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
|
||||
}
|
||||
</style>
|
BIN
components/timetable-terminology.png
Normal file
BIN
components/timetable-terminology.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.1 KiB |
Loading…
Add table
Add a link
Reference in a new issue