Minimally functional schedule rendering
Add timetable and event listing based on transforming a simple input data structure.
This commit is contained in:
parent
7d822e4934
commit
484c27ece2
10 changed files with 610 additions and 7 deletions
|
@ -12,8 +12,6 @@
|
|||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
|
@ -30,6 +28,10 @@ body {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
|
109
app/schedule/events.ts
Normal file
109
app/schedule/events.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
export interface ScheduleEvent {
|
||||
name: string,
|
||||
id: string,
|
||||
host?: string,
|
||||
cancelled?: boolean,
|
||||
description?: string,
|
||||
slots: TimeSlot[],
|
||||
}
|
||||
|
||||
export interface ScheduleLocation {
|
||||
name: string,
|
||||
id: string,
|
||||
description?: string,
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string,
|
||||
start: string,
|
||||
end: string,
|
||||
locations: string[],
|
||||
}
|
||||
|
||||
export const locations: ScheduleLocation[] = [
|
||||
{
|
||||
name: "House",
|
||||
id: "house",
|
||||
description: "Blue building east of the camping",
|
||||
},
|
||||
{
|
||||
name: "Common House",
|
||||
id: "common-house",
|
||||
description: "That big red building in the middle",
|
||||
},
|
||||
{
|
||||
name: "Info Desk",
|
||||
id: "info-desk",
|
||||
},
|
||||
{
|
||||
name: "Camping Fireplace",
|
||||
id: "camping-fireplace",
|
||||
},
|
||||
];
|
||||
|
||||
export const events: ScheduleEvent[] = [
|
||||
{
|
||||
name: "Arcade",
|
||||
id: "arcade",
|
||||
description: "Play retro games!",
|
||||
slots: [
|
||||
{
|
||||
id: "arcade-1",
|
||||
start: "2025-07-18T10:00Z",
|
||||
end: "2025-07-19T01:30Z",
|
||||
locations: ["house"],
|
||||
},
|
||||
{
|
||||
id: "arcade-2",
|
||||
start: "2025-07-19T10:00Z",
|
||||
end: "2025-07-20T01:00Z",
|
||||
locations: ["house"],
|
||||
},
|
||||
{
|
||||
id: "arcade-3",
|
||||
start: "2025-07-20T10:00Z",
|
||||
end: "2025-07-20T18:00Z",
|
||||
locations: ["house"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Bonfire Stories",
|
||||
description: "Share your stories as we sit cosily around the bonfire.",
|
||||
id: "bonfire",
|
||||
slots: [
|
||||
{
|
||||
id: "bonfire-1",
|
||||
start: "2025-07-19T20:00Z",
|
||||
end: "2025-07-20T01:00Z",
|
||||
locations: ["camping-fireplace"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Fursuit Games",
|
||||
description: "Playful time for the suiters.",
|
||||
id: "fursuit-games",
|
||||
slots: [
|
||||
{
|
||||
id: "fursuit-games-1",
|
||||
start: "2025-07-19T19:00Z",
|
||||
end: "2025-07-19T20:00Z",
|
||||
locations: ["common-house"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Late Stragglers",
|
||||
description: "Wait a minute, why are you still here?.",
|
||||
id: "too-late",
|
||||
slots: [
|
||||
{
|
||||
id: "too-late-1",
|
||||
start: "2025-07-22T20:00Z",
|
||||
end: "2025-07-23T01:00Z",
|
||||
locations: ["camping-fireplace"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
18
app/schedule/page.module.css
Normal file
18
app/schedule/page.module.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.schedule {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
.schedule :is(h1, h2, h3, h4) {
|
||||
margin-block: 0.75em 0.25em;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
36
app/schedule/page.tsx
Normal file
36
app/schedule/page.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import Timetable from "@/ui/timetable"
|
||||
import styles from "./page.module.css"
|
||||
import { ScheduleEvent, events, locations } from "./events"
|
||||
|
||||
function EventInfo(props: { event: ScheduleEvent }) {
|
||||
return <section className={styles.event}>
|
||||
<h3>{props.event.name}</h3>
|
||||
<p>{props.event.description ?? "No description provided"}</p>
|
||||
<h4>Timeslots</h4>
|
||||
<ul>
|
||||
{props.event.slots.map(slot => <li key={slot.id}>
|
||||
{slot.start} - {slot.end}
|
||||
</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
export default function schedule() {
|
||||
return <main className={styles.schedule}>
|
||||
<h1>Schedule & Events</h1>
|
||||
<p>
|
||||
Study carefully, we only hold these events once a year.
|
||||
</p>
|
||||
<h2>Schedule</h2>
|
||||
<Timetable events={events} />
|
||||
<h2>Events</h2>
|
||||
{events.map(event => <EventInfo event={event} key={event.id}/>)}
|
||||
<h2>Locations</h2>
|
||||
<ul>
|
||||
{locations.map(location => <li key={location.id}>
|
||||
<h3>{location.name}</h3>
|
||||
{location.description ?? "No description provided"}
|
||||
</li>)}
|
||||
</ul>
|
||||
</main>
|
||||
}
|
8
css.d.ts
vendored
Normal file
8
css.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type * as CSS from 'csstype';
|
||||
|
||||
// typing for custom variables.
|
||||
declare module 'csstype' {
|
||||
interface Properties {
|
||||
"--minutes"?: number,
|
||||
}
|
||||
}
|
|
@ -9,15 +9,16 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.1.7"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19"
|
||||
"@types/react-dom": "^19",
|
||||
"csstype": "^3.1.3",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.12+sha1.a2f983fbf8f2531dc85db2a5d7f398063d51a6f3"
|
||||
}
|
||||
|
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
@ -25,6 +25,9 @@ devDependencies:
|
|||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.0.0
|
||||
csstype:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.0.2
|
||||
|
|
|
@ -22,6 +22,6 @@
|
|||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "css.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
42
ui/timetable.module.css
Normal file
42
ui/timetable.module.css
Normal file
|
@ -0,0 +1,42 @@
|
|||
.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: rgb(78, 78, 143);
|
||||
}
|
384
ui/timetable.tsx
Normal file
384
ui/timetable.tsx
Normal file
|
@ -0,0 +1,384 @@
|
|||
import { ScheduleEvent, TimeSlot, locations } from "@/app/schedule/events";
|
||||
import styles from "./timetable.module.css";
|
||||
|
||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||
const oneHourMs = 60 * 60 * 1000;
|
||||
const oneMinMs = 60 * 1000;
|
||||
|
||||
/** 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 */
|
||||
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
|
||||
*/
|
||||
export 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>): 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>) {
|
||||
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(styles.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)])),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Timetable(props: { events: ScheduleEvent[] }) {
|
||||
const junctions = junctionsFromEdges(edgesFromEvents(props.events));
|
||||
const stretches = [...stretchesFromSpans(spansFromJunctions(junctions), oneHourMs * 5)];
|
||||
const {
|
||||
columnGroups,
|
||||
dayHeaders,
|
||||
hourHeaders,
|
||||
locationRows,
|
||||
} = tableElementsFromStretches(stretches);
|
||||
const eventBySlotId = new Map(
|
||||
props.events.flatMap(
|
||||
event => event.slots.map(slot => [slot.id, event])
|
||||
)
|
||||
);
|
||||
|
||||
const debug = <details>
|
||||
<summary>Debug</summary>
|
||||
<p><b>Junctions</b></p>
|
||||
{junctions.map(j => <div key={j.ts}>
|
||||
{j.ts}: {j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ")}
|
||||
</div>)}
|
||||
<p><b>Stretches</b></p>
|
||||
<ol>
|
||||
{stretches.map(st => <li key={st.start}>
|
||||
<p>Stretch from {st.start} to {st.end}.</p>
|
||||
<p>Spans:</p>
|
||||
<ul>
|
||||
{st.spans.map(s => <li key={s.start.ts}>
|
||||
{s.start.ts} - {s.end.ts}:
|
||||
<ul>
|
||||
{[...s.locations].map(([id, slots]) => <li key={id}>
|
||||
{id}: {[...slots].map(s => s.id).join(", ")}
|
||||
</li>)}
|
||||
</ul>
|
||||
</li>)}
|
||||
</ul>
|
||||
</li>)}
|
||||
</ol>
|
||||
</details>;
|
||||
|
||||
return <figure className={styles.timetable}>
|
||||
{debug}
|
||||
<table>
|
||||
<colgroup>
|
||||
<col className={styles.header} />
|
||||
</colgroup>
|
||||
{columnGroups.map((group, groupIndex) => <colgroup key={groupIndex} className={group.className}>
|
||||
{group.cols.map((col, index) => <col key={index} style={{ "--minutes": col.minutes}} />)}
|
||||
</colgroup>)}
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{dayHeaders.map((day, index) => <th key={index} colSpan={day.span}>
|
||||
{day.content}
|
||||
</th>)}
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
{hourHeaders.map((hour, index) => <th key={index} colSpan={hour.span}>
|
||||
{hour.content}
|
||||
</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{locations.map(location => <tr key={location.id}>
|
||||
<th>{location.name}</th>
|
||||
{locationRows.get(location.id)!.map((row, index) => <td key={index} colSpan={row.span}>
|
||||
{[...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ")}
|
||||
</td>)}
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue