Skip to content

Commit 39584b0

Browse files
committed
feat(programa): add accessible list view as alternative to Pretalx widget
Addresses #216 — the Pretalx widget's CSS grid layout is fundamentally hard to navigate with a screen reader, no matter how much ARIA we inject. Adds a parallel accessible list view rendered server-side from the Pretalx API, with the widget preserved as an opt-in visual alternative. Data layer: - src/lib/schedule.ts — fetches schedule.json at build time, normalises per-day structure, returns null on failure (graceful fallback) - src/types/schedule.ts — TS types for the Pretalx export shape UI: - src/components/ScheduleList.astro — semantic <ol> per day with <article> per session, <time datetime>, <details> for abstracts, lang attribute on text in non-default languages - Toggle in ProgramaPage.astro: <fieldset> + radio group, default to the accessible list, persists choice in localStorage - Widget remains as opt-in 'Visual grid' view, with all a11y enhancements from the previous commit intact i18n: - Adds 15 new strings to programa/{es,en,ca} (view toggle, day label, speaker/room/track/abstract labels, empty state) Edge cases: - Pretalx down or schedule not published: render friendly empty state with link to pretalx.com - Build-time fetch is silent on failure (warning to stderr, no crash)
1 parent bb27cbc commit 39584b0

8 files changed

Lines changed: 748 additions & 271 deletions

File tree

src/components/ProgramaPage.astro

Lines changed: 365 additions & 25 deletions
Large diffs are not rendered by default.

src/components/ScheduleList.astro

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
---
2+
import type { Schedule } from '@/types/schedule'
3+
import { formatDayLabel, formatTime } from '@/lib/schedule'
4+
import { menuTexts } from '@/i18n/menu'
5+
import { programaTexts } from '@/i18n/programa'
6+
7+
interface Props {
8+
schedule: Schedule
9+
lang: string
10+
}
11+
12+
const { schedule, lang } = Astro.props
13+
const t = programaTexts[lang as keyof typeof programaTexts]
14+
const menuT = menuTexts[lang as keyof typeof menuTexts]
15+
---
16+
17+
<div class="schedule-list">
18+
{
19+
schedule.days.map((day) => (
20+
<section class="mb-12" aria-labelledby={`day-${day.index}-heading`}>
21+
<h2
22+
id={`day-${day.index}-heading`}
23+
class="text-2xl md:text-3xl font-black text-white mb-6 flex items-baseline gap-3"
24+
>
25+
<span class="text-pycon-orange">{t.list.dayN(day.index)}</span>
26+
<span class="text-base md:text-lg font-normal text-pycon-gray-25 capitalize">
27+
{formatDayLabel(day.date, lang, schedule.timezone)}
28+
</span>
29+
</h2>
30+
<ol class="flex flex-col gap-4 list-none m-0 p-0">
31+
{day.sessions.map((s) => (
32+
<li class="bg-pycon-black/40 rounded-2xl border border-white/5 p-5 md:p-6 transition-colors hover:border-pycon-orange/40 focus-within:border-pycon-orange/60">
33+
<article>
34+
<header class="flex flex-wrap items-baseline gap-x-4 gap-y-1 mb-3">
35+
<time datetime={s.date} class="text-sm font-mono text-pycon-yellow shrink-0">
36+
{formatTime(s.date, schedule.timezone)}{formatTime(s.end, schedule.timezone)}
37+
</time>
38+
<span class="text-xs text-pycon-gray-25/70 shrink-0">{s.duration}</span>
39+
{s.track && (
40+
<span class="text-xs px-2 py-0.5 rounded-full bg-pycon-orange/15 text-pycon-orange border border-pycon-orange/30 shrink-0">
41+
{s.track}
42+
</span>
43+
)}
44+
{s.type && <span class="text-xs text-pycon-gray-25/70 shrink-0">{s.type}</span>}
45+
</header>
46+
47+
<h3 class="text-lg md:text-xl font-bold text-white mb-2 leading-snug">{s.title}</h3>
48+
49+
{s.subtitle && <p class="text-sm text-pycon-gray-25 mb-3 italic">{s.subtitle}</p>}
50+
51+
{s.persons.length > 0 && (
52+
<p class="text-sm text-pycon-gray-25 mb-3">
53+
<span class="font-semibold text-white">{t.list.by}: </span>
54+
{s.persons.map((p, i) => {
55+
const sep =
56+
i === 0
57+
? ''
58+
: i === s.persons.length - 1
59+
? ' & '
60+
: s.persons.length === 2
61+
? ' & '
62+
: ', '
63+
return (
64+
<>
65+
{sep}
66+
<span class="text-white">{p.public_name || p.name}</span>
67+
</>
68+
)
69+
})}
70+
</p>
71+
)}
72+
73+
<dl class="flex flex-wrap gap-x-6 gap-y-1 text-xs text-pycon-gray-25/80 mb-4">
74+
<div class="flex items-baseline gap-1.5">
75+
<dt class="font-semibold text-white">{t.list.room}:</dt>
76+
<dd>{s.room}</dd>
77+
</div>
78+
{s.language && (
79+
<div class="flex items-baseline gap-1.5">
80+
<dt class="font-semibold text-white">{t.list.language}:</dt>
81+
<dd>
82+
<span lang={s.language}>{s.language.toUpperCase()}</span>
83+
</dd>
84+
</div>
85+
)}
86+
</dl>
87+
88+
{s.abstract && (
89+
<details class="group mb-3">
90+
<summary class="cursor-pointer text-sm text-pycon-yellow hover:text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pycon-yellow rounded inline-flex items-center gap-1.5 select-none list-none [&::-webkit-details-marker]:hidden">
91+
<svg
92+
class="w-3.5 h-3.5 transition-transform group-open:rotate-90"
93+
fill="none"
94+
stroke="currentColor"
95+
viewBox="0 0 24 24"
96+
aria-hidden="true"
97+
>
98+
<path
99+
stroke-linecap="round"
100+
stroke-linejoin="round"
101+
stroke-width="2"
102+
d="M9 5l7 7-7 7"
103+
/>
104+
</svg>
105+
<span class="group-open:hidden">{t.list.showAbstract}</span>
106+
<span class="hidden group-open:inline">{t.list.hideAbstract}</span>
107+
</summary>
108+
<p
109+
lang={s.language === 'en' ? 'en' : s.language === 'ca' ? 'ca' : 'es'}
110+
class="mt-3 text-sm text-pycon-gray-25 leading-relaxed"
111+
>
112+
{s.abstract}
113+
</p>
114+
</details>
115+
)}
116+
117+
{s.url && (
118+
<a
119+
href={s.url}
120+
target="_blank"
121+
rel="noopener noreferrer"
122+
aria-label={`${s.title} — ${t.list.talkLink} ${menuT.new_tab}`}
123+
class="inline-flex items-center gap-1.5 text-sm text-pycon-yellow hover:text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pycon-yellow rounded"
124+
>
125+
<span>{t.list.talkLink}</span>
126+
<svg
127+
class="w-3.5 h-3.5"
128+
fill="none"
129+
stroke="currentColor"
130+
viewBox="0 0 24 24"
131+
aria-hidden="true"
132+
>
133+
<path
134+
stroke-linecap="round"
135+
stroke-linejoin="round"
136+
stroke-width="2"
137+
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
138+
/>
139+
</svg>
140+
</a>
141+
)}
142+
</article>
143+
</li>
144+
))}
145+
</ol>
146+
</section>
147+
))
148+
}
149+
</div>

