Skip to content

Commit fcae930

Browse files
CopilotStarefossen
andauthored
Auto-collapse past schedule days (#343)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Starefossen <968267+Starefossen@users.noreply.github.com> Co-authored-by: Hans Kristian Flaatten <hans@flaatten.org> Co-authored-by: Hans Kristian Flaatten <hans.kristian.flaatten@nav.no>
1 parent 2b67846 commit fcae930

4 files changed

Lines changed: 260 additions & 23 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { isScheduleInPast, isScheduleToday } from '@/lib/program/time-utils'
2+
3+
describe('program/time-utils.ts', () => {
4+
describe('isScheduleInPast', () => {
5+
it('should return true for dates in the past', () => {
6+
const currentTime = new Date('2025-10-28T12:00:00')
7+
const scheduleDate = '2025-10-27'
8+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(true)
9+
})
10+
11+
it('should return false for today', () => {
12+
const currentTime = new Date('2025-10-27T12:00:00')
13+
const scheduleDate = '2025-10-27'
14+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
15+
})
16+
17+
it('should return false for dates in the future', () => {
18+
const currentTime = new Date('2025-10-27T12:00:00')
19+
const scheduleDate = '2025-10-28'
20+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
21+
})
22+
23+
it('should handle dates far in the past', () => {
24+
const currentTime = new Date('2025-10-27T12:00:00')
25+
const scheduleDate = '2024-01-01'
26+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(true)
27+
})
28+
29+
it('should handle dates far in the future', () => {
30+
const currentTime = new Date('2025-10-27T12:00:00')
31+
const scheduleDate = '2026-12-31'
32+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
33+
})
34+
35+
it('should ignore time of day (only compare dates)', () => {
36+
// Morning
37+
const morningTime = new Date('2025-10-28T08:00:00')
38+
expect(isScheduleInPast('2025-10-27', morningTime)).toBe(true)
39+
40+
// Evening
41+
const eveningTime = new Date('2025-10-28T23:59:59')
42+
expect(isScheduleInPast('2025-10-27', eveningTime)).toBe(true)
43+
44+
// Today should still be false regardless of time
45+
expect(isScheduleInPast('2025-10-28', morningTime)).toBe(false)
46+
expect(isScheduleInPast('2025-10-28', eveningTime)).toBe(false)
47+
})
48+
})
49+
50+
describe('isScheduleToday', () => {
51+
it('should return true when schedule date matches current date', () => {
52+
const currentTime = new Date('2025-10-27T12:00:00')
53+
const scheduleDate = '2025-10-27'
54+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(true)
55+
})
56+
57+
it('should return false when schedule date is in the past', () => {
58+
const currentTime = new Date('2025-10-28T12:00:00')
59+
const scheduleDate = '2025-10-27'
60+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(false)
61+
})
62+
63+
it('should return false when schedule date is in the future', () => {
64+
const currentTime = new Date('2025-10-27T12:00:00')
65+
const scheduleDate = '2025-10-28'
66+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(false)
67+
})
68+
})
69+
70+
describe('integration: isScheduleInPast and isScheduleToday', () => {
71+
it('should have mutually exclusive results for past and today', () => {
72+
const currentTime = new Date('2025-10-27T12:00:00')
73+
const yesterdayDate = '2025-10-26'
74+
const todayDate = '2025-10-27'
75+
const tomorrowDate = '2025-10-28'
76+
77+
// Yesterday
78+
expect(isScheduleInPast(yesterdayDate, currentTime)).toBe(true)
79+
expect(isScheduleToday(yesterdayDate, currentTime)).toBe(false)
80+
81+
// Today
82+
expect(isScheduleInPast(todayDate, currentTime)).toBe(false)
83+
expect(isScheduleToday(todayDate, currentTime)).toBe(true)
84+
85+
// Tomorrow
86+
expect(isScheduleInPast(tomorrowDate, currentTime)).toBe(false)
87+
expect(isScheduleToday(tomorrowDate, currentTime)).toBe(false)
88+
})
89+
})
90+
})

src/components/program/ProgramScheduleView.stories.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,69 @@ const multiDayData: FilteredProgramData = {
197197
},
198198
}
199199

200+
const pastDayData: FilteredProgramData = {
201+
schedules: [
202+
{
203+
_id: 'schedule-day1',
204+
date: '2024-09-15',
205+
tracks: [
206+
{
207+
trackTitle: 'Main Stage',
208+
trackDescription: 'Keynotes and featured presentations',
209+
talks: [
210+
{
211+
talk: createTalk('t1', 'Opening Keynote'),
212+
startTime: '09:00',
213+
endTime: '09:45',
214+
},
215+
{
216+
placeholder: 'Coffee Break',
217+
startTime: '09:45',
218+
endTime: '10:00',
219+
},
220+
{
221+
talk: createTalk('t2', 'GitOps Best Practices'),
222+
startTime: '10:00',
223+
endTime: '10:45',
224+
},
225+
],
226+
},
227+
],
228+
},
229+
{
230+
_id: 'schedule-day2',
231+
date: '2099-09-16',
232+
tracks: [
233+
{
234+
trackTitle: 'Main Stage',
235+
trackDescription: 'Day 2 keynotes',
236+
talks: [
237+
{
238+
talk: createTalk('t7', 'Platform Engineering in 2025'),
239+
startTime: '09:00',
240+
endTime: '09:45',
241+
},
242+
{
243+
talk: createTalk('t8', 'eBPF for Beginners'),
244+
startTime: '10:00',
245+
endTime: '10:45',
246+
},
247+
],
248+
},
249+
],
250+
},
251+
],
252+
allTalks: [],
253+
availableFilters: {
254+
days: ['2024-09-15', '2099-09-16'],
255+
tracks: ['Main Stage'],
256+
formats: [Format.presentation_45],
257+
levels: [Level.intermediate],
258+
audiences: [Audience.developer],
259+
topics: mockTopics,
260+
},
261+
}
262+
200263
const emptyData: FilteredProgramData = {
201264
schedules: [],
202265
allTalks: [],
@@ -210,6 +273,10 @@ const emptyData: FilteredProgramData = {
210273
},
211274
}
212275

276+
// Pin Date.now() to Sep 15, 2025 10:30 so schedule dates are "today"/"future"
277+
// and visual snapshots don't drift as real time passes.
278+
const FIXED_NOW = new Date('2025-09-15T10:30:00Z')
279+
213280
const meta = {
214281
title: 'Systems/Program/ProgramScheduleView',
215282
component: ProgramScheduleView,
@@ -223,6 +290,29 @@ const meta = {
223290
},
224291
},
225292
tags: ['autodocs'],
293+
beforeEach: () => {
294+
const OriginalDate = globalThis.Date
295+
const fixedTime = FIXED_NOW.getTime()
296+
297+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
298+
const MockDate: any = function (...args: any[]) {
299+
if (args.length === 0) return new OriginalDate(fixedTime)
300+
return new (Function.prototype.bind.apply(OriginalDate, [
301+
null,
302+
...args,
303+
]) as typeof OriginalDate)()
304+
}
305+
Object.setPrototypeOf(MockDate, OriginalDate)
306+
MockDate.prototype = Object.create(OriginalDate.prototype)
307+
MockDate.now = () => fixedTime
308+
MockDate.parse = OriginalDate.parse.bind(OriginalDate)
309+
MockDate.UTC = OriginalDate.UTC.bind(OriginalDate)
310+
globalThis.Date = MockDate
311+
312+
return () => {
313+
globalThis.Date = OriginalDate
314+
}
315+
},
226316
decorators: [
227317
(Story: React.ComponentType) => (
228318
<BookmarksProvider>
@@ -294,3 +384,17 @@ export const Empty: Story = {
294384
data: emptyData,
295385
},
296386
}
387+
388+
export const PastDayCollapsed: Story = {
389+
args: {
390+
data: pastDayData,
391+
},
392+
parameters: {
393+
docs: {
394+
description: {
395+
story:
396+
'Multi-day schedule showing the collapsible feature for past days. The first day (in the past) is automatically collapsed, while the future day remains expanded. Click the past day header to expand/collapse it.',
397+
},
398+
},
399+
},
400+
}

src/components/program/ProgramScheduleView.tsx

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
22
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
3-
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
3+
import {
4+
MagnifyingGlassIcon,
5+
ChevronDownIcon,
6+
ChevronRightIcon,
7+
} from '@heroicons/react/24/outline'
48
import { FilteredProgramData } from '@/hooks/useProgramFilter'
59
import { ConferenceSchedule, ScheduleTrack } from '@/lib/conference/types'
610
import { TalkCard } from './TalkCard'
7-
import { getTalkStatusKey } from '@/lib/program/time-utils'
11+
import { getTalkStatusKey, isScheduleInPast } from '@/lib/program/time-utils'
812
import type { TalkStatus, CurrentPosition } from '@/lib/program/time-utils'
913
import { formatConferenceDateLong } from '@/lib/time'
1014
import clsx from 'clsx'
@@ -383,6 +387,9 @@ const DaySchedule = React.memo(function DaySchedule({
383387
scheduleIndex: number
384388
scrollTargetRef: React.RefObject<HTMLDivElement | null>
385389
}) {
390+
const isPast = isScheduleInPast(schedule.date)
391+
const [isOpen, setIsOpen] = useState(!isPast)
392+
386393
if (schedule.tracks.length === 0) {
387394
return (
388395
<div className="py-12 text-center">
@@ -397,33 +404,59 @@ const DaySchedule = React.memo(function DaySchedule({
397404
return (
398405
<div className="space-y-6">
399406
<div className="text-center">
400-
<h2 className="font-space-grotesk text-2xl font-semibold text-brand-slate-gray dark:text-white">
401-
{formatConferenceDateLong(schedule.date)}
402-
</h2>
407+
{isPast ? (
408+
<button
409+
onClick={() => setIsOpen(!isOpen)}
410+
className="group inline-flex items-center gap-2 transition-colors hover:text-brand-cloud-blue dark:hover:text-blue-400"
411+
aria-expanded={isOpen}
412+
>
413+
<h2 className="font-space-grotesk text-2xl font-semibold text-brand-slate-gray dark:text-white">
414+
{formatConferenceDateLong(schedule.date)}
415+
</h2>
416+
<span className="text-brand-slate-gray dark:text-gray-400">
417+
{isOpen ? (
418+
<ChevronDownIcon className="h-6 w-6 transition-transform group-hover:scale-110" />
419+
) : (
420+
<ChevronRightIcon className="h-6 w-6 transition-transform group-hover:scale-110" />
421+
)}
422+
</span>
423+
</button>
424+
) : (
425+
<h2 className="font-space-grotesk text-2xl font-semibold text-brand-slate-gray dark:text-white">
426+
{formatConferenceDateLong(schedule.date)}
427+
</h2>
428+
)}
403429
<p className="font-inter mt-1 text-sm text-gray-600 dark:text-gray-400">
404430
{schedule.tracks.length} track
405431
{schedule.tracks.length !== 1 ? 's' : ''}
432+
{isPast && (
433+
<span className="ml-2 text-gray-500 dark:text-gray-500">
434+
{isOpen ? '' : '(click to expand)'}
435+
</span>
436+
)}
406437
</p>
407438
</div>
408439

409-
<div className="space-y-6">
410-
<ScheduleTabbed
411-
tracks={schedule.tracks}
412-
date={schedule.date}
413-
talkStatusMap={talkStatusMap}
414-
currentPosition={currentPosition}
415-
scheduleIndex={scheduleIndex}
416-
scrollTargetRef={scrollTargetRef}
417-
/>
418-
<ScheduleStatic
419-
tracks={schedule.tracks}
420-
date={schedule.date}
421-
talkStatusMap={talkStatusMap}
422-
currentPosition={currentPosition}
423-
scheduleIndex={scheduleIndex}
424-
scrollTargetRef={scrollTargetRef}
425-
/>
426-
</div>
440+
{isOpen && (
441+
<div className="space-y-6">
442+
<ScheduleTabbed
443+
tracks={schedule.tracks}
444+
date={schedule.date}
445+
talkStatusMap={talkStatusMap}
446+
currentPosition={currentPosition}
447+
scheduleIndex={scheduleIndex}
448+
scrollTargetRef={scrollTargetRef}
449+
/>
450+
<ScheduleStatic
451+
tracks={schedule.tracks}
452+
date={schedule.date}
453+
talkStatusMap={talkStatusMap}
454+
currentPosition={currentPosition}
455+
scheduleIndex={scheduleIndex}
456+
scrollTargetRef={scrollTargetRef}
457+
/>
458+
</div>
459+
)}
427460
</div>
428461
)
429462
})

src/lib/program/time-utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ export function isScheduleToday(
7777
)
7878
}
7979

80+
export function isScheduleInPast(
81+
scheduleDate: string,
82+
currentTime: Date = getCurrentConferenceTime(),
83+
): boolean {
84+
return (
85+
stripTime(new Date(scheduleDate)).getTime() <
86+
stripTime(currentTime).getTime()
87+
)
88+
}
89+
8090
export function findCurrentTalkPosition(
8191
schedules: ConferenceSchedule[],
8292
currentTime: Date = getCurrentConferenceTime(),

0 commit comments

Comments
 (0)