From d8b0e9b77b7a8e88aaed0c254ce7bb817e0f18de Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 17:35:22 +0100 Subject: [PATCH 01/30] Memoize DOMPurify sanitize per programme item Sanitizing item.desc on every render was wasted work once an item was already mounted, since the description never changes after initial load. --- src/components/ProgramItem.jsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 239955d..052db98 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -14,7 +14,7 @@ import Participant from "./Participant"; import configData from "../config.json"; import PropTypes from "prop-types"; import { Temporal } from "@js-temporal/polyfill"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { LocalTime } from "../utils/LocalTime"; import { downloadIcs } from "../utils/ICalendar"; import { venueForLocation } from "../utils/Venues"; @@ -124,9 +124,9 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { ); }); } - const safeDesc = DOMPurify.sanitize( - item.desc, - configData.ITEM_DESCRIPTION?.PURIFY_OPTIONS + const safeDesc = useMemo( + () => DOMPurify.sanitize(item.desc, configData.ITEM_DESCRIPTION?.PURIFY_OPTIONS), + [item.desc] ); const links = []; From 1714cc2cac5bfb24b60343a3a725827e6eb8bd31 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 17:35:22 +0100 Subject: [PATCH 02/30] Memoize programme filtering, keep "hide past items" live applyFilters ran a fresh linear pass over the whole programme on every render with no memoization. Wrapping it in useMemo requires accounting for its hidden wall-clock dependency: LocalTime.filterPastItems and isDuringCon each read Temporal.Now directly, so the memo couldn't otherwise tell when "past" had shifted enough to change the result. Both functions now take `now` as a required parameter instead of defaulting to the real time internally, so that dependency is visible at every call site rather than hidden. That ripples out to every consumer of "hide past items" logic (ShowPastItems, MySchedule, UnfilterableProgram) and to ProgramList's own relative-time display, all of which now need a `now` value to pass in. A new useTickingNow() hook centralizes the actual 10-second timer so each of those call sites gets a consistent value without duplicating timer setup, rather than one component owning it and others falling back to their own. --- src/components/FilterableProgram.jsx | 231 +++++++++++++------------ src/components/ItemById.jsx | 4 +- src/components/ItemByIdList.jsx | 4 +- src/components/MySchedule.jsx | 10 +- src/components/Person.jsx | 4 +- src/components/ProgramList.jsx | 12 +- src/components/ShowPastItems.jsx | 10 +- src/components/UnfilterableProgram.jsx | 8 +- src/hooks/useTickingNow.js | 23 +++ src/utils/LocalTime.js | 12 +- 10 files changed, 184 insertions(+), 134 deletions(-) create mode 100644 src/hooks/useTickingNow.js diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index 11d62ea..82da013 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import ReactSelect from "react-select"; import { useStoreState, useStoreActions } from "easy-peasy"; @@ -10,6 +10,112 @@ import ProgramList from "./ProgramList"; import ShowPastItems from "./ShowPastItems"; import { LocalTime } from "../utils/LocalTime"; import { buildLocationOptions, locationMatchesSelection } from "../utils/Venues"; +import { useTickingNow } from "../hooks/useTickingNow"; + +/** + * Apply hide before time filter. + * @param {array} program Program to filter. + * @param {string} minDay Earliest selected day. + * @param {string} hideBefore Time of day to hide items before. + * @returns {array} The program with items before start removed. + */ +function filterHideBefore(program, minDay, hideBefore) { + const beforeDate = Temporal.ZonedDateTime.from( + minDay + "T" + hideBefore + "[" + configData.TIMEZONE + "]" + ); + return program.filter( + (item) => + Temporal.ZonedDateTime.compare(beforeDate, item.startDateAndTime) <= 0 + ); +} + +/** + * Apply filters to the program array. + * @param {array} program Array of program items. + * @param {object} filters Current filter selections. + * @returns {array} The filtered array. + */ +function applyFilters(program, { search, selLoc, selTags, showPastItems, hideBefore, tags, now }) { + const term = search.trim().toLowerCase(); + + // If no filters, return full program; + if (term.length === 0 && selLoc.length === 0 && selTags === 0) + return program; + + let filtered = program; + + // Filter by search term. + if (term.length) { + filtered = filtered.filter((item) => { + if (item.title && item.title.toLowerCase().includes(term)) return true; + if (item.desc && item.desc.toLowerCase().includes(term)) return true; + if (item.people) { + for (const person of item.people) { + if (person.name && person.name.toLowerCase().includes(term)) + return true; + } + } + return false; + }); + } + // Filter by location + if (selLoc.length) { + filtered = filtered.filter((item) => { + for (const location of item.loc) { + for (const selected of selLoc) { + if (locationMatchesSelection(location, selected.value, configData)) return true; + } + } + return false; + }); + } + // Filter by each tag dropdown. + for (const tagType in selTags) { + if (selTags[tagType].length) { + filtered = filtered.filter((item) => { + for (const tag of item.tags) { + for (const selected of selTags[tagType]) { + if (selected.value === tag.value) return true; + } + } + return false; + }); + } + } + if (LocalTime.isDuringCon(program, now) && !showPastItems) { + filtered = LocalTime.filterPastItems(filtered, now); + } + if (!configData.HIDE_BEFORE.HIDE && hideBefore) { + if ( + "days" in selTags && + Array.isArray(selTags.days) && + selTags.days.length > 0 + ) { + // if days selected, take start time for first selected day. + const minDay = selTags.days.reduce( + (acc, curr) => (curr.value < acc ? curr.value : acc), + selTags.days[0].value + ); + filtered = filterHideBefore(filtered, minDay, hideBefore); + } else if (filtered[0] && "tags" in filtered[0]) { + // If days tag present on items get date of first programme item. + const tag = filtered[0].tags.find((item) => item.category === "days"); + if (tag && "value" in tag) { + const minDay = tag.value; + filtered = filterHideBefore(filtered, minDay, hideBefore); + } + } else { + // As backup get date from drop-down items. + const labelledDays = tags.days.filter((item) => typeof item.label !== "undefined"); + const minDay = labelledDays.reduce( + (acc, curr) => (curr.value < acc ? curr.value : acc), + labelledDays[0].value + ); + filtered = filterHideBefore(filtered, minDay, hideBefore); + } + } + return filtered; +} const FilterableProgram = () => { const navigate = useNavigate(); @@ -62,7 +168,21 @@ const FilterableProgram = () => { ) ); - const filtered = applyFilters(program); + const now = useTickingNow(); + + const filtered = useMemo( + () => + applyFilters(program, { + search, + selLoc, + selTags, + showPastItems, + hideBefore, + tags, + now, + }), + [program, search, selLoc, selTags, showPastItems, hideBefore, tags, now] + ); const total = filtered.length; const totalMessage = displayLimit !== "all" && displayLimit < total @@ -97,109 +217,6 @@ const FilterableProgram = () => { navigate("/"); } - /** - * Apply hide before time filter. - * @param {array} program Program to filter. - * @param {array} days Days to choose earliest from. - * @returns {array} The program with items before start removed. - */ - function filterHideBefore(program, minDay) { - const beforeDate = Temporal.ZonedDateTime.from( - minDay + "T" + hideBefore + "[" + configData.TIMEZONE + "]" - ); - return program.filter( - (item) => - Temporal.ZonedDateTime.compare(beforeDate, item.startDateAndTime) <= 0 - ); - } - - /** - * Apply filters to the program array. - * @param {array} program Array of program items. - * @returns {array} The filtered array. - */ - function applyFilters(program) { - const term = search.trim().toLowerCase(); - - // If no filters, return full program; - if (term.length === 0 && selLoc.length === 0 && selTags === 0) - return program; - - let filtered = program; - - // Filter by search term. - if (term.length) { - filtered = filtered.filter((item) => { - if (item.title && item.title.toLowerCase().includes(term)) return true; - if (item.desc && item.desc.toLowerCase().includes(term)) return true; - if (item.people) { - for (const person of item.people) { - if (person.name && person.name.toLowerCase().includes(term)) - return true; - } - } - return false; - }); - } - // Filter by location - if (selLoc.length) { - filtered = filtered.filter((item) => { - for (const location of item.loc) { - for (const selected of selLoc) { - if (locationMatchesSelection(location, selected.value, configData)) return true; - } - } - return false; - }); - } - // Filter by each tag dropdown. - for (const tagType in selTags) { - if (selTags[tagType].length) { - filtered = filtered.filter((item) => { - for (const tag of item.tags) { - for (const selected of selTags[tagType]) { - if (selected.value === tag.value) return true; - } - } - return false; - }); - } - } - if (LocalTime.isDuringCon(program) && !showPastItems) { - filtered = LocalTime.filterPastItems(filtered); - } - if (!configData.HIDE_BEFORE?.HIDE && hideBefore) { - if ( - "days" in selTags && - Array.isArray(selTags.days) && - selTags.days.length > 0 - ) { - // if days selected, take start time for first selected day. - const minDay = selTags.days.reduce( - (acc, curr) => (curr.value < acc ? curr.value : acc), - selTags.days[0].value - ); - filtered = filterHideBefore(filtered, minDay); - } else if (filtered[0] && "tags" in filtered[0]) { - // If days tag present on items get date of first programme item. - const tag = filtered[0].tags.find((item) => item.category === "days"); - if (tag && "value" in tag) { - const minDay = tag.value; - filtered = filterHideBefore(filtered, minDay); - } - } else { - // As backup get date from drop-down items. - const labelledDays = tags.days.filter((item) => typeof item.label !== "undefined"); - const minDay = labelledDays.reduce( - (acc, curr) => (curr.value < acc ? curr.value : acc), - labelledDays[0].value - ); - filtered = filterHideBefore(filtered, minDay); - } - } - return filtered; - } - function limitDropDown() { function displayLimit(limit) { if (limit === null) return configData.PROGRAM.LIMIT.DEFAULT; @@ -352,12 +369,12 @@ const FilterableProgram = () => {
- +
- +
diff --git a/src/components/ItemById.jsx b/src/components/ItemById.jsx index 08b56af..a8d0dc6 100644 --- a/src/components/ItemById.jsx +++ b/src/components/ItemById.jsx @@ -1,16 +1,18 @@ import { useStoreState } from "easy-peasy"; import { useParams } from "react-router-dom"; import ProgramList from "./ProgramList"; +import { useTickingNow } from "../hooks/useTickingNow"; const ItemById = () => { const program = useStoreState((state) => state.program); const params = useParams(); + const now = useTickingNow(); // Filter to select only the specified ID. const filteredProgram = program.filter( (item) => item.id.toString() === params.id ); - return ; + return ; }; export default ItemById; diff --git a/src/components/ItemByIdList.jsx b/src/components/ItemByIdList.jsx index 74e9ba3..244c218 100644 --- a/src/components/ItemByIdList.jsx +++ b/src/components/ItemByIdList.jsx @@ -2,6 +2,7 @@ import { useStoreState, useStoreActions } from "easy-peasy"; import { useParams } from "react-router-dom"; import configData from "../config.json"; import ProgramList from "./ProgramList"; +import { useTickingNow } from "../hooks/useTickingNow"; const ItemByIdList = () => { const { addSelection } = useStoreActions((actions) => ({ @@ -11,6 +12,7 @@ const ItemByIdList = () => { const params = useParams(); const itemIds = params.idList.split("~"); const program = useStoreState((state) => state.program); + const now = useTickingNow(); if (program.length === 0) return <>; // Filter to select only the specified ID. @@ -23,7 +25,7 @@ const ItemByIdList = () => {

{configData.PROGRAM.SHARED.TITLE}

{configData.PROGRAM.SHARED.DESCRIPTION}
- +
- +
- + ); diff --git a/src/components/Person.jsx b/src/components/Person.jsx index 96eac75..e1f7131 100644 --- a/src/components/Person.jsx +++ b/src/components/Person.jsx @@ -8,12 +8,14 @@ import PersonLinks from "./PersonLinks"; import ProgramList from "./ProgramList"; import Tag from "./Tag"; import configData from "../config.json"; +import { useTickingNow } from "../hooks/useTickingNow"; const Person = () => { const navigate = useNavigate(); const program = useStoreState((state) => state.program); const people = useStoreState((state) => state.people); const params = useParams(); + const now = useTickingNow(); const person = people.find((person) => person.id.toString() === params.id); if (!person) return ( @@ -61,7 +63,7 @@ const Person = () => { dangerouslySetInnerHTML={{ __html: safeBio }} /> - + ); }; diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 296708e..e448c1a 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import PropTypes from "prop-types"; import { useStoreState } from "easy-peasy"; import { LocalTime } from "../utils/LocalTime"; @@ -6,19 +6,12 @@ import Day from "./Day"; import configData from "../config.json"; import { Temporal } from "@js-temporal/polyfill"; -const ProgramList = ({ program, forceExpanded = false }) => { +const ProgramList = ({ program, now, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); useEffect(() => { LocalTime.storeCachedTimes(); }); - const [now, setNow] = useState(Temporal.Now.zonedDateTimeISO("UTC")); - useEffect(() => { - setInterval(() => { - setNow(Temporal.Now.zonedDateTimeISO("UTC")); - }, 10000); - }, []); - LocalTime.checkTimeZonesDiffer(program); const rows = []; @@ -99,6 +92,7 @@ const ProgramList = ({ program, forceExpanded = false }) => { ProgramList.propTypes = { program: PropTypes.array, + now: PropTypes.instanceOf(Temporal.ZonedDateTime).isRequired, forceExpanded: PropTypes.bool, }; diff --git a/src/components/ShowPastItems.jsx b/src/components/ShowPastItems.jsx index f276920..7961b3d 100644 --- a/src/components/ShowPastItems.jsx +++ b/src/components/ShowPastItems.jsx @@ -1,15 +1,17 @@ import { useStoreState, useStoreActions } from "easy-peasy"; +import PropTypes from "prop-types"; +import { Temporal } from "@js-temporal/polyfill"; import configData from "../config.json"; import { LocalTime } from "../utils/LocalTime"; import Switch from "./Switch"; -const ShowPastItems = () => { +const ShowPastItems = ({ now }) => { const program = useStoreState((state) => state.program); const showPastItems = useStoreState((state) => state.showPastItems); const setShowPastItems = useStoreActions( (actions) => actions.setShowPastItems ); - return LocalTime.isDuringCon(program) && + return LocalTime.isDuringCon(program, now) && configData.SHOW_PAST_ITEMS?.SHOW_CHECKBOX ? ( { ); }; +ShowPastItems.propTypes = { + now: PropTypes.instanceOf(Temporal.ZonedDateTime).isRequired, +}; + export default ShowPastItems; diff --git a/src/components/UnfilterableProgram.jsx b/src/components/UnfilterableProgram.jsx index 42194c1..e278155 100644 --- a/src/components/UnfilterableProgram.jsx +++ b/src/components/UnfilterableProgram.jsx @@ -3,21 +3,23 @@ import { useStoreState } from "easy-peasy"; import ProgramList from "./ProgramList"; import Info from "./Info"; import { LocalTime } from "../utils/LocalTime"; +import { useTickingNow } from "../hooks/useTickingNow"; const UnfilterableProgram = () => { const program = useStoreState((state) => state.program); const showPastItems = useStoreState((state) => state.showPastItems); + const now = useTickingNow(); const filtered = - LocalTime.isDuringCon(program) && !showPastItems - ? LocalTime.filterPastItems(program) + LocalTime.isDuringCon(program, now) && !showPastItems + ? LocalTime.filterPastItems(program, now) : program; return (
- +

diff --git a/src/hooks/useTickingNow.js b/src/hooks/useTickingNow.js new file mode 100644 index 0000000..68c4e12 --- /dev/null +++ b/src/hooks/useTickingNow.js @@ -0,0 +1,23 @@ +import { useState, useEffect } from "react"; +import { Temporal } from "@js-temporal/polyfill"; + +const TICK_INTERVAL_MS = 10000; + +/** + * A Temporal.ZonedDateTime that updates every 10 seconds, for components + * that depend on wall-clock time (e.g. "hide past items", link WHEN + * gating). Call this once per mounted route and pass the result down as a + * `now` prop. Components further down the tree should receive `now` as a + * prop rather than calling this hook themselves, since each call site gets + * its own independent timer. + */ +export function useTickingNow() { + const [now, setNow] = useState(() => Temporal.Now.zonedDateTimeISO("UTC")); + useEffect(() => { + const timer = setInterval(() => { + setNow(Temporal.Now.zonedDateTimeISO("UTC")); + }, TICK_INTERVAL_MS); + return () => clearInterval(timer); + }, []); + return now; +} diff --git a/src/utils/LocalTime.js b/src/utils/LocalTime.js index 0b1eeca..11e829c 100644 --- a/src/utils/LocalTime.js +++ b/src/utils/LocalTime.js @@ -401,21 +401,21 @@ export class LocalTime { * Filter out program items that have already happened. * * @param {Array} program - * @param {bool} showPastItems + * @param {Temporal.ZonedDateTime} now * @returns {Array} */ - static filterPastItems(program) { + static filterPastItems(program, now) { const adjustMinutes = configData.SHOW_PAST_ITEMS?.ADJUST_MINUTES ?? 0; if (configData.SHOW_PAST_ITEMS?.FROM_START) { // Filter by past item state. Quick hack to treat this as a filter. - const cutOff = Temporal.Now.zonedDateTimeISO("UTC").add({ + const cutOff = now.add({ minutes: adjustMinutes, }); return program.filter((item) => { return Temporal.ZonedDateTime.compare(cutOff, item.startDateAndTime) <= 0; }); } else { - const cutOff = Temporal.Now.zonedDateTimeISO("UTC").subtract({ + const cutOff = now.subtract({ minutes: adjustMinutes, }); return program.filter((item) => { @@ -431,14 +431,14 @@ export class LocalTime { * Check if currently during the convention. * * @param {Array} program + * @param {Temporal.ZonedDateTime} now * @returns {bool} */ - static isDuringCon(program) { + static isDuringCon(program, now) { //First check that program is an array. if (!program || !(program instanceof Array) || program.length === 0) { return false; } - const now = Temporal.Now.zonedDateTimeISO("UTC"); const startTime = program[0].startDateAndTime; const [lastItem] = program.slice(-1); const endTime = lastItem.endDateAndTime; From e74254eacc3e5f98515fc1dc0bae928564c758f1 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Fri, 10 Jul 2026 09:29:37 +0100 Subject: [PATCH 03/30] Reduce GC pressure from mounting the programme list A Firefox Profiler capture showed ~61% of the render window on initial load was a single browser GCMajor pause, not layout/paint/JS execution - allocation pressure from mounting ~1000 ProgramItems at once, each setting up a ResizeObserver (useMeasure) and a react-spring Controller regardless of whether the item is ever opened. - New ExpandableDetails.jsx owns the expand/collapse height animation, lazily mounted only once an item is actually interacted with (a pointerdown/focus pre-warm keeps the first-ever expand animated rather than snapping, since the spring needs to exist with a collapsed baseline before the real transition). - The chevron's rotation moved from a second react-spring Controller to a plain CSS transition (App.css): rotation is always a fixed known value (0deg/180deg), unlike height, which depends on runtime content and genuinely needs measuring - so it doesn't need JS animation at all, and no longer needs to be lazily mounted either. - ProgramItem wraps in React.memo with a custom comparator: the ticking `now` prop is compared via its derived before/during/after bucket rather than by reference, so a 10s tick only re-renders items whose bucket actually changed instead of all ~1000. - TimeSlot/Day wrap in plain React.memo. --- src/App.css | 8 ++ src/components/Day.jsx | 3 +- src/components/ExpandableDetails.jsx | 82 +++++++++++++++++ src/components/ProgramItem.jsx | 129 +++++++++++++-------------- src/components/TimeSlot.jsx | 3 +- 5 files changed, 155 insertions(+), 70 deletions(-) create mode 100644 src/components/ExpandableDetails.jsx diff --git a/src/App.css b/src/App.css index ee43411..89ce6f8 100644 --- a/src/App.css +++ b/src/App.css @@ -1192,10 +1192,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 { diff --git a/src/components/Day.jsx b/src/components/Day.jsx index 88de538..bd1c6be 100644 --- a/src/components/Day.jsx +++ b/src/components/Day.jsx @@ -1,4 +1,5 @@ import PropTypes from "prop-types"; +import { memo } from "react"; import TimeSlot from "./TimeSlot"; import { LocalTime } from "../utils/LocalTime"; import { Temporal } from "@js-temporal/polyfill"; @@ -54,4 +55,4 @@ Day.propTypes = { now: PropTypes.instanceOf(Temporal.ZonedDateTime), }; -export default Day; +export default memo(Day); diff --git a/src/components/ExpandableDetails.jsx b/src/components/ExpandableDetails.jsx new file mode 100644 index 0000000..553d940 --- /dev/null +++ b/src/components/ExpandableDetails.jsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { useSpring, animated } from "react-spring"; +import useMeasure from "react-use-measure"; +import PropTypes from "prop-types"; +import configData from "../config.json"; + +/** + * Expand/collapse animated body of a programme item. Only mounted once an + * item has actually been interacted with — useMeasure (a ResizeObserver) and + * useSpring both allocate real objects per instance, which is wasted for the + * large fraction of items a user never opens. + */ +export function ExpandableDetails({ + id, + showExpanded, + calendarLink, + permaLink, + people, + tags, + safeDesc, + links, +}) { + const [ref, bounds] = useMeasure(); + const [detailsVisible, setDetailsVisible] = useState(showExpanded); + + useEffect(() => { + if (showExpanded) setDetailsVisible(true); + }, [showExpanded]); + + const itemExpandedStyle = useSpring({ + height: showExpanded ? bounds.height : 0, + display: "block", + config: { + tension: 300, + friction: 15, + clamp: true, + ...configData.EXPAND?.SPRING_CONFIG, + }, + onRest: () => { + if (!showExpanded) setDetailsVisible(false); + }, + }); + + if (!detailsVisible) return null; + + return ( + +
+
+ {calendarLink} + {permaLink} +
+
+
    {people}
+
+
{tags}
+
+
{links}
+
+ + ); +} + +ExpandableDetails.propTypes = { + id: PropTypes.string, + showExpanded: PropTypes.bool, + calendarLink: PropTypes.node, + permaLink: PropTypes.node, + people: PropTypes.node, + tags: PropTypes.node, + safeDesc: PropTypes.string, + links: PropTypes.node, +}; diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 052db98..c0989d8 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -1,8 +1,6 @@ import DOMPurify from "dompurify"; import { useStoreState, useStoreActions } from "easy-peasy"; import { Link } from "react-router-dom"; -import useMeasure from "react-use-measure"; -import { useSpring, animated } from "react-spring"; import { IoChevronDownCircle } from "react-icons/io5"; import { HiLink } from "react-icons/hi"; import { FaStar, FaRegStar } from "react-icons/fa"; @@ -11,14 +9,30 @@ import ItemLink from "./ItemLink"; import Location from "./Location"; import Tag from "./Tag"; import Participant from "./Participant"; +import { ExpandableDetails } from "./ExpandableDetails"; import configData from "../config.json"; import PropTypes from "prop-types"; import { Temporal } from "@js-temporal/polyfill"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, memo } from "react"; import { LocalTime } from "../utils/LocalTime"; import { downloadIcs } from "../utils/ICalendar"; import { venueForLocation } from "../utils/Venues"; +/** + * Which side of "now" an item falls on. + */ +function getRelativeTime(item, now) { + if (Temporal.ZonedDateTime.compare(now, item.bufferedStartDateAndTime) < 0) { + return "before"; + } else if ( + Temporal.ZonedDateTime.compare(now, item.bufferedEndDateAndTime) < 0 + ) { + return "during"; + } else { + return "after"; + } +} + const ProgramItem = ({ item, forceExpanded = false, now }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); const show12HourTime = useStoreState((state) => state.show12HourTime); @@ -50,20 +64,6 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { else removeSelection(item.id); } - function getRelativeTime(item) { - if ( - Temporal.ZonedDateTime.compare(now, item.bufferedStartDateAndTime) < 0 - ) { - return "before"; - } else if ( - Temporal.ZonedDateTime.compare(now, item.bufferedEndDateAndTime) < 0 - ) { - return "during"; - } else { - return "after"; - } - } - let id = "item_" + item.id; const locations = []; if (Array.isArray(item.loc)) @@ -134,7 +134,7 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { configData.LINKS.forEach((link) => { if (item.links && item.links[link.NAME] && item.links[link.NAME].length) { const enabled = - !link.WHEN || link.WHEN.indexOf(getRelativeTime(item)) >= 0; + !link.WHEN || link.WHEN.indexOf(getRelativeTime(item, now)) >= 0; links.push( { ).replace("@con_time", conTime); } - const [ref, bounds] = useMeasure(); const showExpanded = !configData.INTERACTIVE || expanded || forceExpanded; - const [detailsVisible, setDetailsVisible] = useState(showExpanded); + // Mounting the details is a one-way latch: once mounted it stays mounted, + // so the spring keeps its state across repeated expand/collapse. + // pointerdown/focus fire before the click that actually toggles expansion, + // so the spring exists with a "collapsed" baseline before the first real + // transition - without this, the very first expand of any item would snap + // instead of animate. (See ExpandableDetails for why it isn't simply + // always mounted.) + const [animationReady, setAnimationReady] = useState(showExpanded); useEffect(() => { - if (showExpanded) setDetailsVisible(true); + if (showExpanded) setAnimationReady(true); }, [showExpanded]); - - const chevronExpandedClass = showExpanded ? " item-chevron-expanded" : ""; - const chevronExpandedStyle = useSpring({ - transform: showExpanded ? "rotate(180deg)" : "rotate(0deg)", - }); - const itemExpandedStyle = useSpring({ - height: showExpanded ? bounds.height : 0, - display: "block", - config: { - tension: 300, - friction: 15, - clamp: true, - ...configData.EXPAND?.SPRING_CONFIG - }, - onRest: () => { - if (!showExpanded) setDetailsVisible(false); - }, - }); + function warmUpAnimation() { + setAnimationReady(true); + } const chevron = configData.INTERACTIVE && !forceExpanded ? ( - - +
) : ( "" ); @@ -268,6 +258,8 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { className="item-header" aria-expanded={showExpanded} aria-controls={"details-" + id} + onPointerDown={warmUpAnimation} + onFocus={warmUpAnimation} >

{item.title} @@ -279,30 +271,17 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { {duration}

- {detailsVisible && ( - -
-
- {calendarLink} - {permaLink} -
-
-
    {people}
-
-
{tags}
-
-
{links}
-
- + {animationReady && ( + )}
@@ -314,4 +293,18 @@ ProgramItem.propTypes = { now: PropTypes.instanceOf(Temporal.ZonedDateTime), }; -export default ProgramItem; +/** + * `now` ticks every 10s so relative-time-gated links stay live, but on any + * given tick almost no item's before/during/after bucket changes. Comparing + * the bucket (rather than `now` itself) lets those items skip re-rendering. + */ +function areEqual(prevProps, nextProps) { + if (prevProps.item !== nextProps.item) return false; + if (prevProps.forceExpanded !== nextProps.forceExpanded) return false; + return ( + getRelativeTime(prevProps.item, prevProps.now) === + getRelativeTime(nextProps.item, nextProps.now) + ); +} + +export default memo(ProgramItem, areEqual); diff --git a/src/components/TimeSlot.jsx b/src/components/TimeSlot.jsx index b2b25d7..38e7e98 100644 --- a/src/components/TimeSlot.jsx +++ b/src/components/TimeSlot.jsx @@ -1,4 +1,5 @@ import PropTypes from "prop-types"; +import { memo } from "react"; import { useStoreState } from "easy-peasy"; import ProgramItem from "./ProgramItem"; import { LocalTime } from "../utils/LocalTime"; @@ -61,4 +62,4 @@ TimeSlot.propTypes = { now: PropTypes.instanceOf(Temporal.ZonedDateTime), }; -export default TimeSlot; +export default memo(TimeSlot); From 96551356135268f6563f91c2c08f35deab9eb96e Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 17:40:01 +0100 Subject: [PATCH 04/30] Replace O(n*m) participant lookup with a Map addProgramParticipantDetails did a linear people.find() per participant per programme item - roughly items * participants * people comparisons. Building an id -> person Map once turns each lookup into O(1). --- src/ProgramData.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ProgramData.js b/src/ProgramData.js index bc075a3..50102f6 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -114,14 +114,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); } From 097924315a51d96428adeaa80145cbc54d347168 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Fri, 10 Jul 2026 09:44:36 +0100 Subject: [PATCH 05/30] Progressively mount the programme list across React transitions Mounting ~1000 ProgramItems in one synchronous commit blocks the page for the full duration. Revealing them in chunks (visibleCount growing by CHUNK_SIZE, each step inside startTransition) marks the mount as low-priority interruptible work: user input can pre-empt an in-flight chunk instead of queueing behind it, so the page becomes interactive after the first chunk instead of after all of them. Chunks chain off the previous chunk's transition settling (isPending flipping false) rather than a fixed clock, so a slow chunk delays the next one instead of piling up. Nothing is unmounted once revealed - this only spreads the initial mount cost over time. A beforeprint listener forces the full list to reveal immediately - deliberately not a transition, so it pre-empts the in-flight reveal and printing always gets the complete programme. The programme is grouped into days once per data change rather than per render, so each fully-revealed day keeps a stable items array and memo(Day) can skip it; only the day at the reveal frontier gets a fresh slice as the budget grows. This keeps each chunk's render cost proportional to the chunk, not to everything mounted so far, and the memoized list as a whole stops unrelated store updates (expanding an item, changing selections) from rebuilding the page's most expensive subtree. The reveal only ever grows or clamps down to fit the current list - it never restarts from scratch just because `program`'s reference changed. The clamp isn't for visual safety (the reveal budget caps naturally) but to reset the growth budget: without it, widening a filter back out would mount every newly-matching item in one synchronous commit. Resetting unconditionally instead only caused a fully-revealed list to visibly flicker back down and replay the climb on every filter/search keystroke. --- src/components/FilterableProgram.jsx | 17 +++- src/components/ProgramList.jsx | 137 +++++++++++++++++++-------- 2 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index 82da013..b12bdd5 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -85,7 +85,7 @@ function applyFilters(program, { search, selLoc, selTags, showPastItems, hideBef if (LocalTime.isDuringCon(program, now) && !showPastItems) { filtered = LocalTime.filterPastItems(filtered, now); } - if (!configData.HIDE_BEFORE.HIDE && hideBefore) { + if (!configData.HIDE_BEFORE?.HIDE && hideBefore) { if ( "days" in selTags && Array.isArray(selTags.days) && @@ -188,10 +188,17 @@ const FilterableProgram = () => { displayLimit !== "all" && displayLimit < total ? `Listing ${displayLimit} of ${total} items` : `Listing ${total} items`; - const display = - configData.PROGRAM.LIMIT.SHOW && !isNaN(displayLimit) - ? filtered.slice(0, displayLimit) - : filtered; + // Memoized so ProgramList only sees a new `program` reference when the + // visible items actually change - otherwise unrelated re-renders (the + // ticking clock, etc.) would hand it a fresh array each time, defeating + // the memoization inside it. + const display = useMemo( + () => + configData.PROGRAM.LIMIT.SHOW && !isNaN(displayLimit) + ? filtered.slice(0, displayLimit) + : filtered, + [filtered, displayLimit] + ); /** * When filters change, set the display limit back to the selection. diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index e448c1a..36089f3 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { memo, useEffect, useMemo, useState, useTransition } from 'react'; import PropTypes from "prop-types"; import { useStoreState } from "easy-peasy"; import { LocalTime } from "../utils/LocalTime"; @@ -6,19 +6,85 @@ import Day from "./Day"; import configData from "../config.json"; import { Temporal } from "@js-temporal/polyfill"; +// Mount the list in chunks, each inside a transition, so user input can +// pre-empt an in-flight chunk and the page is interactive after the first +// chunk instead of after all of them. Nothing is ever unmounted once +// revealed - this spreads the initial mount cost, it isn't virtualization. +const CHUNK_SIZE = 100; +const INITIAL_CHUNK_SIZE = 10; + const ProgramList = ({ program, now, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); useEffect(() => { LocalTime.storeCachedTimes(); }); - LocalTime.checkTimeZonesDiffer(program); + // Reveal the list in chunks instead of mounting everything at once. + const [visibleCount, setVisibleCount] = useState(() => + Math.min(INITIAL_CHUNK_SIZE, program.length) + ); + const [isPending, startTransition] = useTransition(); - const rows = []; - let itemRows = []; - let curDate = null; + // Memoized so each day's items array keeps a stable identity across + // renders - that identity is what lets memo(Day) skip fully-revealed + // days below. + const days = useMemo(() => { + const out = []; + let cur = null; + program.forEach((item) => { + const itemDate = item.startDateAndTime + .withTimeZone(LocalTime.conventionTimeZone) + .round({ smallestUnit: "day", roundingMode: "floor" }); + if (cur === null || !itemDate.equals(cur.date)) { + cur = { key: itemDate.toString(), date: itemDate, items: [] }; + out.push(cur); + } + cur.items.push(item); + }); + return out; + }, [program]); + + // Corrections when `program` changes shape, made during render (an + // effect would show the uncorrected state for a frame): + // - A list that had emptied out resumes at INITIAL_CHUNK_SIZE so results + // appear in this same commit, not after the first low-priority chunk. + // - A shrunken list clamps visibleCount down - not for rendering (the + // day loop below caps naturally) but so that re-growing goes through + // the chunked transitions again instead of one giant synchronous + // commit. + let effectiveVisibleCount = visibleCount; + if (visibleCount === 0 && program.length > 0) { + effectiveVisibleCount = Math.min(INITIAL_CHUNK_SIZE, program.length); + setVisibleCount(effectiveVisibleCount); + } else if (visibleCount > program.length) { + effectiveVisibleCount = program.length; + setVisibleCount(effectiveVisibleCount); + } + + useEffect(() => { + // Each chunk is revealed inside a transition, keeping the mount + // low-priority and interruptible by input. The next chunk is queued + // when the previous one settles (isPending flips false and this effect + // re-fires), so a slow chunk delays the next rather than piling up. + if (!isPending && visibleCount < program.length) { + startTransition(() => { + setVisibleCount((count) => Math.min(count + CHUNK_SIZE, program.length)); + }); + } + }, [isPending, visibleCount, program, startTransition]); + useEffect(() => { + // Printing needs the complete list immediately. A plain (non-transition) + // update is higher priority, so it pre-empts the in-flight reveal. + function revealAllForPrint() { + setVisibleCount(program.length); + } + window.addEventListener("beforeprint", revealAllForPrint); + return () => window.removeEventListener("beforeprint", revealAllForPrint); + }, [program]); + + LocalTime.checkTimeZonesDiffer(program); - if (program === null || program.length === 0) { + if (program.length === 0) { return (
No items found.
@@ -26,37 +92,27 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { ); } - program.forEach((item) => { - const itemDate = item.startDateAndTime - .withTimeZone(LocalTime.conventionTimeZone) - .round({ smallestUnit: "day", roundingMode: "floor" }); - - if (curDate === null || !itemDate.equals(curDate)) { - if (itemRows.length > 0) { - rows.push( - - ); - itemRows = []; - } - curDate = itemDate; - } - itemRows.push(item); - }); - rows.push( - - ); + // Only the day at the reveal frontier gets a fresh slice; days before it + // receive their memoized array unchanged, so memo(Day) skips them. + const rows = []; + let remaining = effectiveVisibleCount; + for (const day of days) { + if (remaining <= 0) break; + const items = + remaining >= day.items.length + ? day.items + : day.items.slice(0, remaining); + remaining -= items.length; + rows.push( + + ); + } const conventionTime = configData.CONVENTION_TIME?.NOTICE ? ( ); + if (loadError) return ; + return (
@@ -317,6 +325,7 @@ const FilterableProgram = () => { placeholder="Select locations" options={buildLocationOptions(locations, configData)} isMulti + isDisabled={isLoading} isSearchable={configData.LOCATIONS?.SEARCHABLE} value={selLoc} onChange={(value) => { @@ -338,6 +347,7 @@ const FilterableProgram = () => { tags={tags} selTags={selTags} setSelTags={setSelTags} + isLoading={isLoading} tagConfig={configData.TAGS ?? {}} resetLimit={resetDisplayLimit} /> @@ -349,6 +359,7 @@ const FilterableProgram = () => { type="search" placeholder={configData.PROGRAM.SEARCH.SEARCH_LABEL} value={search} + disabled={isLoading} onChange={(e) => { resetDisplayLimit(); setSearch(e.target.value); @@ -365,12 +376,14 @@ const FilterableProgram = () => { {limitDropDown()}
-
{totalMessage}
+
+ {isLoading ? configData.APPLICATION.LOADING.MESSAGE : totalMessage} +
- -
@@ -380,15 +393,31 @@ const FilterableProgram = () => {
-
- -
-
-
-
{totalMessage}
+ {isLoading ? ( +
+
+ +
{"\u00A0"}
+
-
-
{moreButton}
+ ) : ( + <> +
+ +
+
+
+
{totalMessage}
+
+
+
{moreButton}
+ + )}
); }; diff --git a/src/components/LoadError.jsx b/src/components/LoadError.jsx new file mode 100644 index 0000000..99f55da --- /dev/null +++ b/src/components/LoadError.jsx @@ -0,0 +1,10 @@ +import configData from "../config.json"; + +const LoadError = () => ( +
+

{configData.APPLICATION.LOADING.MESSAGE}

+

Sorry, the program could not be loaded.

+
+); + +export default LoadError; diff --git a/src/components/Loading.jsx b/src/components/Loading.jsx index 3e8ad97..371c2cf 100644 --- a/src/components/Loading.jsx +++ b/src/components/Loading.jsx @@ -1,18 +1,12 @@ import { useStoreState } from "easy-peasy"; import configData from "../config.json"; +import LoadError from "./LoadError"; const Loading = ({ children }) => { const isLoading = useStoreState((state) => state.isLoading); const loadError = useStoreState((state) => state.loadError); - if (loadError) { - return ( -
-

{configData.APPLICATION.LOADING.MESSAGE}

-

Sorry, the program could not be loaded.

-
- ); - } + if (loadError) return ; if (isLoading) return

{configData.APPLICATION.LOADING.MESSAGE}

; diff --git a/src/components/TagSelect.jsx b/src/components/TagSelect.jsx index 70b9a74..99d5e24 100644 --- a/src/components/TagSelect.jsx +++ b/src/components/TagSelect.jsx @@ -1,11 +1,12 @@ import ReactSelect from "react-select"; -const TagSelect = ({ options, tag, selTags, setSelTags, tagData, resetLimit }) => { +const TagSelect = ({ options, tag, selTags, setSelTags, tagData, resetLimit, isDisabled = false }) => { return ( { diff --git a/src/components/TagSelectors.jsx b/src/components/TagSelectors.jsx index abf44e4..e7be940 100644 --- a/src/components/TagSelectors.jsx +++ b/src/components/TagSelectors.jsx @@ -1,6 +1,6 @@ import TagSelect from "./TagSelect"; -const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, resetLimit }) => { +const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, resetLimit, isLoading = false }) => { /** * Get the tag information for the tag category. * @param {string} tag The tag category. @@ -18,24 +18,38 @@ const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, resetLimit }) => { return tagConfig; } + // Before data has loaded, tag categories aren't known from data yet (all + // empty), but the set of categories is defined statically in config - use + // that so every dropdown still appears (disabled) instead of none at all. + function categories() { + if (!isLoading) return Object.keys(tags); + // Matches the order ProgramData.processTags actually builds the tags + // object in (per-item categories first, "days" generated last), so the + // loading skeleton's dropdown order matches the loaded order exactly. + const keys = tagConfig.SEPARATE.map((item) => item.PREFIX); + if (tagConfig.DAY_TAG.GENERATE) keys.push("days"); + return keys; + } + const tagFilters = []; - for (const tag in tags) { + for (const tag of categories()) { const tagData = findTagData(tag); - // Only add drop-down if tag type actually contains elements, and isn't marked hidden in config. - if (tags[tag].length && !tagData.HIDE) { - tagFilters.push( -
- -
- ); - } + if (tagData.HIDE) continue; + // Once loaded, only show drop-downs for tag types that actually contain elements. + if (!isLoading && !(tags[tag] && tags[tag].length)) continue; + tagFilters.push( +
+ +
+ ); } return <>{tagFilters}; }; From f3a56f9a18a1998a088e0161db7d8a8e70ce8e33 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 18:20:51 +0100 Subject: [PATCH 07/30] Replace buffered comparison timestamps with epoch milliseconds bufferedStartDateAndTime/bufferedEndDateAndTime were only ever used via Temporal.ZonedDateTime.compare() (never formatted, never timezone-aware) - two full ZonedDateTime objects (each backed by a BigInt) constructed per item purely for comparison. Storing them as epoch milliseconds instead gives identical comparison semantics at a fraction of the allocation cost, for both the one-time data processing pass and every render's getRelativeTime() call. --- src/ProgramData.js | 6 ++++-- src/components/ProgramItem.jsx | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ProgramData.js b/src/ProgramData.js index 50102f6..f77cb62 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -66,9 +66,11 @@ export class ProgramData { program.map((item) => { const startTime = this.processDateAndTime(item); item.startDateAndTime = startTime.withTimeZone(utcTimeZone); - item.bufferedStartDateAndTime = item.startDateAndTime.subtract({ minutes: 20}); + // Only ever compared against, never formatted or displayed. + item.bufferedStartEpochMs = + item.startDateAndTime.epochMilliseconds - 20 * 60000; item.endDateAndTime = item.startDateAndTime.add({ minutes: item.mins ? item.mins : 0}); - item.bufferedEndDateAndTime = item.endDateAndTime.add({ minutes: 10}); + item.bufferedEndEpochMs = item.endDateAndTime.epochMilliseconds + 10 * 60000; item.timeSlot = LocalTime.getTimeSlot(item.startDateAndTime); return item; }); diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index c0989d8..03f945d 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -22,11 +22,10 @@ import { venueForLocation } from "../utils/Venues"; * Which side of "now" an item falls on. */ function getRelativeTime(item, now) { - if (Temporal.ZonedDateTime.compare(now, item.bufferedStartDateAndTime) < 0) { + const nowEpochMs = now.epochMilliseconds; + if (nowEpochMs < item.bufferedStartEpochMs) { return "before"; - } else if ( - Temporal.ZonedDateTime.compare(now, item.bufferedEndDateAndTime) < 0 - ) { + } else if (nowEpochMs < item.bufferedEndEpochMs) { return "during"; } else { return "after"; From 20dc609d1424e582cfd737d208d5b13f2ad16626 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Fri, 10 Jul 2026 09:52:43 +0100 Subject: [PATCH 08/30] Hoist global store subscriptions out of ProgramItem showLocalTime, show12HourTime and timeZoneIsShown are all global values, not per-item, but ProgramItem subscribed to each of them independently (~1000 redundant easy-peasy subscriptions on every mount). TimeSlot already subscribed to the display settings for its own header. It now passes all four down as props, cutting ProgramItem's store subscriptions from 6 to 2 (the genuinely per-item ones: selected, expanded). The React.memo comparator now also checks these props so ettings changes and bulk expand/collapse still correctly reach every item. --- src/components/ProgramItem.jsx | 19 ++++++++++++++----- src/components/TimeSlot.jsx | 10 +++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 03f945d..4341e17 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -32,11 +32,14 @@ function getRelativeTime(item, now) { } } -const ProgramItem = ({ item, forceExpanded = false, now }) => { - const showLocalTime = useStoreState((state) => state.showLocalTime); - const show12HourTime = useStoreState((state) => state.show12HourTime); - const timeZoneIsShown = useStoreState((state) => state.timeZoneIsShown); - +const ProgramItem = ({ + item, + now, + showLocalTime, + show12HourTime, + timeZoneIsShown, + forceExpanded = false, +}) => { const selected = useStoreState((state) => state.isSelected(item.id)); const { addSelection, removeSelection } = useStoreActions((actions) => ({ addSelection: actions.addSelectionAndSync, @@ -290,6 +293,9 @@ const ProgramItem = ({ item, forceExpanded = false, now }) => { ProgramItem.propTypes = { forceExpanded: PropTypes.bool, now: PropTypes.instanceOf(Temporal.ZonedDateTime), + showLocalTime: PropTypes.string, + show12HourTime: PropTypes.bool, + timeZoneIsShown: PropTypes.bool, }; /** @@ -300,6 +306,9 @@ ProgramItem.propTypes = { function areEqual(prevProps, nextProps) { if (prevProps.item !== nextProps.item) return false; if (prevProps.forceExpanded !== nextProps.forceExpanded) return false; + if (prevProps.showLocalTime !== nextProps.showLocalTime) return false; + if (prevProps.show12HourTime !== nextProps.show12HourTime) return false; + if (prevProps.timeZoneIsShown !== nextProps.timeZoneIsShown) return false; return ( getRelativeTime(prevProps.item, prevProps.now) === getRelativeTime(nextProps.item, nextProps.now) diff --git a/src/components/TimeSlot.jsx b/src/components/TimeSlot.jsx index 38e7e98..8669e0f 100644 --- a/src/components/TimeSlot.jsx +++ b/src/components/TimeSlot.jsx @@ -39,7 +39,15 @@ const TimeSlot = ({ timeSlot, dateAndTime, items, forceExpanded = false, now }) const rows = []; items.forEach((item) => { rows.push( - + ); }); From 234e97da83cb2b7f78c8510bd2785e1539794413 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 18:32:01 +0100 Subject: [PATCH 09/30] Avoid wrapper-object allocation in ProgramItem's action selectors useStoreActions(actions => ({ a: actions.a, b: actions.b })) allocates a fresh wrapper object every render even though the action functions themselves are stable. Selecting each action individually avoids that allocation, multiplied by ~1000 items. --- src/components/ProgramItem.jsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 4341e17..1290157 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -41,16 +41,12 @@ const ProgramItem = ({ forceExpanded = false, }) => { const selected = useStoreState((state) => state.isSelected(item.id)); - const { addSelection, removeSelection } = useStoreActions((actions) => ({ - addSelection: actions.addSelectionAndSync, - removeSelection: actions.removeSelectionAndSync, - })); + const addSelection = useStoreActions((actions) => actions.addSelectionAndSync); + const removeSelection = useStoreActions((actions) => actions.removeSelectionAndSync); const expanded = useStoreState((state) => state.isExpanded(item.id)); - const { expandItem, collapseItem } = useStoreActions((actions) => ({ - expandItem: actions.expandItem, - collapseItem: actions.collapseItem, - })); + const expandItem = useStoreActions((actions) => actions.expandItem); + const collapseItem = useStoreActions((actions) => actions.collapseItem); function toggleExpanded() { if (configData.INTERACTIVE) { From aeb0157db1b5daca082175d25c037752defde38b Mon Sep 17 00:00:00 2001 From: Andrew January Date: Thu, 9 Jul 2026 22:25:07 +0100 Subject: [PATCH 10/30] Fade in programme items as they mount Gives the progressive-mount reveal a visible "still loading, making progress" signal instead of rows just popping into existence in batches. Respects prefers-reduced-motion. --- src/App.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/App.css b/src/App.css index 89ce6f8..606abd4 100644 --- a/src/App.css +++ b/src/App.css @@ -1153,6 +1153,23 @@ div.switch-wrapper { 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; From 0e8f089680a3454cd41e2735524e9f0243c71a80 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Fri, 10 Jul 2026 10:18:33 +0100 Subject: [PATCH 11/30] Use contain: layout for items Because we know that nothing inside of an item can affect the layout of anything outside of the item, this hint allows the browser to reduce the amount of layout work it needs to do, improving performance. --- src/App.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App.css b/src/App.css index 606abd4..fd86162 100644 --- a/src/App.css +++ b/src/App.css @@ -1146,6 +1146,7 @@ div.switch-wrapper { margin-bottom: 0.25rem; max-width: 100%; display: flex; + contain: layout; } .item ~ .item { From 5ecb7704471168e98497bda2d1b396b0f9df0eae Mon Sep 17 00:00:00 2001 From: Andrew January Date: Fri, 10 Jul 2026 13:19:53 +0100 Subject: [PATCH 12/30] Skip redundant reprocessing when a program refresh is unchanged A background refresh commonly comes back byte-identical to what's already loaded, since the schedule rarely changes within a given 30-minute window. Previously, every refresh unconditionally reprocessed and replaced program/people/etc. with fresh array references even when nothing changed, which made ProgramList's progressive-mount reveal treat it as a new list and restart - visibly snapping a fully-revealed page back to a partial reveal for no reason. ProgramData.fetchData now hashes the raw fetched text (SHA-256, via the fingerprint helper) and compares it against the caller-supplied previousFingerprint, skipping the per-item processing (DOMPurify sanitization, Temporal conversions, sorting, tag/location extraction) and returning data: null when unchanged. The fingerprint is a fixed-size digest rather than the raw text itself, so a large schedule/people payload isn't held in memory indefinitely just to compare against. fetchData takes the previous fingerprint as a parameter and returns the new one rather than tracking it internally, so it stays deterministic given its inputs - no hidden state to reset between calls. The store (model.js) owns lastFetchFingerprint as ordinary state and passes it through fetchProgram, consistent with how the rest of the app's state is managed. --- src/ProgramData.js | 40 ++++++++++++++++++++++++++++++++++++---- src/model.js | 22 ++++++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/ProgramData.js b/src/ProgramData.js index f77cb62..51b017d 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -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} 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. * @@ -419,15 +434,19 @@ export class ProgramData { * 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, info, rawParts; if (configData.DATA_URLS) { const { COMBINED, SCHEDULE, PEOPLE } = configData.DATA_URLS; if (COMBINED) { @@ -436,6 +455,7 @@ export class ProgramData { this.fetchText(COMBINED, fetchOptions), this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions), ]); + rawParts = [raw, info]; ({ program, people } = this.parseSchemaVersionedData(raw, "data")); } else { let rawSchedule, rawPeople; @@ -444,6 +464,7 @@ export class ProgramData { this.fetchText(PEOPLE, fetchOptions), this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions), ]); + rawParts = [rawSchedule, rawPeople, info]; program = this.parseSchemaVersionedData(rawSchedule, "schedule").program; people = this.parseSchemaVersionedData(rawPeople, "people").people; } @@ -455,6 +476,7 @@ export class ProgramData { this.fetchText(configData.PROGRAM_DATA_URL, fetchOptions), this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions), ]); + rawParts = [raw, info]; [program, people] = JsonParse.extractJson(raw); } else { let rawProgram, rawPeople; @@ -463,13 +485,23 @@ export class ProgramData { this.fetchText(configData.PEOPLE_DATA_URL, fetchOptions), this.fetchText(configData.INFORMATION.MARKDOWN_URL, fetchOptions), ]); + rawParts = [rawProgram, rawPeople, info]; 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 }; } } diff --git a/src/model.js b/src/model.js index 558ba27..7a60371 100644 --- a/src/model.js +++ b/src/model.js @@ -55,6 +55,11 @@ const model = { personTags: [], info: "", loadError: null, + // Digest of the last successfully-loaded program/people/info payload, so + // fetchProgram can tell ProgramData.fetchData whether a new fetch's + // contents actually differ - letting it skip reprocessing (and reusing + // the same array references) for a byte-identical background refresh. + lastFetchFingerprint: null, lastFetchTime: null, timeSinceLastFetch: null, helpTextDismissed: () => { @@ -85,9 +90,19 @@ const model = { darkMode: localStorage.getItem("dark_mode") ? localStorage.getItem("dark_mode") : 'browser', showSyncWarning: false, // Thunks - fetchProgram: thunk(async (actions, firstTime) => { + fetchProgram: thunk(async (actions, firstTime, { getState }) => { try { - actions.setData(await ProgramData.fetchData(firstTime)); + // data is null when the fetch came back byte-identical to what's + // already loaded - nothing to commit, but the fetch itself still + // succeeded. + const { fingerprint, data } = await ProgramData.fetchData( + firstTime, + getState().lastFetchFingerprint + ); + if (data) { + actions.setData(data); + actions.setLastFetchFingerprint(fingerprint); + } actions.setLoadError(null); actions.resetLastFetchTime(firstTime); actions.updateTimeSinceLastFetch(); @@ -195,6 +210,9 @@ const model = { state.info = data.info; }), setInfo: action((state, info) => state.info = info), + setLastFetchFingerprint: action((state, fingerprint) => { + state.lastFetchFingerprint = fingerprint; + }), setLoadError: action((state, error) => { state.loadError = error; }), From a4d68164de22a35d5bb28defd5490e21b6a652a4 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 07:31:33 +0100 Subject: [PATCH 13/30] Remove the "Show more" button and the item limit The progressive reveal now handles large lists smoothly, so paging through results with a manual button no longer serves a purpose, and neither does capping how many items are displayed. Removes the "Show more" button, the limit drop-down, and the whole PROGRAM.LIMIT config block along with its README documentation. --- docs/config_reference.md | 10 -- src/App.css | 32 ------- src/components/FilterableProgram.jsx | 137 +++------------------------ src/components/TagSelect.jsx | 3 +- src/components/TagSelectors.jsx | 3 +- src/configSchema.json | 37 +------- src/config_example.json | 12 --- src/model.js | 14 --- 8 files changed, 18 insertions(+), 230 deletions(-) diff --git a/docs/config_reference.md b/docs/config_reference.md index 99f1c9f..f134d20 100644 --- a/docs/config_reference.md +++ b/docs/config_reference.md @@ -58,16 +58,6 @@ 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.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.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. - `PROGRAM.SEARCH` (object, required) - `PROGRAM.SEARCH.SEARCH_LABEL` (string, required) — Placeholder for the programme search box. - `PROGRAM.MY_SCHEDULE` (object, required) — Settings for the My Schedule page. diff --git a/src/App.css b/src/App.css index fd86162..de2111a 100644 --- a/src/App.css +++ b/src/App.css @@ -935,14 +935,6 @@ div.switch-wrapper { width: 100%; } -.program-limit-select { - margin: 0.5em; -} - -.program-limit-select select { - font-size: 1rem; -} - .stack { flex-basis: 0; flex-grow: 2; @@ -1382,30 +1374,6 @@ div.switch-wrapper { margin: 0.25rem; } -/* When limit on displayed items, show more button. */ - -.result-more-button { - display: flex; - flex-direction: row; - justify-content: center; -} - -.show-more-button { - background-color: var(--button-primary-bg); - color: var(--button-primary-fg); - font-size: 1rem; - padding: .5rem 1.5rem; - border: none; - border-radius: 1.5rem; - cursor: pointer; -} - -.show-more-button:hover, -.show-more-button:focus { - background-color: var(--button-primary-hover-bg); - color: var(--button-primary-hover-fg); -} - /* ItemIDs page, showing shared items. */ .buttons { diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index 3d0105e..0abdf91 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import ReactSelect from "react-select"; import { useStoreState, useStoreActions } from "easy-peasy"; @@ -156,22 +156,6 @@ const FilterableProgram = () => { ); const programIsFiltered = useStoreState((state) => state.programIsFiltered); - // User selected display limit. - const programDisplayLimit = useStoreState( - (state) => state.programDisplayLimit - ); - const setProgramDisplayLimit = useStoreActions( - (actions) => actions.setProgramDisplayLimit - ); - // Current display limit, changes when user presses "show more". Resets whenever filters change. - const [displayLimit, setDisplayLimit] = useState( - parseInt( - programDisplayLimit === null - ? configData.PROGRAM.LIMIT.DEFAULT - : programDisplayLimit - ) - ); - const now = useTickingNow(); const filtered = useMemo( @@ -188,103 +172,7 @@ const FilterableProgram = () => { [program, search, selLoc, selTags, showPastItems, hideBefore, tags, now] ); const total = filtered.length; - const totalMessage = - displayLimit !== "all" && displayLimit < total - ? `Listing ${displayLimit} of ${total} items` - : `Listing ${total} items`; - // Memoized so ProgramList only sees a new `program` reference when the - // visible items actually change - otherwise unrelated re-renders (the - // ticking clock, etc.) would hand it a fresh array each time, defeating - // the memoization inside it. - const display = useMemo( - () => - configData.PROGRAM.LIMIT.SHOW && !isNaN(displayLimit) - ? filtered.slice(0, displayLimit) - : filtered, - [filtered, displayLimit] - ); - - /** - * When filters change, set the display limit back to the selection. - */ - function resetDisplayLimit() { - setDisplayLimit( - parseInt( - programDisplayLimit === null - ? configData.PROGRAM.LIMIT.DEFAULT - : programDisplayLimit - ) - ); - } - - /** - * When reset filters pressed, reset the display limit and the program filters. - */ - function resetLimitsAndFilters() { - resetDisplayLimit(); - resetProgramFilters(); - // Clearing the filters must also drop any /loc/ URL, otherwise a refresh - // restores the location filter from the path. - navigate("/"); - } - - function limitDropDown() { - function displayLimit(limit) { - if (limit === null) return configData.PROGRAM.LIMIT.DEFAULT; - if (limit === "all") return limit; - if (isNaN(limit)) return configData.PROGRAM.LIMIT.DEFAULT; - return limit; - } - if (configData.PROGRAM.LIMIT.SHOW) { - const options = configData.PROGRAM.LIMIT.OPTIONS.map((item) => ( - - )); - options.push( - - ); - return ( -
- - -
- ); - } - return ""; - } - - const moreButton = - displayLimit < total ? ( - - ) : ( - {configData.PROGRAM.LIMIT.SHOW_MORE.NO_MORE} - ); + const totalMessage = `Listing ${total} items`; // create list of options for hide before time drop-down. const hideBeforeSelect = !configData.HIDE_BEFORE || configData.HIDE_BEFORE.HIDE ? ( @@ -298,7 +186,6 @@ const FilterableProgram = () => { value={hideBefore} disabled={isLoading} onChange={(e) => { - resetDisplayLimit(); setHideBefore(e.target.value); }} > @@ -314,6 +201,16 @@ const FilterableProgram = () => {
); + /** + * When reset filters pressed, reset the program filters. Clearing the + * filters must also drop any /loc/ URL, otherwise a refresh restores the + * location filter from the path. + */ + function resetFiltersAndNavigate() { + resetProgramFilters(); + navigate("/"); + } + if (loadError) return ; return ( @@ -329,7 +226,7 @@ const FilterableProgram = () => { isSearchable={configData.LOCATIONS?.SEARCHABLE} value={selLoc} onChange={(value) => { - resetDisplayLimit(); + console.log(value); setSelLoc(value); if (value.length) { const locList = value.map((location) => encodeURIComponent(location.value)).join('~'); @@ -349,7 +246,6 @@ const FilterableProgram = () => { setSelTags={setSelTags} isLoading={isLoading} tagConfig={configData.TAGS ?? {}} - resetLimit={resetDisplayLimit} /> {hideBeforeSelect}
@@ -361,7 +257,6 @@ const FilterableProgram = () => { value={search} disabled={isLoading} onChange={(e) => { - resetDisplayLimit(); setSearch(e.target.value); }} /> @@ -370,10 +265,9 @@ const FilterableProgram = () => {
- {limitDropDown()}
@@ -408,14 +302,13 @@ const FilterableProgram = () => { ) : ( <>
- +
{totalMessage}
-
{moreButton}
)}
diff --git a/src/components/TagSelect.jsx b/src/components/TagSelect.jsx index 99d5e24..abfdf45 100644 --- a/src/components/TagSelect.jsx +++ b/src/components/TagSelect.jsx @@ -1,6 +1,6 @@ import ReactSelect from "react-select"; -const TagSelect = ({ options, tag, selTags, setSelTags, tagData, resetLimit, isDisabled = false }) => { +const TagSelect = ({ options, tag, selTags, setSelTags, tagData, isDisabled = false }) => { return ( { - resetLimit(); let selections = { ...selTags }; selections[tag] = value; setSelTags(selections); diff --git a/src/components/TagSelectors.jsx b/src/components/TagSelectors.jsx index e7be940..3023c04 100644 --- a/src/components/TagSelectors.jsx +++ b/src/components/TagSelectors.jsx @@ -1,6 +1,6 @@ import TagSelect from "./TagSelect"; -const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, resetLimit, isLoading = false }) => { +const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, isLoading = false }) => { /** * Get the tag information for the tag category. * @param {string} tag The tag category. @@ -45,7 +45,6 @@ const TagSelectors = ({ tags, selTags, setSelTags, tagConfig, resetLimit, isLoad selTags={selTags} setSelTags={setSelTags} tagData={tagData} - resetLimit={resetLimit} isDisabled={isLoading} />
diff --git a/src/configSchema.json b/src/configSchema.json index 9b705e0..4a1203f 100644 --- a/src/configSchema.json +++ b/src/configSchema.json @@ -235,43 +235,8 @@ "type": "object", "additionalProperties": false, "description": "Settings for the main programme list, My Schedule, and shared-items pages.", - "required": ["LIMIT", "SEARCH", "MY_SCHEDULE", "SHARED"], + "required": ["SEARCH", "MY_SCHEDULE", "SHARED"], "properties": { - "LIMIT": { - "type": "object", - "additionalProperties": false, - "description": "Controls the \"maximum items displayed\" drop-down on the programme page.", - "required": ["DEFAULT", "SHOW_MORE"], - "properties": { - "SHOW": { "type": "boolean", "description": "If true, \"limit number of items\" drop-down will be displayed." }, - "LABEL": { "type": "string", "description": "Label for limit drop-down." }, - "OPTIONS": { - "type": "array", - "description": "Options for the limit drop-down.", - "items": { "type": "integer" } - }, - "ALL_LABEL": { "type": "string", "description": "Label to show for the \"All\" entry." }, - "DEFAULT": { "type": "integer", "description": "Default value for the limit drop-down." }, - "SHOW_MORE": { - "type": "object", - "additionalProperties": false, - "required": ["LABEL", "NO_MORE", "NUM_EXTRA"], - "properties": { - "LABEL": { "type": "string", "description": "Label for the \"Show more\" button." }, - "NO_MORE": { "type": "string", "description": "Message to display when no more items are available." }, - "NUM_EXTRA": { "type": "integer", "description": "Number of items to add when \"Show more\" is pressed." } - } - } - }, - "if": { - "required": ["SHOW"], - "properties": { "SHOW": { "const": true } } - }, - "then": { - "required": ["OPTIONS", "ALL_LABEL", "LABEL"], - "properties": { "OPTIONS": true, "ALL_LABEL": true, "LABEL": true } - } - }, "SEARCH": { "type": "object", "additionalProperties": false, diff --git a/src/config_example.json b/src/config_example.json index 6bff87a..dbf115e 100644 --- a/src/config_example.json +++ b/src/config_example.json @@ -59,18 +59,6 @@ } }, "PROGRAM": { - "LIMIT": { - "SHOW": true, - "LABEL": "Maximum items displayed", - "OPTIONS": [50, 100, 250, 500, 1000], - "ALL_LABEL": "Show all", - "DEFAULT": 100, - "SHOW_MORE": { - "LABEL": "Show more items", - "NO_MORE": "No more items to display", - "NUM_EXTRA": 50 - } - }, "SEARCH": { "SEARCH_LABEL": "Search title, desc, or people" }, diff --git a/src/model.js b/src/model.js index 7a60371..a26e52f 100644 --- a/src/model.js +++ b/src/model.js @@ -73,7 +73,6 @@ const model = { selectedTimeZone: LocalTime.getStoredSelectedTimeZone(), showPastItems: LocalTime.getStoredPastItems(), expandedItems: [], - programDisplayLimit: localStorage.getItem("program_display_limit"), selectionStore: ProgramSelection.getSelectionStore().selections, mySelections: ProgramSelection.getSelectedIds(), currentUserId: null, @@ -298,19 +297,6 @@ const model = { state.expandedItems = []; }), - // Action for number of items displayed. - setProgramDisplayLimit: action((state, limit) => { - if (limit === "all") { - localStorage.setItem("program_display_limit", limit); - state.programDisplayLimit = limit; - } - if (!isNaN(limit)) { - localStorage.setItem("program_display_limit", limit); - state.programDisplayLimit = limit; - } - // Take no action if limit is not numeric or null. - }), - // Actions for filtering program and people. setProgramSelectedLocations: action((state, selectedLocations) => { state.programSelectedLocations = selectedLocations; From 26d63b67333294c1adba2f9e38c8a7a2809bda8e Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 07:33:19 +0100 Subject: [PATCH 14/30] Show a loading indicator while the programme list mounts A small spinner and message at the bottom of the list while the progressive reveal is still mounting chunks, so the filling-in list reads as deliberate loading rather than missing content. Keyed on the remaining count rather than the transition's isPending, which briefly flips false at every chunk boundary and would flicker the indicator. The message is configurable via PROGRAM.LOADING_MORE_MESSAGE; the spinner respects prefers-reduced-motion. --- src/App.css | 30 ++++++++++++++++++++++++++++++ src/components/ProgramList.jsx | 11 +++++++++++ src/config_example.json | 1 + 3 files changed, 42 insertions(+) diff --git a/src/App.css b/src/App.css index de2111a..589dcc0 100644 --- a/src/App.css +++ b/src/App.css @@ -1019,6 +1019,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; diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 36089f3..9d2201d 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -137,11 +137,22 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { "" ); + // Driven by the remaining count, not isPending - isPending blips false + // at every chunk boundary, which would flicker the indicator. + const loadingMore = + effectiveVisibleCount < program.length ? ( +
+
+ ) : null; + return (
{conventionTime} {localTime}
{rows}
+ {loadingMore}
); }; diff --git a/src/config_example.json b/src/config_example.json index dbf115e..c50e329 100644 --- a/src/config_example.json +++ b/src/config_example.json @@ -59,6 +59,7 @@ } }, "PROGRAM": { + "LOADING_MORE_MESSAGE": "Loading more items…", "SEARCH": { "SEARCH_LABEL": "Search title, desc, or people" }, From d68bb5cd38bc66dd729010902dd37becbce7ba4b Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 08:04:37 +0100 Subject: [PATCH 15/30] Pause the programme reveal while the user interacts Transitions keep each reveal chunk's rendering interruptible, but the commit that mounts a chunk (DOM insertion + layout) is atomic, and at ~100 items it is long enough to eat frames from a short expand/collapse animation. Rather than competing for frames, the reveal now yields outright: any pointerdown or keydown defers the next chunk until 300ms after the last interaction, so interaction-triggered animations get every frame to themselves. A chunk already in flight when the interaction starts still lands - a queued transition commit can't be recalled. --- src/components/ProgramList.jsx | 41 +++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 9d2201d..cabeed3 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useState, useTransition } from 'react'; +import { memo, useEffect, useMemo, useRef, useState, useTransition } from 'react'; import PropTypes from "prop-types"; import { useStoreState } from "easy-peasy"; import { LocalTime } from "../utils/LocalTime"; @@ -12,6 +12,12 @@ import { Temporal } from "@js-temporal/polyfill"; // revealed - this spreads the initial mount cost, it isn't virtualization. const CHUNK_SIZE = 100; const INITIAL_CHUNK_SIZE = 10; +// How long the reveal holds off after the user interacts. Transitions keep +// the mount interruptible while *rendering*, but each chunk's commit +// (DOM insertion + layout) is atomic and long enough to eat frames from a +// short expand/collapse animation - so give animations the frames outright +// rather than competing for them. +const INTERACTION_HOLD_MS = 300; const ProgramList = ({ program, now, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); @@ -61,16 +67,39 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { setVisibleCount(effectiveVisibleCount); } + const lastInteractionRef = useRef(0); useEffect(() => { - // Each chunk is revealed inside a transition, keeping the mount - // low-priority and interruptible by input. The next chunk is queued - // when the previous one settles (isPending flips false and this effect - // re-fires), so a slow chunk delays the next rather than piling up. - if (!isPending && visibleCount < program.length) { + function noteInteraction() { + lastInteractionRef.current = performance.now(); + } + window.addEventListener("pointerdown", noteInteraction, true); + window.addEventListener("keydown", noteInteraction, true); + return () => { + window.removeEventListener("pointerdown", noteInteraction, true); + window.removeEventListener("keydown", noteInteraction, true); + }; + }, []); + + useEffect(() => { + // The next chunk is queued when the previous one settles (isPending + // flips false and this effect re-fires), so a slow chunk delays the + // next rather than piling up. + if (isPending || visibleCount >= program.length) { + return undefined; + } + let timer; + function revealNextChunk() { + const heldFor = performance.now() - lastInteractionRef.current; + if (heldFor < INTERACTION_HOLD_MS) { + timer = setTimeout(revealNextChunk, INTERACTION_HOLD_MS - heldFor); + return; + } startTransition(() => { setVisibleCount((count) => Math.min(count + CHUNK_SIZE, program.length)); }); } + revealNextChunk(); + return () => clearTimeout(timer); }, [isPending, visibleCount, program, startTransition]); useEffect(() => { // Printing needs the complete list immediately. A plain (non-transition) From a8ff53d6bdfdab17f9602294cac8143da962bc79 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 08:16:14 +0100 Subject: [PATCH 16/30] Fix time-format caches never getting cache hits The per-timeslot format caches are arrays indexed by slot number, but the lookup used indexOf(timeSlot), which searches the array's *values* for the slot number. The values are cache-entry objects, so the search always came back empty and every call re-ran the Temporal timezone conversion and format - for every item, on every render that reached it. Look up by index instead. --- src/utils/LocalTime.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/utils/LocalTime.js b/src/utils/LocalTime.js index 11e829c..71da768 100644 --- a/src/utils/LocalTime.js +++ b/src/utils/LocalTime.js @@ -296,11 +296,9 @@ export class LocalTime { ampm, showTimeZone ) { - // Check if entry cached for timeslot. Create two dimensional array if not. + // Check if entry cached for timeslot. Create cache entry if not. const cacheValue = - this.conventionTimeCache.indexOf(timeSlot) >= 0 - ? this.conventionTimeCache[timeSlot] - : { dateAndTime: dateAndTime }; + this.conventionTimeCache[timeSlot] || { dateAndTime: dateAndTime }; //console.log (cacheValue, intAmPm, showTimeZone, cacheValue.indexOf(intAmPm)) const cacheKey = (ampm ? "h12_" : "h24_") + (showTimeZone ? "tz" : "no"); if (cacheValue.hasOwnProperty(cacheKey)) { @@ -325,11 +323,9 @@ export class LocalTime { */ static formatTimeInLocalTimeZone(timeSlot, dateAndTime, ampm, showTimeZone) { const dateAndTimeStr = dateAndTime.toString(); - // Check if entry cached for timeslot. Create two dimensional array if not. + // Check if entry cached for timeslot. Create cache entry if not. let cacheValue = - this.localTimeCache.indexOf(timeSlot) >= 0 - ? this.localTimeCache[timeSlot] - : { dateAndTime: dateAndTimeStr }; + this.localTimeCache[timeSlot] || { dateAndTime: dateAndTimeStr }; // Make sure we have the correct cached item. if (cacheValue.dateAndTime !== dateAndTimeStr) cacheValue = { dateAndTime: dateAndTimeStr }; From ebc0fee3b717b045dd0f0b7ac1b209363bcf7cd3 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 08:29:36 +0100 Subject: [PATCH 17/30] Defer the search input This means we don't have to apply the filter to every keystroke, improving the responsiveness of the search. --- src/components/FilterableProgram.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index 0abdf91..c9320d6 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useDeferredValue } from "react"; import { useNavigate } from "react-router-dom"; import ReactSelect from "react-select"; import { useStoreState, useStoreActions } from "easy-peasy"; @@ -158,10 +158,11 @@ const FilterableProgram = () => { const now = useTickingNow(); + const deferredSearch = useDeferredValue(search); const filtered = useMemo( () => applyFilters(program, { - search, + search: deferredSearch, selLoc, selTags, showPastItems, @@ -169,7 +170,7 @@ const FilterableProgram = () => { tags, now, }), - [program, search, selLoc, selTags, showPastItems, hideBefore, tags, now] + [program, deferredSearch, selLoc, selTags, showPastItems, hideBefore, tags, now] ); const total = filtered.length; const totalMessage = `Listing ${total} items`; From 524a02dd268105bc1107a005999e517243c1a2c7 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sat, 11 Jul 2026 08:40:05 +0100 Subject: [PATCH 18/30] Precompute each item's convention day at data load Grouping the programme list into days did a timezone-aware Temporal round() per item inside the grouping memo, so every new filtered list - e.g. each search keystroke's deferred render - paid ~1000 rounding operations before anything could mount. The convention day of an item never changes after load (day grouping is pinned to the configured convention timezone; only the *local* time display is user-selectable), so stamp a dayKey string on each item in processProgramData and group by comparing those. Day headings format directly from the key string - Temporal.PlainDate.from() accepts the zoned ISO form. --- src/ProgramData.js | 7 +++++++ src/components/ProgramList.jsx | 9 +++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ProgramData.js b/src/ProgramData.js index 51b017d..92b1a11 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -87,6 +87,13 @@ export class ProgramData { item.endDateAndTime = item.startDateAndTime.add({ minutes: item.mins ? item.mins : 0}); item.bufferedEndEpochMs = item.endDateAndTime.epochMilliseconds + 10 * 60000; 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) => { diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index cabeed3..721486e 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -38,11 +38,8 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { const out = []; let cur = null; program.forEach((item) => { - const itemDate = item.startDateAndTime - .withTimeZone(LocalTime.conventionTimeZone) - .round({ smallestUnit: "day", roundingMode: "floor" }); - if (cur === null || !itemDate.equals(cur.date)) { - cur = { key: itemDate.toString(), date: itemDate, items: [] }; + if (cur === null || item.dayKey !== cur.key) { + cur = { key: item.dayKey, items: [] }; out.push(cur); } cur.items.push(item); @@ -135,7 +132,7 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { rows.push( Date: Sat, 11 Jul 2026 09:15:37 +0100 Subject: [PATCH 19/30] Snap bulk expand/collapse instead of animating every item Expand/collapse all triggered ~1000 simultaneous react-spring height animations, each writing an inline height per frame and forcing layout - main-thread work nobody can follow visually. Bulk actions now record themselves as bulk in the store, and items apply their height change immediately (react-spring's immediate flag) while individual toggles keep animating. Items read the bulk flag non-reactively (getState at render) - an item only needs it when its own expanded state just changed, which already re-rendered it, and subscribing would re-render every item whenever the flag flipped. --- src/App.css | 11 ++++++++-- src/components/ExpandableDetails.jsx | 33 +++++++++++++++++----------- src/components/ProgramItem.jsx | 8 ++++++- src/model.js | 11 ++++++++++ 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/App.css b/src/App.css index 589dcc0..09c86fa 100644 --- a/src/App.css +++ b/src/App.css @@ -1368,8 +1368,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 { diff --git a/src/components/ExpandableDetails.jsx b/src/components/ExpandableDetails.jsx index 553d940..be802c2 100644 --- a/src/components/ExpandableDetails.jsx +++ b/src/components/ExpandableDetails.jsx @@ -14,6 +14,7 @@ export function ExpandableDetails({ id, showExpanded, calendarLink, + snap = false, permaLink, people, tags, @@ -30,6 +31,9 @@ export function ExpandableDetails({ const itemExpandedStyle = useSpring({ height: showExpanded ? bounds.height : 0, display: "block", + // Bulk expand/collapse snaps instead of animating. onRest still fires + // for immediate transitions, so the collapse path's unmount below works. + immediate: snap, config: { tension: 300, friction: 15, @@ -51,20 +55,22 @@ export function ExpandableDetails({ role="region" aria-labelledby={"header-" + id} > -
-
- {calendarLink} - {permaLink} +
+
+
+ {calendarLink} + {permaLink} +
+
+
    {people}
+
+
{tags}
+
+
{links}
-
-
    {people}
-
-
{tags}
-
-
{links}
); @@ -74,6 +80,7 @@ ExpandableDetails.propTypes = { id: PropTypes.string, showExpanded: PropTypes.bool, calendarLink: PropTypes.node, + snap: PropTypes.bool, permaLink: PropTypes.node, people: PropTypes.node, tags: PropTypes.node, diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 1290157..940a506 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -1,5 +1,5 @@ import DOMPurify from "dompurify"; -import { useStoreState, useStoreActions } from "easy-peasy"; +import { useStore, useStoreState, useStoreActions } from "easy-peasy"; import { Link } from "react-router-dom"; import { IoChevronDownCircle } from "react-icons/io5"; import { HiLink } from "react-icons/hi"; @@ -47,6 +47,11 @@ const ProgramItem = ({ const expanded = useStoreState((state) => state.isExpanded(item.id)); const expandItem = useStoreActions((actions) => actions.expandItem); const collapseItem = useStoreActions((actions) => actions.collapseItem); + // Read at render time, not subscribed: an item only needs the flag's + // value when its own expanded state just changed (which already + // re-rendered it). Subscribing would re-render every item whenever the + // flag flips between bulk and individual. + const snapExpansion = useStore().getState().expandedItemsChangedInBulk; function toggleExpanded() { if (configData.INTERACTIVE) { @@ -274,6 +279,7 @@ const ProgramItem = ({ id={id} showExpanded={showExpanded} calendarLink={calendarLink} + snap={snapExpansion} permaLink={permaLink} people={people} tags={tags} diff --git a/src/model.js b/src/model.js index a26e52f..9e046b2 100644 --- a/src/model.js +++ b/src/model.js @@ -73,6 +73,11 @@ const model = { selectedTimeZone: LocalTime.getStoredSelectedTimeZone(), showPastItems: LocalTime.getStoredPastItems(), expandedItems: [], + // Whether the most recent change to expandedItems was a bulk action + // (expand/collapse all or selected); bulk changes snap open/shut instead + // of animating. Read non-reactively (getState) by items, never subscribed + // to, so flipping it re-renders nothing itself. + expandedItemsChangedInBulk: false, selectionStore: ProgramSelection.getSelectionStore().selections, mySelections: ProgramSelection.getSelectedIds(), currentUserId: null, @@ -280,21 +285,27 @@ const model = { // Actions for expanding program items. expandItem: action((state, id) => { state.expandedItems.push(id); + state.expandedItemsChangedInBulk = false; }), collapseItem: action((state, id) => { state.expandedItems = state.expandedItems.filter((item) => item !== id); + state.expandedItemsChangedInBulk = false; }), expandAll: action((state) => { state.expandedItems = state.program.map((item) => item.id); + state.expandedItemsChangedInBulk = true; }), collapseAll: action((state) => { state.expandedItems = []; + state.expandedItemsChangedInBulk = true; }), expandSelected: action((state) => { state.expandedItems = [...state.mySelections]; + state.expandedItemsChangedInBulk = true; }), collapseSelected: action((state) => { state.expandedItems = []; + state.expandedItemsChangedInBulk = true; }), // Actions for filtering program and people. From 53f3fcc2b611fa170958be910fc199a0ecc90961 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sun, 12 Jul 2026 12:19:23 +0100 Subject: [PATCH 20/30] Fix endless re-render loop on the location routes LocationProgramme dispatched setProgramSelectedLocations during render with fresh option objects on every pass; each store update re-rendered its subscribers, which re-rendered LocationProgramme, which dispatched again - an endless per-frame loop. It surfaced as multi-second item mounts and scroll position jumping to the top on /loc/ pages, plus React's setState-during-render warnings. Sync the URL params into the store in an effect keyed on the param instead. --- src/components/LocationProgramme.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/LocationProgramme.jsx b/src/components/LocationProgramme.jsx index 48d188b..c966df4 100644 --- a/src/components/LocationProgramme.jsx +++ b/src/components/LocationProgramme.jsx @@ -11,6 +11,10 @@ const LocationProgramme = () => { ); const params = useParams(); + // Sync the URL's location list into the store in an effect, not during + // render - a render-time store update re-renders every subscriber, which + // re-renders this component, and the fresh option objects created each + // pass turn that into an endless per-frame loop. useEffect(() => { const locations = params.locList.split("~").map((loc) => decodeURIComponent(loc)); if (locations.length) { From a22c9e8dc3838dabd152465173a268d09721743b Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sun, 12 Jul 2026 12:19:42 +0100 Subject: [PATCH 21/30] Add infinite scroll with a device-adaptive page size Mounting the full programme, even progressively, leaves underpowered devices with a huge DOM and a sluggish UI. Instead, gate the chunked reveal on scroll position: an IntersectionObserver sentinel below the mount frontier lets chunks mount only until the list is one viewport ahead of the fold, so the DOM grows as the user scrolls. A filter change resets to the first page (a suffix check keeps the ticking past-items trim from resetting mid-browse), and printing still mounts everything. Chunk size doubles as the page size and adapts to the device: each uninterrupted full chunk's settle time steers the next size towards a 200ms budget (clamped to 25-400, at most halving/doubling per step). The learned size is module-level, so it survives navigation and is shared by every ProgramList view. This also restores the PROGRAM.LIMIT config block, display-limit drop-down, and "Show more" button removed in e6e738d as an opt-out: PROGRAM.LIMIT.INFINITE_SCROLL (default true, along with INITIAL_PAGE_SIZE) silently overrides them when enabled. The restored per-filter resetDisplayLimit call sites collapse into one effect keyed on the filter inputs. --- docs/config_reference.md | 13 +++ package-lock.json | 31 +------ src/App.css | 32 +++++++ src/components/FilterableProgram.jsx | 112 ++++++++++++++++++++++++- src/components/ProgramList.jsx | 121 ++++++++++++++++++++------- src/configSchema.json | 37 ++++++++ src/config_example.json | 14 ++++ src/model.js | 9 ++ src/utils/AdaptivePageSize.js | 43 ++++++++++ 9 files changed, 349 insertions(+), 63 deletions(-) create mode 100644 src/utils/AdaptivePageSize.js diff --git a/docs/config_reference.md b/docs/config_reference.md index f134d20..d7670d9 100644 --- a/docs/config_reference.md +++ b/docs/config_reference.md @@ -58,6 +58,19 @@ 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.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) — 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. - `PROGRAM.SEARCH` (object, required) - `PROGRAM.SEARCH.SEARCH_LABEL` (string, required) — Placeholder for the programme search box. - `PROGRAM.MY_SCHEDULE` (object, required) — Settings for the My Schedule page. diff --git a/package-lock.json b/package-lock.json index 9e12a3f..63ae67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,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" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.3", @@ -5257,21 +5255,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-timezone-select": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/react-timezone-select/-/react-timezone-select-3.3.3.tgz", - "integrity": "sha512-wcKk5jTR7FWhdQI43k9sHQw/97xJ80z7ambdiisKzJ++7SthxeMC1QrTOq3jINz4c5EjDkYVC324yMo2No/mtA==", - "license": "MIT", - "dependencies": { - "spacetime": "^7.12.0", - "timezone-soft": "^1.5.2" - }, - "peerDependencies": { - "react": "^16 || ^17.0.1 || ^18 || ^19", - "react-dom": "^16 || ^17.0.1 || ^18 || ^19", - "react-select": "^5.9.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -5727,12 +5710,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/spacetime": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/spacetime/-/spacetime-7.13.0.tgz", - "integrity": "sha512-QWF8VpjsRTDZ+/k6CntPjRoVpOMTc5tjEUJ5bNFrFp1aYpL6x1nKdpohuGtv8g7EfUnOIZgL4QD04py5CZTcAQ==", - "license": "Apache-2.0" - }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -5905,12 +5882,6 @@ "license": "MIT", "peer": true }, - "node_modules/timezone-soft": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/timezone-soft/-/timezone-soft-1.5.2.tgz", - "integrity": "sha512-BUr+CfBfeWXJwFAuEzPO9uF+v6sy3pL5SKLkDg4vdEhsyXgbBnpFoBCW8oEKSNTqNq9YHbVOjNb31xE7WyGmrA==", - "license": "MIT" - }, "node_modules/tiny-case": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", diff --git a/src/App.css b/src/App.css index 09c86fa..d0c0b9a 100644 --- a/src/App.css +++ b/src/App.css @@ -935,6 +935,14 @@ div.switch-wrapper { width: 100%; } +.program-limit-select { + margin: 0.5em; +} + +.program-limit-select select { + font-size: 1rem; +} + .stack { flex-basis: 0; flex-grow: 2; @@ -1411,6 +1419,30 @@ div.switch-wrapper { margin: 0.25rem; } +/* When limit on displayed items, show more button. */ + +.result-more-button { + display: flex; + flex-direction: row; + justify-content: center; +} + +.show-more-button { + background-color: var(--button-primary-bg); + color: var(--button-primary-fg); + font-size: 1rem; + padding: .5rem 1.5rem; + border: none; + border-radius: 1.5rem; + cursor: pointer; +} + +.show-more-button:hover, +.show-more-button:focus { + background-color: var(--button-primary-hover-bg); + color: var(--button-primary-hover-fg); +} + /* ItemIDs page, showing shared items. */ .buttons { diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index c9320d6..ea93094 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useDeferredValue } from "react"; +import React, { useState, useMemo, useEffect, useDeferredValue } from "react"; import { useNavigate } from "react-router-dom"; import ReactSelect from "react-select"; import { useStoreState, useStoreActions } from "easy-peasy"; @@ -12,6 +12,11 @@ import LoadError from "./LoadError"; import { LocalTime } from "../utils/LocalTime"; import { buildLocationOptions, locationMatchesSelection } from "../utils/Venues"; import { useTickingNow } from "../hooks/useTickingNow"; +import { INFINITE_SCROLL } from "../utils/AdaptivePageSize"; + +// The drop-down and "Show more" flow needs a default limit even when a +// deployed config.json predates the LIMIT block. +const DEFAULT_DISPLAY_LIMIT = configData.PROGRAM.LIMIT?.DEFAULT ?? 100; /** * Apply hide before time filter. @@ -156,6 +161,22 @@ const FilterableProgram = () => { ); const programIsFiltered = useStoreState((state) => state.programIsFiltered); + // User selected display limit. + const programDisplayLimit = useStoreState( + (state) => state.programDisplayLimit + ); + const setProgramDisplayLimit = useStoreActions( + (actions) => actions.setProgramDisplayLimit + ); + // The user's selected limit as a number ("all" and other non-numeric + // selections parse to NaN, which every comparison treats as unlimited). + const selectedLimit = () => + parseInt( + programDisplayLimit === null ? DEFAULT_DISPLAY_LIMIT : programDisplayLimit + ); + // Current display limit, changes when user presses "show more". Resets whenever filters change. + const [displayLimit, setDisplayLimit] = useState(selectedLimit); + const now = useTickingNow(); const deferredSearch = useDeferredValue(search); @@ -173,7 +194,87 @@ const FilterableProgram = () => { [program, deferredSearch, selLoc, selTags, showPastItems, hideBefore, tags, now] ); const total = filtered.length; - const totalMessage = `Listing ${total} items`; + const totalMessage = + !INFINITE_SCROLL && displayLimit < total + ? `Listing ${displayLimit} of ${total} items` + : `Listing ${total} items`; + // Memoized so ProgramList only sees a new `program` reference when the + // visible items actually change - otherwise unrelated re-renders (the + // ticking clock, etc.) would hand it a fresh array each time, defeating + // the memoization inside it. Under infinite scroll ProgramList paces the + // mounting itself, so it gets the whole filtered list. + const display = useMemo( + () => + !INFINITE_SCROLL && configData.PROGRAM.LIMIT.SHOW && !isNaN(displayLimit) + ? filtered.slice(0, displayLimit) + : filtered, + [filtered, displayLimit] + ); + + // Any filter change - including via the reset button or the location URL + // route - puts the display limit back to the selection. + useEffect(() => { + setDisplayLimit(selectedLimit()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, selLoc, selTags, hideBefore]); + + function limitDropDown() { + function normalizeLimit(limit) { + if (limit === null) return DEFAULT_DISPLAY_LIMIT; + if (limit === "all") return limit; + if (isNaN(limit)) return DEFAULT_DISPLAY_LIMIT; + return limit; + } + if (!INFINITE_SCROLL && configData.PROGRAM.LIMIT.SHOW) { + const options = configData.PROGRAM.LIMIT.OPTIONS.map((item) => ( + + )); + options.push( + + ); + return ( +
+ + +
+ ); + } + return ""; + } + + const moreButton = + displayLimit < total ? ( + + ) : ( + {configData.PROGRAM.LIMIT.SHOW_MORE.NO_MORE} + ); // create list of options for hide before time drop-down. const hideBeforeSelect = !configData.HIDE_BEFORE || configData.HIDE_BEFORE.HIDE ? ( @@ -227,7 +328,6 @@ const FilterableProgram = () => { isSearchable={configData.LOCATIONS?.SEARCHABLE} value={selLoc} onChange={(value) => { - console.log(value); setSelLoc(value); if (value.length) { const locList = value.map((location) => encodeURIComponent(location.value)).join('~'); @@ -269,6 +369,7 @@ const FilterableProgram = () => { resetFilters={resetFiltersAndNavigate} />
+ {limitDropDown()}
@@ -303,13 +404,16 @@ const FilterableProgram = () => { ) : ( <>
- +
{totalMessage}
+ {!INFINITE_SCROLL && ( +
{moreButton}
+ )} )}
diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 721486e..6690a5e 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -5,19 +5,21 @@ import { LocalTime } from "../utils/LocalTime"; import Day from "./Day"; import configData from "../config.json"; import { Temporal } from "@js-temporal/polyfill"; +import { + INFINITE_SCROLL, + currentPageSize, + reportChunkSettle, +} from "../utils/AdaptivePageSize"; // Mount the list in chunks, each inside a transition, so user input can // pre-empt an in-flight chunk and the page is interactive after the first // chunk instead of after all of them. Nothing is ever unmounted once // revealed - this spreads the initial mount cost, it isn't virtualization. -const CHUNK_SIZE = 100; +// Chunk size adapts to the device (see AdaptivePageSize). With infinite +// scroll on, chunks additionally wait for the sentinel below the mount +// frontier to come within a viewport of the fold, so the DOM only grows as +// the user scrolls instead of always mounting the whole list. const INITIAL_CHUNK_SIZE = 10; -// How long the reveal holds off after the user interacts. Transitions keep -// the mount interruptible while *rendering*, but each chunk's commit -// (DOM insertion + layout) is atomic and long enough to eat frames from a -// short expand/collapse animation - so give animations the frames outright -// rather than competing for them. -const INTERACTION_HOLD_MS = 300; const ProgramList = ({ program, now, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); @@ -49,14 +51,33 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { // Corrections when `program` changes shape, made during render (an // effect would show the uncorrected state for a frame): + // - With infinite scroll, a changed result set starts back at the first + // chunk - keeping the old count would synchronously mount every page + // the user had scrolled through onto the new results. Time-based + // trimming produces a suffix of the old array (and identical results + // re-wrapped in a fresh array are their own suffix); those must not + // reset or the list would snap back mid-browse every clock tick. // - A list that had emptied out resumes at INITIAL_CHUNK_SIZE so results // appear in this same commit, not after the first low-priority chunk. // - A shrunken list clamps visibleCount down - not for rendering (the // day loop below caps naturally) but so that re-growing goes through // the chunked transitions again instead of one giant synchronous // commit. + const [prevProgram, setPrevProgram] = useState(program); + const programChanged = program !== prevProgram; + if (programChanged) { + setPrevProgram(program); + } + const isSuffixOfPrev = + program.length > 0 && + program.length <= prevProgram.length && + program[program.length - 1] === prevProgram[prevProgram.length - 1] && + program[0] === prevProgram[prevProgram.length - program.length]; let effectiveVisibleCount = visibleCount; - if (visibleCount === 0 && program.length > 0) { + if ( + (INFINITE_SCROLL && programChanged && !isSuffixOfPrev) || + (visibleCount === 0 && program.length > 0) + ) { effectiveVisibleCount = Math.min(INITIAL_CHUNK_SIZE, program.length); setVisibleCount(effectiveVisibleCount); } else if (visibleCount > program.length) { @@ -64,10 +85,13 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { setVisibleCount(effectiveVisibleCount); } - const lastInteractionRef = useRef(0); + // A click or keypress makes React restart the in-flight transition, so + // its elapsed time says nothing about device speed. Cleared when a chunk + // is scheduled; a settle that finds it set discards its measurement. + const interactedDuringChunkRef = useRef(false); useEffect(() => { function noteInteraction() { - lastInteractionRef.current = performance.now(); + interactedDuringChunkRef.current = true; } window.addEventListener("pointerdown", noteInteraction, true); window.addEventListener("keydown", noteInteraction, true); @@ -77,27 +101,62 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { }; }, []); + const inFlightChunkRef = useRef(null); + + // With infinite scroll, chunks only mount while the sentinel below the + // frontier is within a viewport of the fold; each mounted chunk pushes + // the sentinel down, so the list fills to one viewport ahead and stops. + const [sentinelNear, setSentinelNear] = useState(false); + const sentinelRef = useRef(null); + const moreRemaining = effectiveVisibleCount < program.length; + useEffect(() => { + const node = sentinelRef.current; + if (!INFINITE_SCROLL || !node) { + return undefined; + } + const observer = new IntersectionObserver( + ([entry]) => setSentinelNear(entry.isIntersecting), + { rootMargin: "0px 0px 100% 0px" } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [moreRemaining]); + useEffect(() => { // The next chunk is queued when the previous one settles (isPending // flips false and this effect re-fires), so a slow chunk delays the // next rather than piling up. - if (isPending || visibleCount >= program.length) { + if (isPending) { return undefined; } - let timer; - function revealNextChunk() { - const heldFor = performance.now() - lastInteractionRef.current; - if (heldFor < INTERACTION_HOLD_MS) { - timer = setTimeout(revealNextChunk, INTERACTION_HOLD_MS - heldFor); - return; + const settled = inFlightChunkRef.current; + if (settled !== null) { + inFlightChunkRef.current = null; + // Tail chunks are smaller than the size being measured, so they + // can't be trusted either. + if (settled.full && !interactedDuringChunkRef.current) { + reportChunkSettle(settled.size, performance.now() - settled.start); } - startTransition(() => { - setVisibleCount((count) => Math.min(count + CHUNK_SIZE, program.length)); - }); } - revealNextChunk(); - return () => clearTimeout(timer); - }, [isPending, visibleCount, program, startTransition]); + if (visibleCount >= program.length) { + return undefined; + } + if (INFINITE_SCROLL && !sentinelNear) { + return undefined; + } + const pageSize = currentPageSize(); + const chunkSize = Math.min(pageSize, program.length - visibleCount); + interactedDuringChunkRef.current = false; + inFlightChunkRef.current = { + start: performance.now(), + size: chunkSize, + full: chunkSize === pageSize, + }; + startTransition(() => { + setVisibleCount((count) => Math.min(count + chunkSize, program.length)); + }); + return undefined; + }, [isPending, visibleCount, program, startTransition, sentinelNear]); useEffect(() => { // Printing needs the complete list immediately. A plain (non-transition) // update is higher priority, so it pre-empts the in-flight reveal. @@ -165,12 +224,15 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { // Driven by the remaining count, not isPending - isPending blips false // at every chunk boundary, which would flicker the indicator. - const loadingMore = - effectiveVisibleCount < program.length ? ( -
-
+ const loadingMore = moreRemaining ? ( +
+
+ ) : null; + const sentinel = + INFINITE_SCROLL && moreRemaining ? ( +
) : null; return ( @@ -178,6 +240,7 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { {conventionTime} {localTime}
{rows}
+ {sentinel} {loadingMore}
); diff --git a/src/configSchema.json b/src/configSchema.json index 4a1203f..f929fc7 100644 --- a/src/configSchema.json +++ b/src/configSchema.json @@ -237,6 +237,43 @@ "description": "Settings for the main programme list, My Schedule, and shared-items pages.", "required": ["SEARCH", "MY_SCHEDULE", "SHARED"], "properties": { + "LOADING_MORE_MESSAGE": { "type": "string", "description": "Message to display at the bottom of the programme list while items are still loading in." }, + "LIMIT": { + "type": "object", + "additionalProperties": false, + "description": "Controls the \"maximum items displayed\" drop-down on the programme page. Ignored when INFINITE_SCROLL is enabled.", + "properties": { + "INFINITE_SCROLL": { "type": "boolean", "description": "If true (the default), the programme list loads more items as the user scrolls, and the limit drop-down / \"Show more\" button are ignored." }, + "INITIAL_PAGE_SIZE": { "type": "integer", "description": "Starting page size for the adaptive infinite-scroll loader before it has learned the device's actual capacity." }, + "SHOW": { "type": "boolean", "description": "If true, \"limit number of items\" drop-down will be displayed." }, + "LABEL": { "type": "string", "description": "Label for limit drop-down." }, + "OPTIONS": { + "type": "array", + "description": "Options for the limit drop-down.", + "items": { "type": "integer" } + }, + "ALL_LABEL": { "type": "string", "description": "Label to show for the \"All\" entry." }, + "DEFAULT": { "type": "integer", "description": "Default value for the limit drop-down." }, + "SHOW_MORE": { + "type": "object", + "additionalProperties": false, + "required": ["LABEL", "NO_MORE", "NUM_EXTRA"], + "properties": { + "LABEL": { "type": "string", "description": "Label for the \"Show more\" button." }, + "NO_MORE": { "type": "string", "description": "Message to display when no more items are available." }, + "NUM_EXTRA": { "type": "integer", "description": "Number of items to add when \"Show more\" is pressed." } + } + } + }, + "if": { + "required": ["SHOW"], + "properties": { "SHOW": { "const": true } } + }, + "then": { + "required": ["OPTIONS", "ALL_LABEL", "LABEL"], + "properties": { "OPTIONS": true, "ALL_LABEL": true, "LABEL": true } + } + }, "SEARCH": { "type": "object", "additionalProperties": false, diff --git a/src/config_example.json b/src/config_example.json index c50e329..debea05 100644 --- a/src/config_example.json +++ b/src/config_example.json @@ -60,6 +60,20 @@ }, "PROGRAM": { "LOADING_MORE_MESSAGE": "Loading more items…", + "LIMIT": { + "INFINITE_SCROLL": true, + "INITIAL_PAGE_SIZE": 50, + "SHOW": true, + "LABEL": "Maximum items displayed", + "OPTIONS": [50, 100, 250, 500, 1000], + "ALL_LABEL": "Show all", + "DEFAULT": 100, + "SHOW_MORE": { + "LABEL": "Show more items", + "NO_MORE": "No more items to display", + "NUM_EXTRA": 50 + } + }, "SEARCH": { "SEARCH_LABEL": "Search title, desc, or people" }, diff --git a/src/model.js b/src/model.js index 9e046b2..6c8af2e 100644 --- a/src/model.js +++ b/src/model.js @@ -78,6 +78,7 @@ const model = { // of animating. Read non-reactively (getState) by items, never subscribed // to, so flipping it re-renders nothing itself. expandedItemsChangedInBulk: false, + programDisplayLimit: localStorage.getItem("program_display_limit"), selectionStore: ProgramSelection.getSelectionStore().selections, mySelections: ProgramSelection.getSelectedIds(), currentUserId: null, @@ -308,6 +309,14 @@ const model = { state.expandedItemsChangedInBulk = true; }), + // Action for number of items displayed. + setProgramDisplayLimit: action((state, limit) => { + if (limit === "all" || !isNaN(limit)) { + localStorage.setItem("program_display_limit", limit); + state.programDisplayLimit = limit; + } + }), + // Actions for filtering program and people. setProgramSelectedLocations: action((state, selectedLocations) => { state.programSelectedLocations = selectedLocations; diff --git a/src/utils/AdaptivePageSize.js b/src/utils/AdaptivePageSize.js new file mode 100644 index 0000000..5bacb61 --- /dev/null +++ b/src/utils/AdaptivePageSize.js @@ -0,0 +1,43 @@ +import configData from "../config.json"; + +// Fall back when a deployed config.json predates these keys. +const limitConfig = configData.PROGRAM.LIMIT ?? {}; + +// When true, ProgramList loads more items as the user scrolls towards the +// bottom, and the classic limit drop-down / "Show more" button are ignored. +export const INFINITE_SCROLL = limitConfig.INFINITE_SCROLL ?? true; + +const INITIAL_PAGE_SIZE = limitConfig.INITIAL_PAGE_SIZE ?? 50; + +// How long a page is allowed to take from scheduling to settling. Sizes are +// steered towards this so slow devices get small commits and fast ones fewer. +const SETTLE_BUDGET_MS = 200; +const MIN_SIZE = 25; +const MAX_SIZE = 400; + +// Module-level so the learned size survives remounts and route changes; +// deliberately not persisted across visits. +let currentSize = INITIAL_PAGE_SIZE; + +export function currentPageSize() { + return currentSize; +} + +/** + * Feed one clean (uninterrupted) chunk mount measurement. Callers must + * discard polluted measurements - interrupted transitions, interaction + * holds, partial tail chunks - rather than report them. + * @param {number} size Number of items the measured chunk mounted. + * @param {number} elapsedMs Time from scheduling the chunk to it settling. + */ +export function reportChunkSettle(size, elapsedMs) { + if (size <= 0 || elapsedMs <= 0) { + return; + } + // Ideal size is proportional to the budget; cap the per-step change so a + // single noisy reading can't swing the size wildly. + const factor = Math.min(2, Math.max(0.5, SETTLE_BUDGET_MS / elapsedMs)); + currentSize = Math.round( + Math.min(MAX_SIZE, Math.max(MIN_SIZE, size * factor)) + ); +} From b666c8d60f001be36528100780d8d9bd7e467cf5 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 14 Jul 2026 15:25:23 +0100 Subject: [PATCH 22/30] Scope item style invalidation for cheap chunk appends `.item ~ .item` makes every infinite-scroll chunk append re-match styles for all later items; `:not(:first-child)` is equivalent but append-stable. Adding `style` containment narrows invalidation further. --- src/App.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.css b/src/App.css index d0c0b9a..ec46c09 100644 --- a/src/App.css +++ b/src/App.css @@ -1176,10 +1176,10 @@ div.switch-wrapper { margin-bottom: 0.25rem; max-width: 100%; display: flex; - contain: layout; + contain: layout style; } -.item ~ .item { +.item:not(:first-child) { border-top: 1px solid var(--gray-400); padding-top: 0.5rem; } From ec0fd9d8a2adca4ee9732a6cbd42e7c1a38f7a63 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 14 Jul 2026 15:25:40 +0100 Subject: [PATCH 23/30] Drop LocalTime's localStorage cache; fix local-time aliasing bug The persisted time-format caches saved ~34ms of arithmetic per visit but cost more than that in bookkeeping (a polyfill toString() per item per data load in getTimeSlot) and carried staleness and quota risks. Removing the persistence surfaced a display bug: getTimeSlot pushed the same entry object into both the convention and local caches, and the formatters share cache keys (h12_no etc.), so on a fresh session whichever formatter ran first poisoned the other - local times rendered as convention times. The caches now get separate objects, and the slot cache is keyed by epochMilliseconds instead of object-coerced strings. ProgramList also no longer JSON.stringifies three caches to localStorage after every render. --- src/components/ProgramList.jsx | 3 -- src/utils/LocalTime.js | 60 ++++++++++------------------------ 2 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 6690a5e..c20547b 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -23,9 +23,6 @@ const INITIAL_CHUNK_SIZE = 10; const ProgramList = ({ program, now, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); - useEffect(() => { - LocalTime.storeCachedTimes(); - }); // Reveal the list in chunks instead of mounting everything at once. const [visibleCount, setVisibleCount] = useState(() => diff --git a/src/utils/LocalTime.js b/src/utils/LocalTime.js index 71da768..b8dca3d 100644 --- a/src/utils/LocalTime.js +++ b/src/utils/LocalTime.js @@ -30,15 +30,9 @@ export class LocalTime { .toLocaleString(language, { timeZoneName: "short" }) .split(" ") ); - // Load cached times. - const tsCache = localStorage.getItem("time_slot_cache"); - this.timeSlotCache = tsCache ? JSON.parse(tsCache) : {}; - const conTimeCache = localStorage.getItem("convention_time_cache"); - this.conventionTimeCache = conTimeCache ? JSON.parse(conTimeCache) : []; - const locTimeCache = localStorage.getItem( - "local_time_cache_" + this.localTimeZone - ); - this.localTimeCache = locTimeCache ? JSON.parse(locTimeCache) : []; + // Cached local-time strings depend on the selected timezone, so they + // are dropped whenever it (re)initializes. + this.localTimeCache = []; } static get localTimeClass() { @@ -150,35 +144,25 @@ export class LocalTime { } /** - * Get time slot for time. + * Get time slot for time. Slots are identified by their instant; two + * items starting at the same instant share a slot. The convention and + * local caches get *separate* entry objects - the formatters use the + * same cache keys (h12_no etc.), so a shared object would let whichever + * formatter runs first poison the other's cache. */ static getTimeSlot(dateAndTime) { - if (this.timeSlotCache.hasOwnProperty(dateAndTime)) - return this.timeSlotCache[dateAndTime]; + const key = dateAndTime.epochMilliseconds; + if (this.timeSlotCache.hasOwnProperty(key)) { + return this.timeSlotCache[key]; + } const newIndex = this.conventionTimeCache.length; - const newEntry = { dateAndTime: dateAndTime.toString() }; - this.timeSlotCache[dateAndTime] = newIndex; - this.conventionTimeCache[newIndex] = newEntry; - this.localTimeCache[newIndex] = newEntry; + this.timeSlotCache[key] = newIndex; + this.conventionTimeCache[newIndex] = {}; + this.localTimeCache[newIndex] = {}; return newIndex; } - /** - * Save cached times to Local Storage. - */ - static storeCachedTimes() { - localStorage.setItem("time_slot_cache", JSON.stringify(this.timeSlotCache)); - localStorage.setItem( - "convention_time_cache", - JSON.stringify(this.conventionTimeCache) - ); - localStorage.setItem( - "local_time_cache_" + this.localTimeZone, - JSON.stringify(this.localTimeCache) - ); - } - // Format the date as a string in user's language. static formatDateForLocaleAsUTC(date) { let language = window.navigator.userLanguage || window.navigator.language; @@ -296,10 +280,7 @@ export class LocalTime { ampm, showTimeZone ) { - // Check if entry cached for timeslot. Create cache entry if not. - const cacheValue = - this.conventionTimeCache[timeSlot] || { dateAndTime: dateAndTime }; - //console.log (cacheValue, intAmPm, showTimeZone, cacheValue.indexOf(intAmPm)) + const cacheValue = this.conventionTimeCache[timeSlot] || {}; const cacheKey = (ampm ? "h12_" : "h24_") + (showTimeZone ? "tz" : "no"); if (cacheValue.hasOwnProperty(cacheKey)) { return cacheValue[cacheKey]; @@ -322,14 +303,7 @@ export class LocalTime { * @returns {string} The formatted time. */ static formatTimeInLocalTimeZone(timeSlot, dateAndTime, ampm, showTimeZone) { - const dateAndTimeStr = dateAndTime.toString(); - // Check if entry cached for timeslot. Create cache entry if not. - let cacheValue = - this.localTimeCache[timeSlot] || { dateAndTime: dateAndTimeStr }; - // Make sure we have the correct cached item. - if (cacheValue.dateAndTime !== dateAndTimeStr) - cacheValue = { dateAndTime: dateAndTimeStr }; - //console.log("Local time slot: ", timeSlot, dateAndTime.toString(), cacheValue); + const cacheValue = this.localTimeCache[timeSlot] || {}; const cacheKey = (ampm ? "h12_" : "h24_") + (showTimeZone ? "tz" : "no"); if (cacheValue.hasOwnProperty(cacheKey)) { return cacheValue[cacheKey]; From 99bfadaa39afee38dac1565b70fc58af33cf1a1e Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 14 Jul 2026 15:25:56 +0100 Subject: [PATCH 24/30] Sanitize item descriptions on expand, not on mount DOMPurify.sanitize ran at every row's first render, but the result is only used by ExpandableDetails, which is deliberately not mounted until expansion is imminent. Moving the sanitize there means collapsed rows (the vast majority of ~1000) never pay for it. --- src/components/ExpandableDetails.jsx | 12 +++++++++--- src/components/ProgramItem.jsx | 10 ++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/ExpandableDetails.jsx b/src/components/ExpandableDetails.jsx index be802c2..3656aa9 100644 --- a/src/components/ExpandableDetails.jsx +++ b/src/components/ExpandableDetails.jsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useSpring, animated } from "react-spring"; import useMeasure from "react-use-measure"; +import DOMPurify from "dompurify"; import PropTypes from "prop-types"; import configData from "../config.json"; @@ -18,12 +19,17 @@ export function ExpandableDetails({ permaLink, people, tags, - safeDesc, + desc, links, }) { const [ref, bounds] = useMeasure(); const [detailsVisible, setDetailsVisible] = useState(showExpanded); + const safeDesc = useMemo( + () => DOMPurify.sanitize(desc, configData.ITEM_DESCRIPTION?.PURIFY_OPTIONS), + [desc] + ); + useEffect(() => { if (showExpanded) setDetailsVisible(true); }, [showExpanded]); @@ -84,6 +90,6 @@ ExpandableDetails.propTypes = { permaLink: PropTypes.node, people: PropTypes.node, tags: PropTypes.node, - safeDesc: PropTypes.string, + desc: PropTypes.string, links: PropTypes.node, }; diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 940a506..5932c2b 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import { useStore, useStoreState, useStoreActions } from "easy-peasy"; import { Link } from "react-router-dom"; import { IoChevronDownCircle } from "react-icons/io5"; @@ -13,7 +12,7 @@ import { ExpandableDetails } from "./ExpandableDetails"; import configData from "../config.json"; import PropTypes from "prop-types"; import { Temporal } from "@js-temporal/polyfill"; -import { useState, useEffect, useMemo, memo } from "react"; +import { useState, useEffect, memo } from "react"; import { LocalTime } from "../utils/LocalTime"; import { downloadIcs } from "../utils/ICalendar"; import { venueForLocation } from "../utils/Venues"; @@ -127,11 +126,6 @@ const ProgramItem = ({ ); }); } - const safeDesc = useMemo( - () => DOMPurify.sanitize(item.desc, configData.ITEM_DESCRIPTION?.PURIFY_OPTIONS), - [item.desc] - ); - const links = []; if (configData.LINKS) { configData.LINKS.forEach((link) => { @@ -283,7 +277,7 @@ const ProgramItem = ({ permaLink={permaLink} people={people} tags={tags} - safeDesc={safeDesc} + desc={item.desc} links={links} /> )} From b95ef5e5b2d889b4048289a8663b8e1ce0ee92f2 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 14 Jul 2026 15:26:15 +0100 Subject: [PATCH 25/30] Back selection/expansion lookups with Sets Every mounted ProgramItem consults isSelected and isExpanded on each store change, and Array.find per row goes quadratic once many items are expanded (~450k comparisons per store change with all 957 items expanded). State-resolver-backed Set computeds make each lookup O(1) and rebuild only when the underlying array actually changes. --- src/model.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/model.js b/src/model.js index 6c8af2e..ce12d3b 100644 --- a/src/model.js +++ b/src/model.js @@ -443,31 +443,33 @@ const model = { if (state.peopleSearch.length > 0) return true; return false; }), - isSelected: computed((state) => { - return (id) => state.mySelections.find((item) => item === id) || false; + selectedSet: computed( + [(state) => state.mySelections], + (mySelections) => new Set(mySelections) + ), + expandedSet: computed( + [(state) => state.expandedItems], + (expandedItems) => new Set(expandedItems) + ), + isSelected: computed([(state) => state.selectedSet], (selectedSet) => { + return (id) => selectedSet.has(id); }), - isExpanded: computed((state) => { - return (id) => state.expandedItems.find((item) => item === id) || false; + isExpanded: computed([(state) => state.expandedSet], (expandedSet) => { + return (id) => expandedSet.has(id); }), noneExpanded: computed((state) => state.expandedItems.length === 0), allExpanded: computed((state) => { - // Loop through all items in progrm. If any not found in expanded list, return false. for (let item of state.program) - if (!state.expandedItems.find((id) => item.id === id)) return false; - // All found, so can return true. + if (!state.expandedSet.has(item.id)) return false; return true; }), allSelectedExpanded: computed((state) => { - // Loop through all items in progrm. If any not found in expanded list, return false. for (let item of state.mySelections) - if (!state.expandedItems.find((id) => item === id)) return false; - // All found, so can return true. + if (!state.expandedSet.has(item)) return false; return true; }), getMySchedule: computed((state) => - state.program.filter((item) => - state.mySelections.find((id) => item.id === id) - ) + state.program.filter((item) => state.selectedSet.has(item.id)) ), }; From 36ac54e3a0a090246074d55ec5105f5d2d4a7eb7 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 14 Jul 2026 15:26:46 +0100 Subject: [PATCH 26/30] Gate the ticking clock behind programme time boundaries useTickingNow published a fresh Temporal object every 10 seconds, reconciling all ~957 mounted items to recompute identical answers - a ~100ms main-thread stall per tick on a throttled desktop, forever, since it fired even outside the convention. Profiles showed it as the largest steady-state cost on a fully-mounted list. Time behavior now lives in utils/ProgramTime as a single contract: boundariesOf(item) derives every epoch a decision compares the clock against; the frozen reading from programTimeAt(epochMs, program) exposes only query methods (phaseOf, isPast, isDuringCon, hidePastItems), so components cannot compare the clock against anything the gating doesn't know about; and collectBoundaries feeds useProgramTime, which checks the clock every 10s but only publishes a new reading when a boundary was crossed or the programme was replaced. Reading identity thereby doubles as the memo key for the whole tree. The tri-plicated during-con/show-past-items guard collapses into the reading's hidePastItems, and filterPastItems' per-item Temporal arithmetic becomes one precomputed epoch comparison. Verified: zero idle long tasks and zero idle commits outside the con (previously one full reconcile per tick); mid-con (mocked clock), readings publish only on boundary crossings and past items disappear as time advances. --- src/ProgramData.js | 4 - src/components/Day.jsx | 10 +- src/components/FilterableProgram.jsx | 18 ++- src/components/ItemById.jsx | 6 +- src/components/ItemByIdList.jsx | 6 +- src/components/MySchedule.jsx | 14 +- src/components/Person.jsx | 6 +- src/components/ProgramItem.jsx | 33 ++--- src/components/ProgramList.jsx | 8 +- src/components/ShowPastItems.jsx | 9 +- src/components/TimeSlot.jsx | 8 +- src/components/UnfilterableProgram.jsx | 12 +- src/hooks/useProgramTime.js | 55 +++++++ src/hooks/useTickingNow.js | 23 --- src/model.js | 4 + src/utils/LocalTime.js | 52 ------- src/utils/ProgramTime.js | 189 +++++++++++++++++++++++++ 17 files changed, 301 insertions(+), 156 deletions(-) create mode 100644 src/hooks/useProgramTime.js delete mode 100644 src/hooks/useTickingNow.js create mode 100644 src/utils/ProgramTime.js diff --git a/src/ProgramData.js b/src/ProgramData.js index 92b1a11..e23d9cd 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -81,11 +81,7 @@ export class ProgramData { program.map((item) => { const startTime = this.processDateAndTime(item); item.startDateAndTime = startTime.withTimeZone(utcTimeZone); - // Only ever compared against, never formatted or displayed. - item.bufferedStartEpochMs = - item.startDateAndTime.epochMilliseconds - 20 * 60000; item.endDateAndTime = item.startDateAndTime.add({ minutes: item.mins ? item.mins : 0}); - item.bufferedEndEpochMs = item.endDateAndTime.epochMilliseconds + 10 * 60000; 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 diff --git a/src/components/Day.jsx b/src/components/Day.jsx index bd1c6be..168a698 100644 --- a/src/components/Day.jsx +++ b/src/components/Day.jsx @@ -2,9 +2,9 @@ import PropTypes from "prop-types"; import { memo } from "react"; import TimeSlot from "./TimeSlot"; import { LocalTime } from "../utils/LocalTime"; -import { Temporal } from "@js-temporal/polyfill"; +import { programTimePropType } from "../utils/ProgramTime"; -const Day = ({ date, items, forceExpanded = false, now }) => { +const Day = ({ date, items, forceExpanded = false, programTime }) => { const day = LocalTime.formatDateForLocaleAsUTC(date); const rows = []; let itemRows = []; @@ -20,7 +20,7 @@ const Day = ({ date, items, forceExpanded = false, now }) => { dateAndTime={curDateAndTime} items={itemRows} forceExpanded={forceExpanded} - now={now} + programTime={programTime} /> ); itemRows = []; @@ -37,7 +37,7 @@ const Day = ({ date, items, forceExpanded = false, now }) => { dateAndTime={curDateAndTime} items={itemRows} forceExpanded={forceExpanded} - now={now} + programTime={programTime} /> ); @@ -52,7 +52,7 @@ const Day = ({ date, items, forceExpanded = false, now }) => { Day.propTypes = { items: PropTypes.array, forceExpanded: PropTypes.bool, - now: PropTypes.instanceOf(Temporal.ZonedDateTime), + programTime: programTimePropType, }; export default memo(Day); diff --git a/src/components/FilterableProgram.jsx b/src/components/FilterableProgram.jsx index ea93094..5bdb214 100644 --- a/src/components/FilterableProgram.jsx +++ b/src/components/FilterableProgram.jsx @@ -11,7 +11,7 @@ import ShowPastItems from "./ShowPastItems"; import LoadError from "./LoadError"; import { LocalTime } from "../utils/LocalTime"; import { buildLocationOptions, locationMatchesSelection } from "../utils/Venues"; -import { useTickingNow } from "../hooks/useTickingNow"; +import { useProgramTime } from "../hooks/useProgramTime"; import { INFINITE_SCROLL } from "../utils/AdaptivePageSize"; // The drop-down and "Show more" flow needs a default limit even when a @@ -41,7 +41,7 @@ function filterHideBefore(program, minDay, hideBefore) { * @param {object} filters Current filter selections. * @returns {array} The filtered array. */ -function applyFilters(program, { search, selLoc, selTags, showPastItems, hideBefore, tags, now }) { +function applyFilters(program, { search, selLoc, selTags, showPastItems, hideBefore, tags, programTime }) { const term = search.trim().toLowerCase(); // If no filters, return full program; @@ -88,9 +88,7 @@ function applyFilters(program, { search, selLoc, selTags, showPastItems, hideBef }); } } - if (LocalTime.isDuringCon(program, now) && !showPastItems) { - filtered = LocalTime.filterPastItems(filtered, now); - } + filtered = programTime.hidePastItems(filtered, showPastItems); if (!configData.HIDE_BEFORE?.HIDE && hideBefore) { if ( "days" in selTags && @@ -177,7 +175,7 @@ const FilterableProgram = () => { // Current display limit, changes when user presses "show more". Resets whenever filters change. const [displayLimit, setDisplayLimit] = useState(selectedLimit); - const now = useTickingNow(); + const programTime = useProgramTime(); const deferredSearch = useDeferredValue(search); const filtered = useMemo( @@ -189,9 +187,9 @@ const FilterableProgram = () => { showPastItems, hideBefore, tags, - now, + programTime, }), - [program, deferredSearch, selLoc, selTags, showPastItems, hideBefore, tags, now] + [program, deferredSearch, selLoc, selTags, showPastItems, hideBefore, tags, programTime] ); const total = filtered.length; const totalMessage = @@ -385,7 +383,7 @@ const FilterableProgram = () => {
- +
@@ -404,7 +402,7 @@ const FilterableProgram = () => { ) : ( <>
- +
diff --git a/src/components/ItemById.jsx b/src/components/ItemById.jsx index a8d0dc6..c78ee55 100644 --- a/src/components/ItemById.jsx +++ b/src/components/ItemById.jsx @@ -1,18 +1,18 @@ import { useStoreState } from "easy-peasy"; import { useParams } from "react-router-dom"; import ProgramList from "./ProgramList"; -import { useTickingNow } from "../hooks/useTickingNow"; +import { useProgramTime } from "../hooks/useProgramTime"; const ItemById = () => { const program = useStoreState((state) => state.program); const params = useParams(); - const now = useTickingNow(); + const programTime = useProgramTime(); // Filter to select only the specified ID. const filteredProgram = program.filter( (item) => item.id.toString() === params.id ); - return ; + return ; }; export default ItemById; diff --git a/src/components/ItemByIdList.jsx b/src/components/ItemByIdList.jsx index 244c218..bc90e47 100644 --- a/src/components/ItemByIdList.jsx +++ b/src/components/ItemByIdList.jsx @@ -2,7 +2,7 @@ import { useStoreState, useStoreActions } from "easy-peasy"; import { useParams } from "react-router-dom"; import configData from "../config.json"; import ProgramList from "./ProgramList"; -import { useTickingNow } from "../hooks/useTickingNow"; +import { useProgramTime } from "../hooks/useProgramTime"; const ItemByIdList = () => { const { addSelection } = useStoreActions((actions) => ({ @@ -12,7 +12,7 @@ const ItemByIdList = () => { const params = useParams(); const itemIds = params.idList.split("~"); const program = useStoreState((state) => state.program); - const now = useTickingNow(); + const programTime = useProgramTime(); if (program.length === 0) return <>; // Filter to select only the specified ID. @@ -25,7 +25,7 @@ const ItemByIdList = () => {

{configData.PROGRAM.SHARED.TITLE}

{configData.PROGRAM.SHARED.DESCRIPTION}
- +
- +
); diff --git a/src/components/Person.jsx b/src/components/Person.jsx index e1f7131..1d23a13 100644 --- a/src/components/Person.jsx +++ b/src/components/Person.jsx @@ -8,14 +8,14 @@ import PersonLinks from "./PersonLinks"; import ProgramList from "./ProgramList"; import Tag from "./Tag"; import configData from "../config.json"; -import { useTickingNow } from "../hooks/useTickingNow"; +import { useProgramTime } from "../hooks/useProgramTime"; const Person = () => { const navigate = useNavigate(); const program = useStoreState((state) => state.program); const people = useStoreState((state) => state.people); const params = useParams(); - const now = useTickingNow(); + const programTime = useProgramTime(); const person = people.find((person) => person.id.toString() === params.id); if (!person) return ( @@ -63,7 +63,7 @@ const Person = () => { dangerouslySetInnerHTML={{ __html: safeBio }} /> - +
); }; diff --git a/src/components/ProgramItem.jsx b/src/components/ProgramItem.jsx index 5932c2b..2c286b1 100644 --- a/src/components/ProgramItem.jsx +++ b/src/components/ProgramItem.jsx @@ -11,29 +11,15 @@ import Participant from "./Participant"; import { ExpandableDetails } from "./ExpandableDetails"; import configData from "../config.json"; import PropTypes from "prop-types"; -import { Temporal } from "@js-temporal/polyfill"; +import { programTimePropType } from "../utils/ProgramTime"; import { useState, useEffect, memo } from "react"; import { LocalTime } from "../utils/LocalTime"; import { downloadIcs } from "../utils/ICalendar"; import { venueForLocation } from "../utils/Venues"; -/** - * Which side of "now" an item falls on. - */ -function getRelativeTime(item, now) { - const nowEpochMs = now.epochMilliseconds; - if (nowEpochMs < item.bufferedStartEpochMs) { - return "before"; - } else if (nowEpochMs < item.bufferedEndEpochMs) { - return "during"; - } else { - return "after"; - } -} - const ProgramItem = ({ item, - now, + programTime, showLocalTime, show12HourTime, timeZoneIsShown, @@ -131,7 +117,7 @@ const ProgramItem = ({ configData.LINKS.forEach((link) => { if (item.links && item.links[link.NAME] && item.links[link.NAME].length) { const enabled = - !link.WHEN || link.WHEN.indexOf(getRelativeTime(item, now)) >= 0; + !link.WHEN || link.WHEN.indexOf(programTime.phaseOf(item)) >= 0; links.push( { +const ProgramList = ({ program, programTime, forceExpanded = false }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); // Reveal the list in chunks instead of mounting everything at once. @@ -191,7 +191,7 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { date={day.key} items={items} forceExpanded={forceExpanded} - now={now} + programTime={programTime} /> ); } @@ -245,7 +245,7 @@ const ProgramList = ({ program, now, forceExpanded = false }) => { ProgramList.propTypes = { program: PropTypes.array.isRequired, - now: PropTypes.instanceOf(Temporal.ZonedDateTime).isRequired, + programTime: programTimePropType.isRequired, forceExpanded: PropTypes.bool, }; diff --git a/src/components/ShowPastItems.jsx b/src/components/ShowPastItems.jsx index 7961b3d..d9b2068 100644 --- a/src/components/ShowPastItems.jsx +++ b/src/components/ShowPastItems.jsx @@ -1,17 +1,16 @@ import { useStoreState, useStoreActions } from "easy-peasy"; import PropTypes from "prop-types"; -import { Temporal } from "@js-temporal/polyfill"; import configData from "../config.json"; import { LocalTime } from "../utils/LocalTime"; +import { programTimePropType } from "../utils/ProgramTime"; import Switch from "./Switch"; -const ShowPastItems = ({ now }) => { - const program = useStoreState((state) => state.program); +const ShowPastItems = ({ programTime }) => { const showPastItems = useStoreState((state) => state.showPastItems); const setShowPastItems = useStoreActions( (actions) => actions.setShowPastItems ); - return LocalTime.isDuringCon(program, now) && + return programTime.isDuringCon() && configData.SHOW_PAST_ITEMS?.SHOW_CHECKBOX ? ( { }; ShowPastItems.propTypes = { - now: PropTypes.instanceOf(Temporal.ZonedDateTime).isRequired, + programTime: programTimePropType.isRequired, }; export default ShowPastItems; diff --git a/src/components/TimeSlot.jsx b/src/components/TimeSlot.jsx index 8669e0f..14064a0 100644 --- a/src/components/TimeSlot.jsx +++ b/src/components/TimeSlot.jsx @@ -3,9 +3,9 @@ import { memo } from "react"; import { useStoreState } from "easy-peasy"; import ProgramItem from "./ProgramItem"; import { LocalTime } from "../utils/LocalTime"; -import { Temporal } from "@js-temporal/polyfill"; +import { programTimePropType } from "../utils/ProgramTime"; -const TimeSlot = ({ timeSlot, dateAndTime, items, forceExpanded = false, now }) => { +const TimeSlot = ({ timeSlot, dateAndTime, items, forceExpanded = false, programTime }) => { const showLocalTime = useStoreState((state) => state.showLocalTime); const show12HourTime = useStoreState((state) => state.show12HourTime); const timeZoneIsShown = useStoreState((state) => state.timeZoneIsShown); @@ -43,7 +43,7 @@ const TimeSlot = ({ timeSlot, dateAndTime, items, forceExpanded = false, now }) key={item.id} item={item} forceExpanded={forceExpanded} - now={now} + programTime={programTime} showLocalTime={showLocalTime} show12HourTime={show12HourTime} timeZoneIsShown={timeZoneIsShown} @@ -67,7 +67,7 @@ const TimeSlot = ({ timeSlot, dateAndTime, items, forceExpanded = false, now }) TimeSlot.propTypes = { items: PropTypes.array, forceExpanded: PropTypes.bool, - now: PropTypes.instanceOf(Temporal.ZonedDateTime), + programTime: programTimePropType, }; export default memo(TimeSlot); diff --git a/src/components/UnfilterableProgram.jsx b/src/components/UnfilterableProgram.jsx index e278155..6b2e32a 100644 --- a/src/components/UnfilterableProgram.jsx +++ b/src/components/UnfilterableProgram.jsx @@ -2,24 +2,20 @@ import React from "react"; import { useStoreState } from "easy-peasy"; import ProgramList from "./ProgramList"; import Info from "./Info"; -import { LocalTime } from "../utils/LocalTime"; -import { useTickingNow } from "../hooks/useTickingNow"; +import { useProgramTime } from "../hooks/useProgramTime"; const UnfilterableProgram = () => { const program = useStoreState((state) => state.program); const showPastItems = useStoreState((state) => state.showPastItems); - const now = useTickingNow(); + const programTime = useProgramTime(); - const filtered = - LocalTime.isDuringCon(program, now) && !showPastItems - ? LocalTime.filterPastItems(program, now) - : program; + const filtered = programTime.hidePastItems(program, showPastItems); return (
- +

diff --git a/src/hooks/useProgramTime.js b/src/hooks/useProgramTime.js new file mode 100644 index 0000000..2e13d0d --- /dev/null +++ b/src/hooks/useProgramTime.js @@ -0,0 +1,55 @@ +import { useState, useEffect } from "react"; +import { useStoreState } from "easy-peasy"; +import { programTimeAt, crossedBoundary } from "../utils/ProgramTime"; + +const TICK_INTERVAL_MS = 10000; + +/** + * The programme clock, as a reading of query methods (see + * utils/ProgramTime). Call this once per mounted route and pass the + * result down as a `programTime` prop; components further down the tree + * should receive it as a prop rather than calling this hook themselves, + * since each call site gets its own independent timer. + * + * The clock is checked every 10 seconds, but a new reading is only + * published when the tick crossed a programme time boundary (the store's + * timeBoundaries computed) - on any other tick the state update bails + * out with the previous reading, so the tree above the items isn't + * reconciled every 10 seconds just to compute the same answers. + */ +export function useProgramTime() { + const program = useStoreState((state) => state.program); + const boundaries = useStoreState((state) => state.timeBoundaries); + const [current, setCurrent] = useState(() => { + const epochMs = Date.now(); + return { program, epochMs, programTime: programTimeAt(epochMs, program) }; + }); + // A reading captures the programme it was taken against, so a new + // programme (initial load, background refresh) invalidates it + // regardless of the clock. Adjusted during render so no frame shows a + // reading of the old programme against the new one. + if (current.program !== program) { + const epochMs = Date.now(); + setCurrent({ + program, + epochMs, + programTime: programTimeAt(epochMs, program), + }); + } + useEffect(() => { + const timer = setInterval(() => { + const epochMs = Date.now(); + setCurrent((prev) => + crossedBoundary(boundaries, prev.epochMs, epochMs) + ? { + program: prev.program, + epochMs, + programTime: programTimeAt(epochMs, prev.program), + } + : prev + ); + }, TICK_INTERVAL_MS); + return () => clearInterval(timer); + }, [boundaries]); + return current.programTime; +} diff --git a/src/hooks/useTickingNow.js b/src/hooks/useTickingNow.js deleted file mode 100644 index 68c4e12..0000000 --- a/src/hooks/useTickingNow.js +++ /dev/null @@ -1,23 +0,0 @@ -import { useState, useEffect } from "react"; -import { Temporal } from "@js-temporal/polyfill"; - -const TICK_INTERVAL_MS = 10000; - -/** - * A Temporal.ZonedDateTime that updates every 10 seconds, for components - * that depend on wall-clock time (e.g. "hide past items", link WHEN - * gating). Call this once per mounted route and pass the result down as a - * `now` prop. Components further down the tree should receive `now` as a - * prop rather than calling this hook themselves, since each call site gets - * its own independent timer. - */ -export function useTickingNow() { - const [now, setNow] = useState(() => Temporal.Now.zonedDateTimeISO("UTC")); - useEffect(() => { - const timer = setInterval(() => { - setNow(Temporal.Now.zonedDateTimeISO("UTC")); - }, TICK_INTERVAL_MS); - return () => clearInterval(timer); - }, []); - return now; -} diff --git a/src/model.js b/src/model.js index ce12d3b..1f0447a 100644 --- a/src/model.js +++ b/src/model.js @@ -2,6 +2,7 @@ import { action, thunk, computed } from "easy-peasy"; import { ProgramData } from "./ProgramData"; import { ProgramSelection } from "./ProgramSelection"; import { LocalTime } from "./utils/LocalTime"; +import { collectBoundaries } from "./utils/ProgramTime"; import * as SyncService from "./SyncService"; import configData from "./config.json"; @@ -443,6 +444,9 @@ const model = { if (state.peopleSearch.length > 0) return true; return false; }), + timeBoundaries: computed([(state) => state.program], (program) => + collectBoundaries(program) + ), selectedSet: computed( [(state) => state.mySelections], (mySelections) => new Set(mySelections) diff --git a/src/utils/LocalTime.js b/src/utils/LocalTime.js index b8dca3d..77842ea 100644 --- a/src/utils/LocalTime.js +++ b/src/utils/LocalTime.js @@ -367,58 +367,6 @@ export class LocalTime { ); } - /** - * Filter out program items that have already happened. - * - * @param {Array} program - * @param {Temporal.ZonedDateTime} now - * @returns {Array} - */ - static filterPastItems(program, now) { - const adjustMinutes = configData.SHOW_PAST_ITEMS?.ADJUST_MINUTES ?? 0; - if (configData.SHOW_PAST_ITEMS?.FROM_START) { - // Filter by past item state. Quick hack to treat this as a filter. - const cutOff = now.add({ - minutes: adjustMinutes, - }); - return program.filter((item) => { - return Temporal.ZonedDateTime.compare(cutOff, item.startDateAndTime) <= 0; - }); - } else { - const cutOff = now.subtract({ - minutes: adjustMinutes, - }); - return program.filter((item) => { - const itemNearEndTime = item.startDateAndTime.add({ - minutes: item.hasOwnProperty("mins") ? item.mins : adjustMinutes, - }); - return Temporal.ZonedDateTime.compare(cutOff, itemNearEndTime) <= 0; - }); - } - } - - /** - * Check if currently during the convention. - * - * @param {Array} program - * @param {Temporal.ZonedDateTime} now - * @returns {bool} - */ - static isDuringCon(program, now) { - //First check that program is an array. - if (!program || !(program instanceof Array) || program.length === 0) { - return false; - } - const startTime = program[0].startDateAndTime; - const [lastItem] = program.slice(-1); - const endTime = lastItem.endDateAndTime; - // True if between start of first item and end of last item. - // ToDo: consider edge case where the item with the latest end time is not the last item. - return ( - Temporal.ZonedDateTime.compare(now, startTime) > 0 && - Temporal.ZonedDateTime.compare(now, endTime) < 0 - ); - } } // Initialize the LocalTime class diff --git a/src/utils/ProgramTime.js b/src/utils/ProgramTime.js new file mode 100644 index 0000000..f6f4a26 --- /dev/null +++ b/src/utils/ProgramTime.js @@ -0,0 +1,189 @@ +import PropTypes from "prop-types"; +import configData from "../config.json" with { type: "json" }; + +/** + * The programme's entire view of the clock. + * + * Time-dependent behavior lives here, in three co-located parts: + * - boundariesOf(item) derives every epoch a decision compares the + * clock against from the item's schedule facts; + * - the query methods on programTimeAt's reading are the only decisions + * components can make, each comparing against a boundariesOf field; + * - collectBoundaries gathers every value boundariesOf produces, and + * useProgramTime only publishes a new reading when a tick crosses one + * (crossedBoundary) or the programme itself is replaced. + * Components never see a raw date: they can only ask these questions, so + * every possible use of a reading is one the tick gating knows about. To + * add a new time-based decision, add its threshold in boundariesOf (it + * becomes a tick boundary automatically) and add a query method that + * compares against it. + */ + +const boundariesCache = new WeakMap(); + +/** + * Every epoch a time-based decision compares the clock against, derived + * from the item's schedule facts. Only ever compared, never formatted or + * displayed. Cached per item: items are immutable once processed, and + * the queries run per item per render. + * + * @param {object} item Processed programme item (see ProgramData). + * @returns {object} Epoch milliseconds, keyed by threshold name. + */ +function boundariesOf(item) { + let boundaries = boundariesCache.get(item); + if (boundaries !== undefined) { + return boundaries; + } + const startEpochMs = item.startDateAndTime.epochMilliseconds; + const endEpochMs = item.endDateAndTime.epochMilliseconds; + // The epoch after which "hide past items" hides this item, including + // the config margin and branch. + const adjustMinutes = configData.SHOW_PAST_ITEMS?.ADJUST_MINUTES ?? 0; + const pastAdjustMs = adjustMinutes * 60000; + let pastCutoffEpochMs; + if (configData.SHOW_PAST_ITEMS?.FROM_START) { + pastCutoffEpochMs = startEpochMs - pastAdjustMs; + } else { + const nearEndEpochMs = + startEpochMs + + (item.hasOwnProperty("mins") ? item.mins : adjustMinutes) * 60000; + pastCutoffEpochMs = nearEndEpochMs + pastAdjustMs; + } + boundaries = { + // phaseOf (link WHEN gating): the "during" phase opens 20min early + // and closes 10min late. + bufferedStartEpochMs: startEpochMs - 20 * 60000, + bufferedEndEpochMs: endEpochMs + 10 * 60000, + // isPast / hidePastItems. + pastCutoffEpochMs, + // isDuringCon (first item's start, last item's end). + startEpochMs, + endEpochMs, + }; + boundariesCache.set(item, boundaries); + return boundaries; +} + +/** + * A reading of the programme clock: the time-based answers for `program` + * as of `epochMs`, frozen. useProgramTime holds one reading until its + * answers go stale (a boundary crossing or a new programme), so a + * reading's identity doubles as the memo invalidation key all the way + * down the programme tree. + * + * @param {number} epochMs + * @param {array} program Processed programme (defines the con's span). + */ +export function programTimeAt(epochMs, program) { + const conStartEpochMs = + program.length > 0 ? boundariesOf(program[0]).startEpochMs : null; + // The programme is sorted by start time, so a long item that starts + // before the last one but ends after it would be missed here. + const conEndEpochMs = + program.length > 0 + ? boundariesOf(program[program.length - 1]).endEpochMs + : null; + return Object.freeze({ + /** + * The phase of the item's lifecycle: "before", "during" (from 20min + * before start to 10min after end) or "after". Matches the link + * WHEN config vocabulary. + */ + phaseOf(item) { + const boundaries = boundariesOf(item); + if (epochMs < boundaries.bufferedStartEpochMs) { + return "before"; + } + if (epochMs < boundaries.bufferedEndEpochMs) { + return "during"; + } + return "after"; + }, + /** True once the item passes its "hide past items" cutoff. */ + isPast(item) { + return epochMs > boundariesOf(item).pastCutoffEpochMs; + }, + /** True between the first item's start and the last item's end. */ + isDuringCon() { + return ( + conStartEpochMs !== null && + epochMs > conStartEpochMs && + epochMs < conEndEpochMs + ); + }, + /** + * The "hide past items" policy: during the convention, with the + * setting off, items past their cutoff are hidden; at any other time + * (or with "show past items" on) the list is untouched. + * + * @param {Array} items The list to trim (may be a filtered subset). + * @param {bool} showPastItems The user's setting. + */ + hidePastItems(items, showPastItems) { + if (showPastItems || !this.isDuringCon()) { + return items; + } + return items.filter((item) => !this.isPast(item)); + }, + }); +} + +export const programTimePropType = PropTypes.shape({ + phaseOf: PropTypes.func.isRequired, + isPast: PropTypes.func.isRequired, + isDuringCon: PropTypes.func.isRequired, + hidePastItems: PropTypes.func.isRequired, +}); + +/** + * Every epoch at which some query above changes its answer. Between two + * consecutive boundaries, every comparison against the clock gives the + * same result, so publishing a new reading would re-render the whole + * programme tree to produce identical output. + * + * Collects every value boundariesOf derives, so a threshold added there + * is picked up automatically. + * + * @param {array} program Processed programme. + * @returns {array} Sorted epoch milliseconds. + */ +export function collectBoundaries(program) { + const collected = []; + for (const item of program) { + const boundaries = boundariesOf(item); + for (const key in boundaries) { + collected.push(boundaries[key]); + } + } + return collected.sort((a, b) => a - b); +} + +/** + * Whether a boundary lies between the two instants - i.e. whether a + * reading taken at prevEpochMs would answer anything differently at + * nextEpochMs, so a new reading needs to be published. + * + * @param {array} sorted Boundaries from collectBoundaries. + * @param {number} prevEpochMs + * @param {number} nextEpochMs + * @returns {bool} + */ +export function crossedBoundary(sorted, prevEpochMs, nextEpochMs) { + return countPassed(sorted, prevEpochMs) !== countPassed(sorted, nextEpochMs); +} + +/** Number of boundaries at or before `epochMs` (binary search). */ +function countPassed(sorted, epochMs) { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sorted[mid] <= epochMs) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} From 1cddb5bebcde7420ef9d916798921832c6e7f384 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Wed, 15 Jul 2026 07:38:38 +0100 Subject: [PATCH 27/30] Replace react-timezone-select with an Intl-backed picker react-timezone-select pulled in two bundled timezone databases (spacetime, timezone-soft) - ~125kB minified, 14% of the vendor chunk - to render one dropdown in Settings. The browser already has the data: Intl.supportedValuesOf("timeZone") for the zone list, with each zone's offset (for the label and for natural numeric sorting) taken from the Temporal polyfill's offsetNanoseconds, presented through the same react-select styling. Options are searchable by city name, IANA name (with or without underscores), or GMT offset. Engines without supportedValuesOf (pre-Safari 15.4) get a picker offering just the browser's own zone - degraded, never crashing, so the app's compatibility floor is unchanged. Known gaps versus react-timezone-select: timezone abbreviations (EST) aren't searchable, and the zone list uses CLDR's legacy names (e.g. Asia/Calcutta, not Asia/Kolkata), so some current city names won't match a search. --- package.json | 4 +- src/components/Settings.jsx | 3 +- src/components/TimeZoneSelect.jsx | 63 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src/components/TimeZoneSelect.jsx diff --git a/package.json b/package.json index 41e3333..b495bb6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 544574d..59e9714 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -1,7 +1,7 @@ import configData from "../config.json"; import { useStoreState, useStoreActions } from "easy-peasy"; import { Temporal } from "@js-temporal/polyfill"; -import TimeZoneSelect from "react-timezone-select"; +import TimeZoneSelect from "./TimeZoneSelect"; const Settings = () => { const defaultTimeZone = Temporal.Now.timeZoneId(); @@ -35,7 +35,6 @@ const Settings = () => { setSelectedTimeZone(e.value)} - labelStyle="abbrev" className="filter-container" classNamePrefix="filter-select" /> diff --git a/src/components/TimeZoneSelect.jsx b/src/components/TimeZoneSelect.jsx new file mode 100644 index 0000000..ce7722d --- /dev/null +++ b/src/components/TimeZoneSelect.jsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import ReactSelect from "react-select"; +import PropTypes from "prop-types"; +import { Temporal } from "@js-temporal/polyfill"; + +/** + * IANA timezone picker backed by the browser's own timezone data + * (Intl.supportedValuesOf) instead of a bundled database - replacing + * react-timezone-select, whose spacetime/timezone-soft dependencies were + * ~14% of the vendor bundle. + */ + +function buildOptions() { + const zones = + typeof Intl.supportedValuesOf === "function" + ? Intl.supportedValuesOf("timeZone") + : [Intl.DateTimeFormat().resolvedOptions().timeZone]; + const now = Temporal.Now.instant(); + return zones + .map((zone) => { + let offsetNs = 0; + let label = zone.replace(/_/g, " "); + try { + const zoned = now.toZonedDateTimeISO(zone); + offsetNs = zoned.offsetNanoseconds; + label += ` (GMT${zoned.offset})`; + } catch { + // Zone unknown to the Temporal polyfill: fall through with the + // bare name, sorted alongside UTC. + } + return { value: zone, label, offsetNs }; + }) + .sort( + (a, b) => a.offsetNs - b.offsetNs || a.value.localeCompare(b.value) + ); +} + +const TimeZoneSelect = ({ value, onChange, className, classNamePrefix }) => { + const options = useMemo(buildOptions, []); + const selected = + options.find((option) => option.value === value) ?? { + value, + label: value, + }; + return ( + + ); +}; + +TimeZoneSelect.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + classNamePrefix: PropTypes.string, +}; + +export default TimeZoneSelect; From 6d074a35c50cd2f79948a3dcdcff507ad633d0ad Mon Sep 17 00:00:00 2001 From: Andrew January Date: Wed, 15 Jul 2026 07:38:53 +0100 Subject: [PATCH 28/30] Lazy-load the info page, footer markdown, and QR code The programme list is what slow devices open the app for, but the eager bundle carried the whole markdown-rendering ecosystem (~155kB minified, used only by the info page and three one-line footer strings) and the QR generator (~24kB, my-schedule sharing only), and startup fetched info.md alongside the schedule. - Info and ShareLink are React.lazy route-level chunks. - The footer's markdown comes from config.json, so a Vite plugin renders it to HTML at build time (micromark, build-time only) - the footer ships no markdown code at all. - info.md is fetched by a fetchInfo thunk on first visit to /info, and drops out of the programme fetch and its change-detection fingerprint. - The forced single vendor chunk in vite.config is removed: it would pull lazily-imported dependencies back into the eager bundle, defeating the splits. info.md is no longer part of the 30-minute background refresh, so a mid-con edit to it won't reach an already-open session that revisits /info without reloading (state.info is cached for the session). A page reload always refetches it, with the same HTTP caching semantics as schedule.json. Also fixes a latent bug this surfaced: the previously-unused setInfo action implicitly returned the assigned string, which immer treats as a replacement for the entire state tree. Eager JS drops from 993kB to 690kB minified (323kB to 221kB gzip). --- src/ProgramData.js | 46 +++++++++---------- src/components/AppRoutes.jsx | 16 +++++-- src/components/Footer.jsx | 24 +++++----- src/components/Info.jsx | 8 +++- src/components/MySchedule.jsx | 8 +++- src/components/UnfilterableProgram.jsx | 9 ++-- src/model.js | 19 ++++++-- vite.config.js | 61 +++++++++++++++++--------- 8 files changed, 125 insertions(+), 66 deletions(-) diff --git a/src/ProgramData.js b/src/ProgramData.js index e23d9cd..a89f805 100644 --- a/src/ProgramData.js +++ b/src/ProgramData.js @@ -431,7 +431,20 @@ 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 @@ -449,46 +462,34 @@ export class ProgramData { ? configData.FETCH_OPTIONS_FIRST : configData.FETCH_OPTIONS; - let program, people, info, rawParts; + 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), - ]); - rawParts = [raw, info]; + 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, info]; + 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), - ]); - rawParts = [raw, info]; + 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, info]; + rawParts = [rawProgram, rawPeople]; program = JsonParse.extractJson(rawProgram)[0]; people = JsonParse.extractJson(rawPeople)[0]; } @@ -504,7 +505,6 @@ export class ProgramData { } const data = ProgramData.processData(program, people); - data.info = info; return { fingerprint, data }; } } diff --git a/src/components/AppRoutes.jsx b/src/components/AppRoutes.jsx index ca286b6..fc1dd3f 100644 --- a/src/components/AppRoutes.jsx +++ b/src/components/AppRoutes.jsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, lazy, Suspense } from "react"; import { useStoreState, useStoreActions } from "easy-peasy"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; @@ -21,10 +21,11 @@ import ItemByIdList from "./ItemByIdList"; import LocationProgramme from "./LocationProgramme"; import People from "./People"; import Person from "./Person"; -import Info from "./Info"; import Settings from "./Settings"; import Footer from "./Footer"; +const Info = lazy(() => import("./Info")); + const AppRoutes = () => { const appClasses = "App" + (configData.DEBUG_MODE.ENABLE ? " debug-mode" : ""); @@ -76,7 +77,16 @@ const AppRoutes = () => { } /> } /> - } /> + + + + + + } + /> } /> } /> diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 3b250c8..d76e0bf 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -1,18 +1,20 @@ -import ReactMarkdown from "react-markdown"; -import configData from "../config.json"; +import footerHtml from "virtual:footer-html"; const Footer = () => { return (
-
- -
-
- -
-
- -
+
+
+
); }; diff --git a/src/components/Info.jsx b/src/components/Info.jsx index 2fa4316..cbe2d71 100644 --- a/src/components/Info.jsx +++ b/src/components/Info.jsx @@ -1,9 +1,15 @@ -import { useStoreState } from "easy-peasy"; +import { useEffect } from "react"; +import { useStoreState, useStoreActions } from "easy-peasy"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; const Info = () => { const info = useStoreState((state) => state.info); + const fetchInfo = useStoreActions((actions) => actions.fetchInfo); + + useEffect(() => { + fetchInfo(); + }, [fetchInfo]); return (
diff --git a/src/components/MySchedule.jsx b/src/components/MySchedule.jsx index 30178b5..7740b96 100644 --- a/src/components/MySchedule.jsx +++ b/src/components/MySchedule.jsx @@ -2,7 +2,9 @@ import { useStoreState, useStoreActions } from "easy-peasy"; import configData from "../config.json"; import ProgramList from "./ProgramList"; import ShowPastItems from "./ShowPastItems"; -import ShareLink from "./ShareLink"; +import { lazy, Suspense } from "react"; + +const ShareLink = lazy(() => import("./ShareLink")); import { LocalTime } from "../utils/LocalTime"; import { useProgramTime } from "../hooks/useProgramTime"; @@ -58,7 +60,9 @@ const MySchedule = () => {
- + + +
); }; diff --git a/src/components/UnfilterableProgram.jsx b/src/components/UnfilterableProgram.jsx index 6b2e32a..e49752c 100644 --- a/src/components/UnfilterableProgram.jsx +++ b/src/components/UnfilterableProgram.jsx @@ -1,9 +1,10 @@ -import React from "react"; +import React, { lazy, Suspense } from "react"; import { useStoreState } from "easy-peasy"; import ProgramList from "./ProgramList"; -import Info from "./Info"; import { useProgramTime } from "../hooks/useProgramTime"; +const Info = lazy(() => import("./Info")); + const UnfilterableProgram = () => { const program = useStoreState((state) => state.program); @@ -18,7 +19,9 @@ const UnfilterableProgram = () => {

- + + +
); }; diff --git a/src/model.js b/src/model.js index 1f0447a..97f5b48 100644 --- a/src/model.js +++ b/src/model.js @@ -56,7 +56,7 @@ const model = { personTags: [], info: "", loadError: null, - // Digest of the last successfully-loaded program/people/info payload, so + // Digest of the last successfully-loaded program/people payload, so // fetchProgram can tell ProgramData.fetchData whether a new fetch's // contents actually differ - letting it skip reprocessing (and reusing // the same array references) for a byte-identical background refresh. @@ -124,6 +124,18 @@ const model = { } }), + // Info-page markdown, fetched on first visit. + fetchInfo: thunk(async (actions, payload, { getState }) => { + if (getState().info !== "") { + return; + } + try { + actions.setInfo(await ProgramData.fetchInfo(true)); + } catch (e) { + console.error("Failed to load info page:", e); + } + }), + // Sync thunks fetchProfile: thunk(async (actions) => { if (!SyncService.isSyncEnabled()) { @@ -213,9 +225,10 @@ const model = { state.locations = data.locations; state.tags = data.tags; state.personTags = data.personTags; - state.info = data.info; }), - setInfo: action((state, info) => state.info = info), + setInfo: action((state, info) => { + state.info = info; + }), setLastFetchFingerprint: action((state, fingerprint) => { state.lastFetchFingerprint = fingerprint; }), diff --git a/vite.config.js b/vite.config.js index 4a34dce..9b2c25b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -63,9 +63,49 @@ function injectDataPreloads() { }; } +/** + * The footer's markdown comes from config.json, so it's known at build + * time. Rendering it to HTML here (micromark is already in node_modules + * as react-markdown's core) keeps the markdown-rendering packages out of + * the eager bundle entirely - at runtime they're only in the lazy chunk + * for the info page. micromark escapes raw HTML by default, so the + * output is safe for dangerouslySetInnerHTML. + */ +function prerenderFooterPlugin() { + const virtualId = "virtual:footer-html"; + const resolvedId = "\0" + virtualId; + return { + name: "prerender-footer-markdown", + resolveId(id) { + if (id === virtualId) { + return resolvedId; + } + }, + async load(id) { + if (id !== resolvedId) { + return; + } + // Dynamic import: micromark is ESM-only and this config is CJS. + const { micromark } = await import("micromark"); + const footer = JSON.parse(fs.readFileSync(configPath, "utf-8")).FOOTER; + const html = { + site: micromark(footer.SITE_NOTE_MARKDOWN), + copyright: micromark(footer.COPYRIGHT_MARKDOWN), + conclar: micromark(footer.CONCLAR_NOTE_MARKDOWN), + }; + return `export default ${JSON.stringify(html)};`; + }, + }; +} + // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), validateConfigPlugin(), injectDataPreloads()], + plugins: [ + react(), + validateConfigPlugin(), + injectDataPreloads(), + prerenderFooterPlugin(), + ], server: { port: 3000, open: true, @@ -83,25 +123,6 @@ export default defineConfig({ }, }, cssCodeSplit: false, - rollupOptions: { - output: { - manualChunks(id) { - // Bundle all node_modules into a single vendor chunk - // This reduces HTTP requests for short-lived convention deployments - // where dependencies won't change but app code might get bug fixes - if (id.includes("node_modules")) { - return "vendor"; - } - }, - }, - }, - // We are intentionally bundling all the vendor code together. This is - // because it is very unlikely we change dependencies once we've deployed - // because of the short-lived nature of the conventions. - // Hence, having a single vendor bundle reduces the number of HTTP requests - // needed to load the app. - // But this does mean we can end up with a large vendor.js file, so increase - // the warning limit. chunkSizeWarningLimit: 1000, }, }); From 99fa270f890f60cca217bfb6fe9a285d68a6739b Mon Sep 17 00:00:00 2001 From: Andrew January Date: Sun, 2 Aug 2026 11:25:53 +0100 Subject: [PATCH 29/30] Fix infinite scroll resetting to top on every schedule refresh isSuffixOfPrev compared program items by object reference, but processData re-parses raw JSON on every fetch, producing new item objects even when content is unchanged. Compare by item.id instead. --- src/components/ProgramList.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ProgramList.jsx b/src/components/ProgramList.jsx index 641a569..fec2190 100644 --- a/src/components/ProgramList.jsx +++ b/src/components/ProgramList.jsx @@ -68,8 +68,9 @@ const ProgramList = ({ program, programTime, forceExpanded = false }) => { const isSuffixOfPrev = program.length > 0 && program.length <= prevProgram.length && - program[program.length - 1] === prevProgram[prevProgram.length - 1] && - program[0] === prevProgram[prevProgram.length - program.length]; + program[program.length - 1].id === + prevProgram[prevProgram.length - 1].id && + program[0].id === prevProgram[prevProgram.length - program.length].id; let effectiveVisibleCount = visibleCount; if ( (INFINITE_SCROLL && programChanged && !isSuffixOfPrev) || From 98d6f51a887c85b96ce70d25edf7ba229f8cbea9 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 1 Sep 2026 12:56:17 +0100 Subject: [PATCH 30/30] Fix example config to handle streaming tag correctly --- src/config_example.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config_example.json b/src/config_example.json index debea05..26d8b0b 100644 --- a/src/config_example.json +++ b/src/config_example.json @@ -166,7 +166,7 @@ }, {"NAME": "meeting", "TEXT": "Click to view stream", - "TAG": "type:Streaming", + "TAG": "Type:Streaming", "WHEN": ["during"] }, {"NAME": "recording",