@@ -4,10 +4,6 @@ import { format, parseISO, compareAsc } from "date-fns"
44import { ReactElement , useMemo , useState } from "react"
55
66import { ScheduleSession } from "@/app/conf/_api/sched-types"
7- import {
8- ConcurrentSessions ,
9- ScheduleSessionsByDay ,
10- } from "@/app/conf/_components/schedule/session-list"
117
128import { ScheduleSessionCard } from "./schedule-session-card"
139import {
@@ -25,6 +21,17 @@ export interface FiltersConfig
2521 Record < keyof ScheduleSession /* key */ , string /* label */ >
2622 > { }
2723
24+ interface ScheduleBlock {
25+ start : string
26+ end : Date
27+ sessions : ScheduleSession [ ]
28+ isMergeable : boolean
29+ }
30+
31+ interface ScheduleBlocksByDay {
32+ [ date : string ] : ScheduleBlock [ ]
33+ }
34+
2835function getSessionsByDay (
2936 scheduleData : ScheduleSession [ ] ,
3037 filterStates : FilterStates ,
@@ -34,7 +41,7 @@ function getSessionsByDay(
3441 )
3542
3643 const states = Object . entries < FilterStates [ keyof FilterStates ] > ( filterStates )
37- const concurrentSessions : ConcurrentSessions = { }
44+ const filteredSessions : ScheduleSession [ ] = [ ]
3845
3946 filteredSortedSchedule . forEach ( session => {
4047 for ( const [ property , filterState ] of states ) {
@@ -49,21 +56,52 @@ function getSessionsByDay(
4956 }
5057 }
5158
59+ filteredSessions . push ( session )
60+ } )
61+
62+ const concurrentSessions : Record < string , ScheduleSession [ ] > = { }
63+ filteredSessions . forEach ( session => {
5264 ; ( concurrentSessions [ session . event_start ] ||= [ ] ) . push ( session )
5365 } )
5466
55- const sessionsByDay : ScheduleSessionsByDay = { }
67+ const sessionsByDay : ScheduleBlocksByDay = { }
5668 Object . entries ( concurrentSessions ) . forEach ( ( [ date , sessions ] ) => {
5769 const day = date . split ( " " ) [ 0 ]
58- if ( ! sessionsByDay [ day ] ) {
59- sessionsByDay [ day ] = { }
60- }
61- sessionsByDay [ day ] = {
62- ... sessionsByDay [ day ] ,
63- [ date ] : sessions . sort ( ( a , b ) =>
64- ( a ?. venue ?? "" ) . localeCompare ( b ?. venue ?? "" ) ,
70+ const dayBlocks = ( sessionsByDay [ day ] ||= [ ] )
71+ const sortedSessions = sessions . sort ( ( a , b ) =>
72+ ( a ?. venue ?? "" ) . localeCompare ( b ?. venue ?? "" ) ,
73+ )
74+ const blockEnd = new Date (
75+ Math . max (
76+ ... sortedSessions . map ( session => new Date ( session . event_end ) . getTime ( ) ) ,
6577 ) ,
78+ )
79+ const isMergeable = sortedSessions . every ( session => {
80+ const durationMs =
81+ new Date ( session . event_end ) . getTime ( ) -
82+ new Date ( session . event_start ) . getTime ( )
83+ return durationMs < 55 * 60 * 1000
84+ } )
85+ const previousBlock = dayBlocks . at ( - 1 )
86+
87+ if (
88+ isMergeable &&
89+ previousBlock ?. isMergeable &&
90+ new Date ( date ) . getTime ( ) < previousBlock . end . getTime ( )
91+ ) {
92+ previousBlock . sessions . push ( ...sortedSessions )
93+ if ( blockEnd . getTime ( ) > previousBlock . end . getTime ( ) ) {
94+ previousBlock . end = blockEnd
95+ }
96+ return
6697 }
98+
99+ dayBlocks . push ( {
100+ start : date ,
101+ end : blockEnd ,
102+ sessions : sortedSessions ,
103+ isMergeable,
104+ } )
67105 } )
68106
69107 return {
@@ -149,7 +187,8 @@ export function ScheduleList({
149187 )
150188 }
151189 // if the first day has less than 3 sessions, it's probably a "day zero" with extra events or workshops
152- const firstDayIsDayZero = Object . keys ( firstDay ) . length < 3
190+ const firstDayIsDayZero = firstDay . length < 3
191+ const sortedDays = Object . entries ( filteredSessions )
153192 const startIndex = firstDayIsDayZero ? 0 : 1
154193
155194 return (
@@ -197,7 +236,7 @@ export function ScheduleList({
197236 ) : (
198237 < >
199238 < div className = "mb-4 flex space-x-4" >
200- { Object . keys ( filteredSessions ) . map ( ( date , index ) => (
239+ { sortedDays . map ( ( [ date ] , index ) => (
201240 < a
202241 href = { `#day-${ index + startIndex } ` }
203242 key = { date }
@@ -207,126 +246,146 @@ export function ScheduleList({
207246 </ a >
208247 ) ) }
209248 </ div >
210- { Object . entries ( filteredSessions ) . map (
211- ( [ date , concurrentSessionsGroup ] , index ) => (
212- < div
213- key = { date }
214- className = "typography-body-sm bg-neu-200 pb-px dark:bg-neu-50"
249+ { sortedDays . map ( ( [ date , scheduleBlocks ] , index ) => (
250+ < div
251+ key = { date }
252+ className = "typography-body-sm bg-neu-200 pb-px dark:bg-neu-50"
253+ >
254+ < h3
255+ className = "bg-neu-50 py-4 dark:bg-neu-0"
256+ id = { `day-${ index + startIndex } ` }
215257 >
216- < h3
217- className = "bg-neu-50 py-4 dark:bg-neu-0"
218- id = { `day-${ index + startIndex } ` }
219- >
220- { format ( parseISO ( date ) , "EEEE, MMMM d" ) }
221- </ h3 >
222- { Object . entries ( concurrentSessionsGroup ) . map (
223- ( [ sessionDate , sessions ] , i , blocks ) => {
224- const blockEnd = new Date (
225- Math . max (
226- ...sessions . map ( session =>
227- new Date ( session . event_end ) . getTime ( ) ,
258+ { format ( parseISO ( date ) , "EEEE, MMMM d" ) }
259+ </ h3 >
260+ { scheduleBlocks . map ( ( block , i , blocks ) => {
261+ const sessionsByVenue = Object . entries (
262+ block . sessions . reduce < Record < string , ScheduleSession [ ] > > (
263+ ( groupedSessions , session ) => {
264+ const venue = session . venue || ""
265+ ; ( groupedSessions [ venue ] ||= [ ] ) . push ( session )
266+ return groupedSessions
267+ } ,
268+ { } ,
269+ ) ,
270+ )
271+ . sort ( ( [ venueA ] , [ venueB ] ) => venueA . localeCompare ( venueB ) )
272+ . map (
273+ ( [ venue , venueSessions ] ) =>
274+ [
275+ venue ,
276+ venueSessions . sort (
277+ ( a , b ) =>
278+ compareAsc (
279+ new Date ( a . event_start ) ,
280+ new Date ( b . event_start ) ,
281+ ) ||
282+ compareAsc (
283+ new Date ( a . event_end ) ,
284+ new Date ( b . event_end ) ,
285+ ) ,
228286 ) ,
229- ) ,
230- )
287+ ] as const ,
288+ )
231289
232- const nextBlock = blocks [ i + 1 ]
233- const nextBlockStart = nextBlock ?. [ 0 ]
234- ? new Date ( nextBlock [ 0 ] )
235- : undefined
290+ const nextBlockStart = blocks [ i + 1 ]
291+ ? new Date ( blocks [ i + 1 ] . start )
292+ : undefined
236293
237- const isBreak =
238- sessions [ 0 ] ?. event_type
239- ?. toLowerCase ( )
240- . includes ( "break" ) ||
241- blocks [ i + 1 ] ?. [ 1 ] ?. [ 0 ] ?. event_type
242- ?. toLowerCase ( )
243- . includes ( "break" )
244- const hasDashedBorder =
245- blockEnd &&
246- blockEnd . getTime ( ) === nextBlockStart ?. getTime ( ) &&
247- ! isBreak
294+ const isBreak =
295+ block . sessions . some ( session =>
296+ session . event_type ?. toLowerCase ( ) . includes ( "break" ) ,
297+ ) ||
298+ blocks [ i + 1 ] ?. sessions . some ( session =>
299+ session . event_type ?. toLowerCase ( ) . includes ( "break" ) ,
300+ )
301+ const hasDashedBorder =
302+ block . end . getTime ( ) === nextBlockStart ?. getTime ( ) && ! isBreak
248303
249- const endTimesDiffer = sessions . some (
250- session =>
251- new Date ( session . event_end ) . getTime ( ) !==
252- blockEnd . getTime ( ) ,
253- )
304+ const endTimesDiffer = block . sessions . some (
305+ session =>
306+ new Date ( session . event_end ) . getTime ( ) !==
307+ block . end . getTime ( ) ,
308+ )
254309
255- let timeMarker = getTimeMarker ( sessionDate , blockEnd )
256- // if end times differ and blockEnd is far from start, we treat this as a long event, like "solutions showcase"
257- if (
258- endTimesDiffer &&
259- blockEnd . getTime ( ) - new Date ( sessionDate ) . getTime ( ) >
260- 1000 * 60 * 60 * 2
261- ) {
262- timeMarker = null
263- }
310+ let timeMarker = getTimeMarker ( block . start , block . end )
311+ // if end times differ and blockEnd is far from start, we treat this as a long event, like "solutions showcase"
312+ if (
313+ endTimesDiffer &&
314+ block . end . getTime ( ) - new Date ( block . start ) . getTime ( ) >
315+ 1000 * 60 * 60 * 2
316+ ) {
317+ timeMarker = null
318+ }
264319
265- return (
266- < div
267- key = { `concurrent sessions on ${ sessionDate } ` }
268- className = "relative lg:mt-px [&_div:has(a:hover)]:z-[1]"
269- >
270- < div className = "mr-px flex flex-col max-lg:ml-px lg:flex-row" >
271- < div className = "relative border-neu-50 bg-neu-50 dark:bg-neu-0 max-lg:-mx-px max-lg:my-px max-lg:border-x lg:mr-px" >
272- < span className = "typography-body-sm mt-3 inline-block w-28 whitespace-nowrap pb-0.5 pl-4 lg:mr-6 lg:pb-4 lg:pl-0" >
273- { formatBlockTime (
274- sessionDate ,
275- endTimesDiffer ? undefined : blockEnd ,
276- ) }
277- </ span >
278- </ div >
279- < div className = "relative flex w-full flex-col items-end gap-px lg:flex-row lg:items-start" >
280- { sessions . map ( session => (
281- < ScheduleSessionCard
282- key = { session . id }
283- session = { session }
284- year = { year }
285- eventsColors = { eventsColors }
286- blockEnd = { blockEnd }
287- durationVisible = { endTimesDiffer }
288- />
289- ) ) }
290- </ div >
291- </ div >
292- { timeMarker && (
320+ return (
321+ < div
322+ key = { `schedule block on ${ block . start } ` }
323+ className = "relative lg:mt-px [&_div:has(a:hover)]:z-[1]"
324+ >
325+ < div className = "mr-px flex flex-col max-lg:ml-px lg:flex-row" >
326+ < div className = "relative border-neu-50 bg-neu-50 dark:bg-neu-0 max-lg:-mx-px max-lg:my-px max-lg:border-x lg:mr-px" >
327+ < span className = "typography-body-sm mt-3 inline-block w-28 whitespace-nowrap pb-0.5 pl-4 lg:mr-6 lg:pb-4 lg:pl-0" >
328+ { formatBlockTime ( block . start , block . end ) }
329+ </ span >
330+ </ div >
331+ < div className = "relative flex w-full flex-col items-end gap-px lg:flex-row lg:items-stretch" >
332+ { sessionsByVenue . map ( ( [ venue , venueSessions ] ) => (
293333 < div
294- id = "current-time-marker"
295- className = "typography-body-xs pointer-events-none absolute -right-1 z-10 -translate-y-full font-mono tabular-nums text-pri-base before:absolute before:inset-x-0 before:bottom-0 before:border-b before:border-pri-base before:opacity-80 after:absolute after:bottom-0 after:left-[-100vw] after:w-screen after:border-t after:border-pri-base after:opacity-20 dark:text-pri-light dark:before:border-pri-light dark:after:border-pri-light max-xl:bg-neu-0 xl:translate-x-full"
296- style = { {
297- top : `${ timeMarker . positionPercentage } %` ,
298- } }
334+ key = { venue || venueSessions [ 0 ] ?. id }
335+ className = "flex w-full flex-col gap-px lg:w-0 lg:flex-1 lg:self-stretch"
299336 >
300- < span className = "max-2xl:hidden" > now: </ span >
301- { timeMarker . currentTime }
337+ { venueSessions . map ( ( session , sessionIndex ) => (
338+ < div key = { session . id } className = "min-h-0 flex-1" >
339+ < ScheduleSessionCard
340+ session = { session }
341+ year = { year }
342+ eventsColors = { eventsColors }
343+ blockEnd = { block . end }
344+ durationVisible = { endTimesDiffer }
345+ startTimeVisible = { sessionIndex > 0 }
346+ />
347+ </ div >
348+ ) ) }
302349 </ div >
303- ) }
304- { hasDashedBorder && (
305- < svg
306- className = "absolute -bottom-px left-0 h-px w-full text-neu-50"
307- viewBox = "0 0 100 1"
308- preserveAspectRatio = "none"
309- >
310- < line
311- x1 = "0"
312- y1 = "0.5"
313- x2 = "100"
314- y2 = "0.5"
315- stroke = "currentColor"
316- strokeWidth = "1"
317- strokeDasharray = "4,4"
318- strokeDashoffset = "4"
319- vectorEffect = "non-scaling-stroke"
320- />
321- </ svg >
322- ) }
350+ ) ) }
351+ </ div >
352+ </ div >
353+ { timeMarker && (
354+ < div
355+ id = "current-time-marker"
356+ className = "typography-body-xs pointer-events-none absolute -right-1 z-10 -translate-y-full font-mono tabular-nums text-pri-base before:absolute before:inset-x-0 before:bottom-0 before:border-b before:border-pri-base before:opacity-80 after:absolute after:bottom-0 after:left-[-100vw] after:w-screen after:border-t after:border-pri-base after:opacity-20 dark:text-pri-light dark:before:border-pri-light dark:after:border-pri-light max-xl:bg-neu-0 xl:translate-x-full"
357+ style = { {
358+ top : `${ timeMarker . positionPercentage } %` ,
359+ } }
360+ >
361+ < span className = "max-2xl:hidden" > now: </ span >
362+ { timeMarker . currentTime }
323363 </ div >
324- )
325- } ,
326- ) }
327- </ div >
328- ) ,
329- ) }
364+ ) }
365+ { hasDashedBorder && (
366+ < svg
367+ className = "absolute -bottom-px left-0 h-px w-full text-neu-50"
368+ viewBox = "0 0 100 1"
369+ preserveAspectRatio = "none"
370+ >
371+ < line
372+ x1 = "0"
373+ y1 = "0.5"
374+ x2 = "100"
375+ y2 = "0.5"
376+ stroke = "currentColor"
377+ strokeWidth = "1"
378+ strokeDasharray = "4,4"
379+ strokeDashoffset = "4"
380+ vectorEffect = "non-scaling-stroke"
381+ />
382+ </ svg >
383+ ) }
384+ </ div >
385+ )
386+ } ) }
387+ </ div >
388+ ) ) }
330389 </ >
331390 ) }
332391 </ >
0 commit comments