diff --git a/app/globals.css b/app/globals.css index dc19cef..de460ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/app/schedule/events.ts b/app/schedule/events.ts new file mode 100644 index 0000000..c1ff69d --- /dev/null +++ b/app/schedule/events.ts @@ -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"], + }, + ], + }, +]; diff --git a/app/schedule/page.module.css b/app/schedule/page.module.css new file mode 100644 index 0000000..95746c0 --- /dev/null +++ b/app/schedule/page.module.css @@ -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; +} diff --git a/app/schedule/page.tsx b/app/schedule/page.tsx new file mode 100644 index 0000000..0b38408 --- /dev/null +++ b/app/schedule/page.tsx @@ -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
+

{props.event.name}

+

{props.event.description ?? "No description provided"}

+

Timeslots

+ +
+} + +export default function schedule() { + return
+

Schedule & Events

+

+ Study carefully, we only hold these events once a year. +

+

Schedule

+ +

Events

+ {events.map(event => )} +

Locations

+
    + {locations.map(location =>
  • +

    {location.name}

    + {location.description ?? "No description provided"} +
  • )} +
+
+} diff --git a/css.d.ts b/css.d.ts new file mode 100644 index 0000000..fff318e --- /dev/null +++ b/css.d.ts @@ -0,0 +1,8 @@ +import type * as CSS from 'csstype'; + +// typing for custom variables. +declare module 'csstype' { + interface Properties { + "--minutes"?: number, + } +} diff --git a/package.json b/package.json index 4ff4c67..0926378 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7244b94..a32729a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.json b/tsconfig.json index 96f8e1b..52ea92c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/ui/timetable.module.css b/ui/timetable.module.css new file mode 100644 index 0000000..60564d2 --- /dev/null +++ b/ui/timetable.module.css @@ -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); +} diff --git a/ui/timetable.tsx b/ui/timetable.tsx new file mode 100644 index 0000000..4f94f71 --- /dev/null +++ b/ui/timetable.tsx @@ -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>, +}; + +/** 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(iterable: Iterable) { + let index = 0; + for (const item of iterable) { + yield [index++, item] as [number, T]; + } +} + +/** Returns adjacent pairs from iterable */ +function* pairs(iterable: Iterable) { + 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(...sets: Set[]) { + 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): Generator { + 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) { + const junctions = new Map(); + 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): Generator { + const activeLocations = new Map( + locations.map(location => [location.id, new Set()]) + ); + 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, minSeparation: number): Generator { + 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 { + 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) { + type Col = { minutes?: number }; + type DayHead = { span: number, content?: string } + type HourHead = { span: number, content?: string } + type LocationCell = { span: number, slots: Set } + const columnGroups: { className?: string, cols: Col[] }[] = []; + const dayHeaders: DayHead[] = []; + const hourHeaders: HourHead[]= []; + const locationRows = new Map(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()) { + 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 =
+ Debug +

Junctions

+ {junctions.map(j =>
+ {j.ts}: {j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ")} +
)} +

Stretches

+
    + {stretches.map(st =>
  1. +

    Stretch from {st.start} to {st.end}.

    +

    Spans:

    +
      + {st.spans.map(s =>
    • + {s.start.ts} - {s.end.ts}: +
        + {[...s.locations].map(([id, slots]) =>
      • + {id}: {[...slots].map(s => s.id).join(", ")} +
      • )} +
      +
    • )} +
    +
  2. )} +
+
; + + return
+ {debug} + + + + + {columnGroups.map((group, groupIndex) => + {group.cols.map((col, index) => )} + )} + + + + {dayHeaders.map((day, index) => )} + + + + {hourHeaders.map((hour, index) => )} + + + + {locations.map(location => + + {locationRows.get(location.id)!.map((row, index) => )} + )} + +
+ {day.content} +
Location + {hour.content} +
{location.name} + {[...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ")} +
+
+}