diff --git a/src/lib/components/Disclosure.svelte b/src/lib/components/Disclosure.svelte new file mode 100644 index 0000000..7c52465 --- /dev/null +++ b/src/lib/components/Disclosure.svelte @@ -0,0 +1,141 @@ + + +{@render trigger({ expanded, panelId, buttonId, toggle })} + +
+
+ {@render children()} +
+
diff --git a/src/lib/components/ScheduleBlock.svelte b/src/lib/components/ScheduleBlock.svelte new file mode 100644 index 0000000..d1d8d58 --- /dev/null +++ b/src/lib/components/ScheduleBlock.svelte @@ -0,0 +1,37 @@ + + +
+ +
+
+ {#if date} +

+ {formatDate(date, 'EEEE, MMMM do, yyyy')} +

+ {/if} + + +
+
+
diff --git a/src/lib/components/ScheduleGrid.svelte b/src/lib/components/ScheduleGrid.svelte new file mode 100644 index 0000000..d46766f --- /dev/null +++ b/src/lib/components/ScheduleGrid.svelte @@ -0,0 +1,301 @@ + + +
+
+
+ +
+ {#each visibleState.visibleTracks as track (track.id)} +
+
+

+ {track.name} +

+
+
+ {/each} +
+ +
+
+ + {#each sessions as session (session.id)} + {@const gridAreaId = createGridAreaId( + getEndPath(session.route.path ?? ''), + )} + + {/each} +
+ + diff --git a/src/lib/components/SessionCard.svelte b/src/lib/components/SessionCard.svelte new file mode 100644 index 0000000..f937c50 --- /dev/null +++ b/src/lib/components/SessionCard.svelte @@ -0,0 +1,155 @@ + + +
+ {#if start} + + {/if} + + {#if markup} + + {#snippet trigger({ expanded, panelId, buttonId, toggle })} +
+
+ {#if profileImageUrl} + + {/if} +
+

+ {session.name} +

+ {#if speakerContent} +

+ {#if disableSpeakerLinks} + {speakerContent.name} + {:else} + + {speakerContent.name} + {/if}{jobTitle ? `: ${jobTitle}` : ''} +

+ {/if} +
+
+ +
+ {/snippet} + + +
+ {:else} +
+ {#if profileImageUrl} + + {/if} +
+

+ {session.name} +

+ {#if speakerContent} +

+ {#if disableSpeakerLinks} + {speakerContent.name} + {:else} + + {speakerContent.name} + {/if}{jobTitle ? `: ${jobTitle}` : ''} +

+ {/if} +
+
+ {/if} +
diff --git a/src/lib/components/schedule.ts b/src/lib/components/schedule.ts new file mode 100644 index 0000000..09b2a48 --- /dev/null +++ b/src/lib/components/schedule.ts @@ -0,0 +1,142 @@ +import type { + ParsedSession, + ScheduleItem, + TrackWithSessions, +} from '#lib/types.ts'; + +export type ScheduleGridCell = string | null | undefined; +export type ScheduleGridRows = ScheduleGridCell[][]; + +export const TRACK_HEADER_ID = 'track-header'; + +export interface VisibleGridState { + grid: string[][]; + visibleGrid: string[][]; + visibleTracks: TrackWithSessions[]; + visibleIds: Set; +} + +export function createGridAreaId(id: string): string { + return `area-${id.replaceAll('/', '')}`; +} + +export function getEndPath(path: string): string { + const parts = path.split('/').filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +export function updateGridIds(grid: ScheduleGridRows): string[][] { + return grid.map((gridRow) => + gridRow.map((gridColumn) => + gridColumn ? createGridAreaId(gridColumn) : '...', + ), + ); +} + +export function clampStartColumnIndex( + startColumnIndex: number, + totalColumns: number, + visibleColumns: number, +): number { + return Math.max( + 0, + Math.min(startColumnIndex, Math.max(totalColumns - visibleColumns, 0)), + ); +} + +export function nextStartColumnIndex( + startColumnIndex: number, + totalColumns: number, + visibleColumns: number, +): number { + return clampStartColumnIndex( + startColumnIndex + 1, + totalColumns, + visibleColumns, + ); +} + +export function previousStartColumnIndex(startColumnIndex: number): number { + return Math.max(startColumnIndex - 1, 0); +} + +export function visibleColumnCountForWidth( + width: number, + totalColumns: number, +): number { + if (totalColumns <= 0) { + return 0; + } + + if (width <= 1023) { + return 1; + } + + if (width <= 1279) { + return Math.min(totalColumns, 2); + } + + return Math.min(totalColumns, 3); +} + +export function flattenAndSortSessions( + items: ScheduleItem[] = [], +): ParsedSession[] { + return items + .flatMap((item) => + item.contentType === 'track' ? item.children : [item], + ) + .sort(sessionStartSort); +} + +export function getScheduleTracks( + items: ScheduleItem[] = [], +): TrackWithSessions[] { + return items.filter( + (item): item is TrackWithSessions => item.contentType === 'track', + ); +} + +export function buildVisibleGridState({ + rows, + tracks, + startColumnIndex, + visibleColumns, +}: { + rows: ScheduleGridRows; + tracks: TrackWithSessions[]; + startColumnIndex: number; + visibleColumns: number; +}): VisibleGridState { + const grid = updateGridIds([tracks.map(() => TRACK_HEADER_ID), ...rows]); + const visibleGrid = grid.map((row) => + row.slice(startColumnIndex, startColumnIndex + visibleColumns), + ); + + return { + grid, + visibleGrid, + visibleTracks: tracks.slice( + startColumnIndex, + startColumnIndex + visibleColumns, + ), + visibleIds: new Set(visibleGrid.flat()), + }; +} + +export function gridTemplateAreas(visibleGrid: string[][]): string { + const nonEmptyRows = visibleGrid.filter((row) => row.length > 0); + + if (nonEmptyRows.length === 0) { + return 'none'; + } + + return nonEmptyRows.map((row) => `"${row.join(' ')}"`).join('\n'); +} + +function sessionStartSort(a: ParsedSession, b: ParsedSession): number { + return ( + (a.properties?.start?.getTime() ?? 0) - + (b.properties?.start?.getTime() ?? 0) + ); +} diff --git a/src/lib/server/getSchedule.ts b/src/lib/server/getSchedule.ts index 55fe8ba..e921967 100644 --- a/src/lib/server/getSchedule.ts +++ b/src/lib/server/getSchedule.ts @@ -1,4 +1,8 @@ -import type { Schedule, ScheduleItem } from '#lib/types.ts'; +import type { + PublicScheduleGridCell, + Schedule, + ScheduleItem, +} from '#lib/types.ts'; import { treeByRoutePath } from '#lib/server/umbraco/treeByRoutePath.ts'; import { parseSession } from '#lib/server/parseSession.ts'; import { getFirstChildNodeOfType } from '#lib/server/umbraco/getChildNodesOfType.ts'; @@ -46,7 +50,7 @@ export async function getScheduleItems( export async function getScheduleGrid( conferenceId: string, -): Promise { +): Promise { const { data, error } = await getUmbracoClient().GET( '/api/v1/conference/{conferenceId}/schedule', { params: { path: { conferenceId } } }, diff --git a/src/lib/types.ts b/src/lib/types.ts index e0483ad..e47cdb8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,8 @@ export type Sponsor = components['schemas']['SponsorElementModel']; export type Sessions = Extract; +export type Speaker = Extract; + export type Session = Extract; export type ParsedSession = Overwrite< @@ -47,9 +49,12 @@ export type TrackWithSessions = Track & { children: ParsedSession[] }; export type ScheduleItem = ParsedSession | TrackWithSessions; -export type Schedule = { items: ScheduleItem[]; grid: string[][] }; +export type PublicScheduleGridCell = string | null; -export type Speaker = Extract; +export type Schedule = { + items: ScheduleItem[]; + grid: PublicScheduleGridCell[][]; +}; export type Page = Extract; diff --git a/tests/unit/schedule-components.test.ts b/tests/unit/schedule-components.test.ts new file mode 100644 index 0000000..c8094f4 --- /dev/null +++ b/tests/unit/schedule-components.test.ts @@ -0,0 +1,348 @@ +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; +import ScheduleBlock from '../../src/lib/components/ScheduleBlock.svelte'; +import ScheduleGrid from '../../src/lib/components/ScheduleGrid.svelte'; +import SessionCard from '../../src/lib/components/SessionCard.svelte'; +import { + buildVisibleGridState, + clampStartColumnIndex, + createGridAreaId, + flattenAndSortSessions, + getEndPath, + gridTemplateAreas, + nextStartColumnIndex, + previousStartColumnIndex, + updateGridIds, + visibleColumnCountForWidth, +} from '../../src/lib/components/schedule.ts'; +import type { + ParsedConference, + ParsedSession, + Schedule, + Speaker, + TrackWithSessions, +} from '../../src/lib/types.ts'; + +const bryanSpeaker = { + id: 'speaker-bryan', + contentType: 'speaker', + name: ' Bryan McCann', + route: { + path: '/2024/speakers/bryan-mccann/', + startItem: { id: 'root', path: '/' }, + }, + properties: { + jobTitle: 'Developer', + profileImage: [ + { + id: 'bryan-image', + name: 'Bryan', + mediaType: 'Image', + url: '/media/bryan.jpg', + width: 100, + height: 100, + properties: null, + }, + ], + }, +} as unknown as Speaker; + +const secondSpeaker = { + id: 'speaker-second', + contentType: 'speaker', + name: 'Second Speaker', + route: { + path: '/2024/speakers/second-speaker/', + startItem: { id: 'root', path: '/' }, + }, + properties: {}, +} as unknown as Speaker; + +const makeSession = ({ + id, + name, + slug, + start, + speakers = [bryanSpeaker], + markup = '

Session details

', +}: { + id: string; + name: string; + slug: string; + start: string; + speakers?: Speaker[]; + markup?: string; +}): ParsedSession => + ({ + id, + contentType: 'session', + name, + route: { + path: `/2024/sessions/${slug}/`, + startItem: { id: 'root', path: '/' }, + }, + properties: { + start: new Date(start), + end: new Date('2024-10-12T18:00:00.000Z'), + speakers, + description: markup ? { markup, blocks: [] } : null, + }, + }) as unknown as ParsedSession; + +const trackOneSession = makeSession({ + id: 'session-a', + name: 'How Fast Is My App?', + slug: 'how-fast-is-my-app-performance-testing-101', + start: '2024-10-12T13:30:00.000Z', + speakers: [bryanSpeaker, secondSpeaker], +}); +const trackTwoSession = makeSession({ + id: 'session-b', + name: 'Connect: Why You So Slow?', + slug: 'connect-why-you-so-slow', + start: '2024-10-12T14:40:00.000Z', +}); +const workshop = makeSession({ + id: 'session-workshop', + name: 'Yep, You Can Build Hardware', + slug: 'yep-you-can-build-hardware', + start: '2024-10-12T13:30:00.000Z', +}); +const topLevelLunch = makeSession({ + id: 'session-lunch', + name: 'Lunch', + slug: 'lunch', + start: '2024-10-12T17:00:00.000Z', + speakers: [], +}); + +const tracks = [ + { + id: 'track-one', + contentType: 'track', + name: 'Track 1 (Coxhealth)', + route: { + path: '/2024/sessions/track-1/', + startItem: { id: 'root', path: '/' }, + }, + properties: {}, + children: [trackTwoSession, trackOneSession], + }, + { + id: 'track-two', + contentType: 'track', + name: 'Track 2 (IDEA loft)', + route: { + path: '/2024/sessions/track-2/', + startItem: { id: 'root', path: '/' }, + }, + properties: {}, + children: [], + }, + { + id: 'track-workshop', + contentType: 'track', + name: 'Workshop', + route: { + path: '/2024/sessions/workshop/', + startItem: { id: 'root', path: '/' }, + }, + properties: {}, + children: [workshop], + }, +] as unknown as TrackWithSessions[]; + +const grid = [ + ['check-inbreakfast', 'check-inbreakfast', 'check-inbreakfast'], + [ + 'how-fast-is-my-app-performance-testing-101', + 'i-hope-im-not-the-smartest-person-in-the-room', + 'yep-you-can-build-hardware', + ], + [ + 'connect-why-you-so-slow', + 'energize-your-teams-performance-think-like-an-improv-actor', + 'yep-you-can-build-hardware', + ], + ['building-powerful-and-intelligent-applications', 'silent-figures', null], +]; + +const conference = { + id: 'conference-id', + contentType: 'conference', + name: '2024', + route: { path: '/2024/', startItem: { id: 'root', path: '/' } }, + properties: { + date: new Date('2024-10-12T05:00:00.000Z'), + }, +} as unknown as ParsedConference; + +const schedule: Schedule = { + items: [tracks[0], tracks[1], tracks[2], topLevelLunch], + grid, +}; + +describe('schedule helpers', () => { + it('maps public grid IDs, empty cells, and route tails to CSS grid areas', () => { + expect(createGridAreaId('yep-you-can-build-hardware/')).toBe( + 'area-yep-you-can-build-hardware', + ); + expect(getEndPath('/2024/sessions/lunch/')).toBe('lunch'); + expect(updateGridIds([[null, '', 'lunch']])).toEqual([ + ['...', '...', 'area-lunch'], + ]); + }); + + it('slices visible tracks and preserves rectangular duplicate areas', () => { + const visible = buildVisibleGridState({ + rows: grid, + tracks, + startColumnIndex: 1, + visibleColumns: 2, + }); + + expect(visible.visibleTracks.map((track) => track.name)).toEqual([ + 'Track 2 (IDEA loft)', + 'Workshop', + ]); + expect(visible.visibleGrid[3]).toEqual([ + 'area-energize-your-teams-performance-think-like-an-improv-actor', + 'area-yep-you-can-build-hardware', + ]); + expect(visible.visibleGrid[4]).toEqual(['area-silent-figures', '...']); + expect(gridTemplateAreas(visible.visibleGrid)).toContain( + 'area-track-header area-track-header', + ); + }); + + it('matches live breakpoints and clamps controls', () => { + expect(visibleColumnCountForWidth(1023, 3)).toBe(1); + expect(visibleColumnCountForWidth(1024, 3)).toBe(2); + expect(visibleColumnCountForWidth(1279, 3)).toBe(2); + expect(visibleColumnCountForWidth(1280, 3)).toBe(3); + expect(visibleColumnCountForWidth(1280, 2)).toBe(2); + expect(nextStartColumnIndex(1, 3, 2)).toBe(1); + expect(previousStartColumnIndex(0)).toBe(0); + expect(clampStartColumnIndex(2, 3, 2)).toBe(1); + }); + + it('flattens track children and top-level sessions by start time', () => { + expect( + flattenAndSortSessions(schedule.items).map((item) => item.id), + ).toEqual([ + 'session-a', + 'session-workshop', + 'session-b', + 'session-lunch', + ]); + }); +}); + +describe('schedule SSR components', () => { + it('renders the schedule section, date, tracks, and SSR three-column grid', () => { + const html = render(ScheduleBlock, { + props: { conference, schedule }, + }).body; + + expect(html).toContain('id="schedule"'); + expect(html).toContain('>Schedule'); + expect(html).toContain('Saturday, October 12th, 2024'); + expect(html).toContain('Track 1 (Coxhealth)'); + expect(html).toContain('Track 2 (IDEA loft)'); + expect(html).toContain('Workshop'); + expect(html).toContain('--grid-template-columns: 3'); + expect(html).toContain('area-yep-you-can-build-hardware'); + expect(html).not.toContain('area-null'); + }); + + it('renders speaker cards with the first speaker, avatar crop, time, and no session link', () => { + const html = render(SessionCard, { + props: { session: trackOneSession }, + }).body; + + expect(html).toContain('8:30 AM'); + expect(html).toContain('How Fast Is My App?'); + expect(html).toContain('href="/2024/speakers/bryan-mccann/"'); + expect(html).toContain(' Bryan McCann profile image'); + expect(html).toContain('width%3D100%26height%3D100'); + expect(html).toContain('w=128'); + expect(html).toContain('q=75'); + expect(html).toContain( + 'w-[50px] h-[50px] md:w-[70px] md:h-[70px] lg:w-[75px] lg:h-[75px] 2xl:w-[80px] 2xl:h-[80px] rounded-full mr-3', + ); + expect(html).toContain('More'); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain('Second Speaker'); + expect(html).not.toContain( + 'href="/2024/sessions/how-fast-is-my-app-performance-testing-101/"', + ); + }); + + it('keeps collapsed disclosure content and the controlled region in SSR', () => { + const html = render(SessionCard, { + props: { session: trackOneSession }, + }).body; + const controlledRegion = html.match(/aria-controls="([^"]+)"/); + + expect(controlledRegion?.[1]).toBe('session-card-session-a-panel'); + expect(html.match(/aria-expanded="false"/g)).toHaveLength(1); + expect(html).toContain('id="session-card-session-a-trigger"'); + expect(html).toContain('id="session-card-session-a-panel"'); + expect(html).toContain( + 'aria-labelledby="session-card-session-a-trigger"', + ); + expect(html).toContain('role="region"'); + expect(html).toContain('

Session details

'); + expect(html).toMatch( + /class="transition-\[height\][^"]*overflow-hidden" style="[^"]*display:\s*none[^"]*height:\s*0px/, + ); + }); + + it('can disable speaker links and still render the speaker text', () => { + const html = render(SessionCard, { + props: { session: trackOneSession, disableSpeakerLinks: true }, + }).body; + + expect(html).toContain(' Bryan McCann'); + expect(html).not.toContain('href="/2024/speakers/bryan-mccann/"'); + }); + + it('renders no-speaker descriptions with a More button', () => { + const html = render(SessionCard, { + props: { session: topLevelLunch }, + }).body; + + expect(html).toContain('Lunch'); + expect(html).toContain('12:00 PM'); + expect(html).toContain('More'); + expect(html).not.toContain('profile image'); + }); + + it('uses a valid heading fallback when a description is missing', () => { + const sessionWithoutDescription = makeSession({ + id: 'session-no-description', + name: 'No Description', + slug: 'no-description', + start: '2024-10-12T20:30:00.000Z', + markup: '', + }); + const html = render(SessionCard, { + props: { session: sessionWithoutDescription }, + }).body; + + expect(html).toContain(' { + const html = render(ScheduleGrid, { + props: { grid: [], tracks: [], sessions: [] }, + }).body; + + expect(html).toContain('schedule-grid'); + expect(html).toContain('--grid-template-columns: 0'); + expect(html).toContain('--grid-template-areas: none'); + }); +});