Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d8b0e9b
Memoize DOMPurify sanitize per programme item
aJanuary Jul 9, 2026
1714cc2
Memoize programme filtering, keep "hide past items" live
aJanuary Jul 9, 2026
e74254e
Reduce GC pressure from mounting the programme list
aJanuary Jul 10, 2026
9655135
Replace O(n*m) participant lookup with a Map
aJanuary Jul 9, 2026
0979243
Progressively mount the programme list across React transitions
aJanuary Jul 10, 2026
34af58a
Render the filter bar immediately with disabled placeholders
aJanuary Jul 9, 2026
f3a56f9
Replace buffered comparison timestamps with epoch milliseconds
aJanuary Jul 9, 2026
20dc609
Hoist global store subscriptions out of ProgramItem
aJanuary Jul 10, 2026
234e97d
Avoid wrapper-object allocation in ProgramItem's action selectors
aJanuary Jul 9, 2026
aeb0157
Fade in programme items as they mount
aJanuary Jul 9, 2026
0e8f089
Use contain: layout for items
aJanuary Jul 10, 2026
5ecb770
Skip redundant reprocessing when a program refresh is unchanged
aJanuary Jul 10, 2026
a4d6816
Remove the "Show more" button and the item limit
aJanuary Jul 11, 2026
26d63b6
Show a loading indicator while the programme list mounts
aJanuary Jul 11, 2026
d68bb5c
Pause the programme reveal while the user interacts
aJanuary Jul 11, 2026
a8ff53d
Fix time-format caches never getting cache hits
aJanuary Jul 11, 2026
ebc0fee
Defer the search input
aJanuary Jul 11, 2026
524a02d
Precompute each item's convention day at data load
aJanuary Jul 11, 2026
797fbf2
Snap bulk expand/collapse instead of animating every item
aJanuary Jul 11, 2026
53f3fcc
Fix endless re-render loop on the location routes
aJanuary Jul 12, 2026
a22c9e8
Add infinite scroll with a device-adaptive page size
aJanuary Jul 12, 2026
b666c8d
Scope item style invalidation for cheap chunk appends
aJanuary Jul 14, 2026
ec0fd9d
Drop LocalTime's localStorage cache; fix local-time aliasing bug
aJanuary Jul 14, 2026
99bfada
Sanitize item descriptions on expand, not on mount
aJanuary Jul 14, 2026
b95ef5e
Back selection/expansion lookups with Sets
aJanuary Jul 14, 2026
36ac54e
Gate the ticking clock behind programme time boundaries
aJanuary Jul 14, 2026
1cddb5b
Replace react-timezone-select with an Intl-backed picker
aJanuary Jul 15, 2026
6d074a3
Lazy-load the info page, footer markdown, and QR code
aJanuary Jul 15, 2026
99fa270
Fix infinite scroll resetting to top on every schedule refresh
aJanuary Aug 2, 2026
98d6f51
Fix example config to handle streaming tag correctly
aJanuary Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/config_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,16 @@ Copy `src/config_example.json` to `src/config.json` and customise. See the [Gett
- `APPLICATION.LOADING` (object, required)
- `APPLICATION.LOADING.MESSAGE` (string, required) — Message to display while loading.
- `PROGRAM` (object, required) — Settings for the main programme list, My Schedule, and shared-items pages.
- `PROGRAM.LIMIT` (object, required) — Controls the "maximum items displayed" drop-down on the programme page.
- `PROGRAM.LOADING_MORE_MESSAGE` (string) — Message to display at the bottom of the programme list while items are still loading in.
- `PROGRAM.LIMIT` (object) — Controls the "maximum items displayed" drop-down on the programme page. Ignored when INFINITE_SCROLL is enabled.
- `PROGRAM.LIMIT.INFINITE_SCROLL` (boolean) — If true (the default), the programme list loads more items as the user scrolls, and the limit drop-down / "Show more" button are ignored.
- `PROGRAM.LIMIT.INITIAL_PAGE_SIZE` (integer) — Starting page size for the adaptive infinite-scroll loader before it has learned the device's actual capacity.
- `PROGRAM.LIMIT.SHOW` (boolean) — If true, "limit number of items" drop-down will be displayed.
- `PROGRAM.LIMIT.LABEL` (string) — Label for limit drop-down.
- `PROGRAM.LIMIT.OPTIONS` (array of integer) — Options for the limit drop-down.
- `PROGRAM.LIMIT.ALL_LABEL` (string) — Label to show for the "All" entry.
- `PROGRAM.LIMIT.DEFAULT` (integer, required) — Default value for the limit drop-down.
- `PROGRAM.LIMIT.SHOW_MORE` (object, required)
- `PROGRAM.LIMIT.DEFAULT` (integer) — Default value for the limit drop-down.
- `PROGRAM.LIMIT.SHOW_MORE` (object)
- `PROGRAM.LIMIT.SHOW_MORE.LABEL` (string, required) — Label for the "Show more" button.
- `PROGRAM.LIMIT.SHOW_MORE.NO_MORE` (string, required) — Message to display when no more items are available.
- `PROGRAM.LIMIT.SHOW_MORE.NUM_EXTRA` (integer, required) — Number of items to add when "Show more" is pressed.
Expand Down
31 changes: 1 addition & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@
"react-router-dom": "^7.18.1",
"react-select": "^5.10.2",
"react-spring": "^10.0.4",
"react-timezone-select": "^3.3.3",
"react-use-measure": "^2.1.7",
"remark-gfm": "^4.0.1",
"timezone-soft": "^1.5.2"
"remark-gfm": "^4.0.1"
},
"scripts": {
"start": "vite",
Expand Down
69 changes: 66 additions & 3 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,36 @@ div.switch-wrapper {
padding-top: 1rem;
}

/* Shown at the bottom of the list while the progressive reveal is still
* mounting chunks. */
.program-loading-more {
display: flex;
align-items: center;
gap: 0.5em;
font-style: italic;
padding: 1rem 0;
}

.program-loading-more-spinner {
width: 1em;
height: 1em;
border: 2px solid var(--gray-400);
border-top-color: currentColor;
border-radius: 50%;
}

@media (prefers-reduced-motion: no-preference) {
.program-loading-more-spinner {
animation: program-loading-more-spin 0.8s linear infinite;
}
}

@keyframes program-loading-more-spin {
to {
transform: rotate(360deg);
}
}

.program-container {
margin-top: 0.5rem;
clear: both;
Expand Down Expand Up @@ -1146,13 +1176,31 @@ div.switch-wrapper {
margin-bottom: 0.25rem;
max-width: 100%;
display: flex;
contain: layout style;
}

.item ~ .item {
.item:not(:first-child) {
border-top: 1px solid var(--gray-400);
padding-top: 0.5rem;
}

/* Only plays on a given item's first mount - React reuses the same DOM node
* on re-render, so it never re-triggers. */
@media (prefers-reduced-motion: no-preference) {
.item {
animation: item-fade-in 0.25s ease-out;
}
}

@keyframes item-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.item-selection {
min-width: 3ch;
vertical-align: baseline;
Expand Down Expand Up @@ -1192,10 +1240,18 @@ div.switch-wrapper {
font-size: 1.5rem;
line-height: 1rem;
overflow: auto;
transition: transform 0.3s ease-in-out, color 0.2s ease;
}

.item-chevron-expanded {
color: var(--brand-700);
transform: rotate(180deg);
}

@media (prefers-reduced-motion: reduce) {
.item-chevron {
transition: none;
}
}

.item-title {
Expand Down Expand Up @@ -1320,8 +1376,15 @@ div.switch-wrapper {

.item-details {
overflow: hidden;
/* Give enough space for the focus ring on the header above. */
margin-top: 3px;
}

/* Space for the focus ring on the header above. Padding on the measured
* wrapper - not margin on .item-details - so the gap is part of the
* animated height and clips away at height 0. A margin would still occupy
* space while the (zero-height) details element is mounted, nudging the
* row taller before an expand snaps open and after a collapse snaps shut. */
.item-details-measure {
padding-top: 3px;
}

/* .item-entry > .item-details.item-details-expanded {
Expand Down
90 changes: 63 additions & 27 deletions src/ProgramData.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ export class ProgramData {
static regex =
/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{3})?(Z)?([+-]\d{2}:\d{2})?/;

/**
* Hash of the given strings, joined with a separator very unlikely to
* appear in real content (to avoid boundary-concatenation false matches).
*
* @param {string[]} parts
* @returns {Promise<string>} Hex-encoded SHA-256 digest.
*/
static async fingerprint(parts) {
const bytes = new TextEncoder().encode(parts.join("\u0000"));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Process a program item and return the date and time as a ZonedDateTime.
*
Expand Down Expand Up @@ -66,10 +81,15 @@ export class ProgramData {
program.map((item) => {
const startTime = this.processDateAndTime(item);
item.startDateAndTime = startTime.withTimeZone(utcTimeZone);
item.bufferedStartDateAndTime = item.startDateAndTime.subtract({ minutes: 20});
item.endDateAndTime = item.startDateAndTime.add({ minutes: item.mins ? item.mins : 0});
item.bufferedEndDateAndTime = item.endDateAndTime.add({ minutes: 10});
item.timeSlot = LocalTime.getTimeSlot(item.startDateAndTime);
// Which convention day the item belongs to. Precomputed because the
// timezone-aware rounding is far too expensive to run per item per
// render when grouping the programme into days.
item.dayKey = item.startDateAndTime
.withTimeZone(LocalTime.conventionTimeZone)
.round({ smallestUnit: "day", roundingMode: "floor" })
.toString();
return item;
});
program.sort((a, b) => {
Expand Down Expand Up @@ -114,14 +134,13 @@ export class ProgramData {
* @param {array} people
*/
static addProgramParticipantDetails(program, people) {
const peopleById = new Map(people.map((person) => [person.id, person]));
// Add extra participant info to program participants.
for (let item of program) {
if (item.people) {
// Loop through people backwards, so we don't miss anyone if entries are removed.
for (let index = item.people.length - 1; index >= 0; index--) {
let fullPerson = people.find(
(fullPerson) => fullPerson.id === item.people[index].id
);
let fullPerson = peopleById.get(item.people[index].id);
if (!fullPerson) {
item.people.splice(index, 1);
}
Expand Down Expand Up @@ -412,63 +431,80 @@ export class ProgramData {
}

/**
* Fetch and parse program, people, and info.
* Fetch the info-page markdown.
*
* @param {boolean} firstTime
* @returns {string}
*/
static async fetchInfo(firstTime) {
return this.fetchText(
configData.INFORMATION.MARKDOWN_URL,
firstTime ? configData.FETCH_OPTIONS_FIRST : configData.FETCH_OPTIONS
);
}

/**
* Fetch and parse program and people.
*
* Throws on fetch or parse errors; the caller is responsible for surfacing
* the failure to the user. Data-source configuration is validated at build
* time (see vite.config.js), so config.json is assumed valid here.
*
* @returns {object}
* @param {boolean} firstTime
* @param {string} [previousFingerprint] Fingerprint of the previously
* fetched payload (see fingerprint()).
* @returns {{fingerprint: string, data: object|null}} `data` is null when
* the fetched payload is unchanged from the previous fetch.
*/
static async fetchData(firstTime) {
static async fetchData(firstTime, previousFingerprint) {
console.log("Fetching:", firstTime ? "First time" : "Refreshing");
const fetchOptions = firstTime
? configData.FETCH_OPTIONS_FIRST
: configData.FETCH_OPTIONS;

let program, people, info;
let program, people, rawParts;
if (configData.DATA_URLS) {
const { COMBINED, SCHEDULE, PEOPLE } = configData.DATA_URLS;
if (COMBINED) {
let raw;
[raw, info] = await Promise.all([
this.fetchText(COMBINED, fetchOptions),
this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions),
]);
const raw = await this.fetchText(COMBINED, fetchOptions);
rawParts = [raw];
({ program, people } = this.parseSchemaVersionedData(raw, "data"));
} else {
let rawSchedule, rawPeople;
[rawSchedule, rawPeople, info] = await Promise.all([
const [rawSchedule, rawPeople] = await Promise.all([
this.fetchText(SCHEDULE, fetchOptions),
this.fetchText(PEOPLE, fetchOptions),
this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions),
]);
rawParts = [rawSchedule, rawPeople];
program = this.parseSchemaVersionedData(rawSchedule, "schedule").program;
people = this.parseSchemaVersionedData(rawPeople, "people").people;
}
} else {
// Legacy path: always the v1 bare-array format.
if (configData.PROGRAM_DATA_URL === configData.PEOPLE_DATA_URL) {
let raw;
[raw, info] = await Promise.all([
this.fetchText(configData.PROGRAM_DATA_URL, fetchOptions),
this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions),
]);
const raw = await this.fetchText(configData.PROGRAM_DATA_URL, fetchOptions);
rawParts = [raw];
[program, people] = JsonParse.extractJson(raw);
} else {
let rawProgram, rawPeople;
[rawProgram, rawPeople, info] = await Promise.all([
const [rawProgram, rawPeople] = await Promise.all([
this.fetchText(configData.PROGRAM_DATA_URL, fetchOptions),
this.fetchText(configData.PEOPLE_DATA_URL, fetchOptions),
this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions),
]);
rawParts = [rawProgram, rawPeople];
program = JsonParse.extractJson(rawProgram)[0];
people = JsonParse.extractJson(rawPeople)[0];
}
}

// A background refresh commonly comes back byte-identical to what's
// already loaded. Returning early keeps the previously processed arrays
// (and their references) in place, so reprocessing and downstream
// re-rendering are skipped for data that hasn't actually changed.
const fingerprint = await ProgramData.fingerprint(rawParts);
if (fingerprint === previousFingerprint) {
return { fingerprint, data: null };
}

const data = ProgramData.processData(program, people);
data.info = info;
return data;
return { fingerprint, data };
}
}
Loading
Loading