src/i18n/programa/ca.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,22 @@ export const ca = {
1212
timezoneLabel: 'Zona horària',
1313
closeFilters: 'Tanca els filtres',
1414
},
15+
list: {
16+
viewModeLegend: 'Mode de visualització del programa',
17+
viewModeList: 'Llista accessible',
18+
viewModeGrid: 'Graella visual',
19+
dayN: (n: number) => `Dia ${n}`,
20+
speakers: (count: number) => (count === 1 ? 'Ponent' : 'Ponents'),
21+
by: 'Per',
22+
room: 'Sala',
23+
track: 'Track',
24+
type: 'Tipus',
25+
language: 'Idioma',
26+
showAbstract: 'Mostra la descripció',
27+
hideAbstract: 'Amaga la descripció',
28+
talkLink: 'Veure detalls de la xerrada a Pretalx',
29+
emptyTitle: 'El horari encara no està disponible',
30+
emptyBody: 'Estem acabant de preparar el cronograma. Torna a visitar-nos els propers dies.',
31+
emptyCta: 'Consulta el horari a Pretalx',
32+
},
1533
} as const

src/i18n/programa/en.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,22 @@ export const en = {
1212
timezoneLabel: 'Timezone',
1313
closeFilters: 'Close filters',
1414
},
15+
list: {
16+
viewModeLegend: 'Schedule view mode',
17+
viewModeList: 'Accessible list',
18+
viewModeGrid: 'Visual grid',
19+
dayN: (n: number) => `Day ${n}`,
20+
speakers: (count: number) => (count === 1 ? 'Speaker' : 'Speakers'),
21+
by: 'By',
22+
room: 'Room',
23+
track: 'Track',
24+
type: 'Type',
25+
language: 'Language',
26+
showAbstract: 'Show description',
27+
hideAbstract: 'Hide description',
28+
talkLink: 'View talk details on Pretalx',
29+
emptyTitle: 'The schedule is not available yet',
30+
emptyBody: "We're still putting the programme together. Check back in a few days.",
31+
emptyCta: 'See the schedule on Pretalx',
32+
},
1533
} as const

src/i18n/programa/es.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,22 @@ export const es = {
1212
timezoneLabel: 'Zona horaria',
1313
closeFilters: 'Cerrar filtros',
1414
},
15+
list: {
16+
viewModeLegend: 'Modo de visualización del programa',
17+
viewModeList: 'Lista accesible',
18+
viewModeGrid: 'Grilla visual',
19+
dayN: (n: number) => `Día ${n}`,
20+
speakers: (count: number) => (count === 1 ? 'Ponente' : 'Ponentes'),
21+
by: 'Por',
22+
room: 'Sala',
23+
track: 'Track',
24+
type: 'Tipo',
25+
language: 'Idioma',
26+
showAbstract: 'Ver descripción',
27+
hideAbstract: 'Ocultar descripción',
28+
talkLink: 'Ver detalles de la charla en Pretalx',
29+
emptyTitle: 'El horario todavía no está disponible',
30+
emptyBody: 'Estamos terminando de armar el cronograma. Vuelve a visitarnos en los próximos días.',
31+
emptyCta: 'Consultar el cronograma en Pretalx',
32+
},
1533
} as const

src/lib/schedule.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { PretalxScheduleResponse, Schedule, ScheduleDay, PretalxSession } from '@/types/schedule'
2+
3+
const PRETALX_EVENT = 'pycones-2026'
4+
const SCHEDULE_URL = `https://pretalx.com/${PRETALX_EVENT}/schedule/export/schedule.json`
5+
const FALLBACK_TIMEZONE = 'Europe/Madrid'
6+
7+
export class ScheduleNotAvailableError extends Error {
8+
constructor(public readonly cause: unknown) {
9+
super(`Schedule for ${PRETALX_EVENT} is not available`)
10+
this.name = 'ScheduleNotAvailableError'
11+
}
12+
}
13+
14+
interface FetchOptions {
15+
/** When true, log warnings on stderr instead of throwing. Default: true. */
16+
silent?: boolean
17+
}
18+
19+
/**
20+
* Fetches the schedule JSON from Pretalx and normalises it into a flat
21+
* day-by-day structure that's easy to render. Returns `null` if the schedule
22+
* is not available (e.g. event not yet published) so the caller can render
23+
* a fallback state instead of crashing the build.
24+
*/
25+
export async function getSchedule(opts: FetchOptions = {}): Promise<Schedule | null> {
26+
const { silent = true } = opts
27+
28+
let data: PretalxScheduleResponse
29+
try {
30+
const res = await fetch(SCHEDULE_URL)
31+
if (!res.ok) {
32+
if (silent) console.warn(`[schedule] Pretalx returned ${res.status} for ${PRETALX_EVENT}`)
33+
return null
34+
}
35+
data = (await res.json()) as PretalxScheduleResponse
36+
} catch (err) {
37+
if (silent) console.warn(`[schedule] Fetch failed: ${(err as Error).message}`)
38+
return null
39+
}
40+
41+
if (!data?.schedule?.conference) {
42+
if (silent) console.warn(`[schedule] Unexpected payload shape`)
43+
return null
44+
}
45+
46+
const conf = data.schedule.conference
47+
48+
// day.rooms is a dict { [roomName]: Session[] } — flatten into a single list per day
49+
const dayEntries = conf.days as unknown as Array<
50+
PretalxScheduleResponse['schedule']['conference']['days'][number] & {
51+
rooms?: Record<string, PretalxSession[]>
52+
}
53+
>
54+
55+
const days: ScheduleDay[] = dayEntries
56+
.map((d) => {
57+
const sessions = d.rooms ? Object.values(d.rooms).flat() : []
58+
return {
59+
index: d.index,
60+
date: d.date,
61+
sessions: sessions.sort((a, b) => a.date.localeCompare(b.date)),
62+
}
63+
})
64+
.filter((d) => d.sessions.length > 0)
65+
66+
return {
67+
conferenceTitle: conf.title,
68+
timezone: conf.time_zone_name || FALLBACK_TIMEZONE,
69+
days,
70+
}
71+
}
72+
73+
/**
74+
* Formats an ISO 8601 datetime in the event's timezone as a human label
75+
* like "Viernes 6 de noviembre" / "Friday, November 6".
76+
*/
77+
export function formatDayLabel(date: string, locale: string, timezone: string): string {
78+
const lang = locale === 'ca' ? 'ca-ES' : locale === 'en' ? 'en-US' : 'es-ES'
79+
return new Intl.DateTimeFormat(lang, {
80+
weekday: 'long',
81+
day: 'numeric',
82+
month: 'long',
83+
timeZone: timezone,
84+
}).format(new Date(date))
85+
}
86+
87+
/**
88+
* Formats the time portion of an ISO 8601 datetime in the event's timezone.
89+
* Returns "HH:MM" in 24h format.
90+
*/
91+
export function formatTime(iso: string, timezone: string): string {
92+
return new Intl.DateTimeFormat('en-GB', {
93+
hour: '2-digit',
94+
minute: '2-digit',
95+
hour12: false,
96+
timeZone: timezone,
97+
}).format(new Date(iso))
98+
}

0 commit comments

Comments
 (0)