EuroPython Companion is an Expo/React Native app (Expo SDK 57, React 19, React Native 0.86, new architecture enabled) that works fully offline once conference data is cached. It fetches static EuroPython programme JSON, normalizes it, and stores it in AsyncStorage alongside user settings and favorites. Native (iOS/Android) and web builds share almost all code; the one deliberate fork is the bottom tab bar (see Navigation).
index.tscallsregisterRootComponent(App).App.tsximports@utils/webAlertPolyfillfor its side effect (patchesAlert.alerton web — see Utilities), configuresNotifications.setNotificationHandlerto show banners/lists/sound in the foreground, and renders<SettingsProvider><AppContent /></SettingsProvider>.AppContentreadsconferenceYear,themeMode,onboardingSeen,hydratedfromuseSettings(). It renders nothing untilhydratedis true (settings have finished loading from AsyncStorage).useAppNavTheme(themeMode)derives the active Paper theme and a matching React Navigation theme.useNotificationDeepLink()wires notification-tap deep linking and returnsonNavReady.usePwaInstallPrompt()(web-only logic) returns install-prompt state.AppScaffoldwraps everything inPaperProvider→SafeAreaProvider→NavigationContainer(withref={navigationRef}andonReady={onNavReady}).- If
!onboardingSeen, only the onboarding stack (builtStacks.onboarding) is rendered — the onboarding stack is not part ofAppTabs/tabScreens, so it's only reachable via this pre-tabs gate. Onboarding screens should not calluseConferenceData/useFavorites, since those providers aren't mounted yet. - Once onboarding is complete,
ConferenceDataProvider(year)→FavoritesProvider(year)wrap aScheduleNotificationManager(a component that callsuseScheduleNotifications()and rendersnull— a side-effect-only hook needs a mount point),UrlDeepLinkManager(same no-render pattern, foruseUrlDeepLink()— see below; it needsuseConferenceData(), which is why it's mounted here and not alongsideuseNotificationDeepLink()in step 4),InstallPrompt(rendered with theusePwaInstallPrompt()result spread as props), andAppTabs.
Navigation is a bottom tab navigator (src/navigation/AppTabs.tsx / AppTabs.native.tsx) with one native-stack navigator per tab, built from a declarative config.
src/navigation/routes.ts— route name constants and typed param lists.TabRoutesare the 5 tabs (HomeTab,ScheduleTab,SpeakersTab,AgendaTab,SettingsTab). Each tab's root screen route uses a*Mainsuffix (HomeMain,ScheduleMain,SpeakersMain,AgendaMain,SettingsMain).SharedRoutes(SessionDetail,SpeakerDetail) are duplicated into every tab's stack (Home/Schedule/Speakers/Agenda) rather than living in one shared modal stack — so a session/speaker detail screen pushed from any tab stays within that tab. The Settings stack is the exception: it hasNotificationsListinstead.CombinedParamListintersects all of the above for cross-stack-typed navigation.src/navigation/stackConfigs.ts— maps route names to screen components per stack (stackConfigs.home/schedule/speakers/agenda/settings/onboarding), plustabIconNames(Ionicons glyphs, used by the web tab bar) andtabSfSymbols(SF Symbol names, used by the native iOS tab bar).tabScreensis the ordered list of the 5 tabs with{ name, stackKey, title }. The "Agenda" tab rendersMyAgendaScreen, which is imported from@screens/FavoritesScreen— the route/tab is conceptually "My agenda" but the underlying screen file and export are namedFavoritesScreen.src/navigation/stacks.tsx—makeStackNavigator(screens)is a generic factory aroundcreateNativeStackNavigator(headerShown: falseglobally).builtStacksis built eagerly at module load by iteratingstackConfigs's keys, giving one navigator component per stack key.src/navigation/navigationRef.ts— acreateNavigationContainerRef<RootTabParamList>(), for navigating from outside a component'suseNavigation()context (used byuseNotificationDeepLinkand as the final fallback inuseAppNavigation).
App.tsx imports AppTabs from @navigation/AppTabs with a bare specifier — there is no Platform.OS/Platform.select branch anywhere. Both src/navigation/AppTabs.tsx and src/navigation/AppTabs.native.tsx export a same-signature AppTabs({ theme }); Metro/Expo's automatic platform-extension resolution picks .native.tsx for iOS/Android builds and falls back to the plain .tsx for web. The two implementations are genuinely different libraries, not a themed variant of the same one:
AppTabs.tsx(web) uses@react-navigation/bottom-tabs(createBottomTabNavigator, JS-rendered). It overridestabBarStyle.heightto55whenuseWindowDimensions().width < 768(narrow-screen override), otherwise leaves itundefined. Icons areIoniconscomponents looked up viatabIconNames.AppTabs.native.tsx(iOS/Android) uses@bottom-tabs/react-navigation(createNativeBottomTabNavigator), which renders the actual OS-native tab bar. Native tabs can't take a React component as an icon, so:- On iOS,
tabBarIconreturns{ sfSymbol: tabSfSymbols[route] }directly — no async work needed. - On other platforms, icons must be pre-resolved to
ImageSourcePropTypeviaIonicons.getImageSource(name, 24, "black")(color is irrelevant — the image is rendered as a template and re-tinted natively per active/inactive state). This resolution happens once in auseEffecton mount; the component rendersnulluntil it resolves, so there's a brief blank frame on non-iOS native platforms before the tab bar appears. useAppNavTheme(below) also callsAppearance.setColorScheme(...)on non-web platforms, because the native tab bar reads the OS light/dark trait directly and would otherwise flash the wrong scheme when it disagrees with the in-app theme.
- On iOS,
src/hooks/useAppNavigation.ts(useAppNavigation/ default export) is what screens call for everyday navigation:goToScheduleTab,goToSpeakersTab,goToHomeTab,goToSettingsTab,goToAgendaTab,openSession(id),openSpeaker(id),openNotificationsList,openCoC,openCoCContacts,openVenue. Detail-opening helpers use a 3-tier fallback: (1) if the current stack already has the target route, navigate directly within it; (2) else if a parent tab navigator exists, navigate it with a nested{ screen, params }; (3) else fall back tonavigationRefdirectly (e.g. called before any navigator has mounted).openCoC/openCoCContactsuse atry/catcharoundnavigation.navigateinstead of the route-inspection check the other helpers use — an inconsistency, not a bug, worth knowing about if you're adding a similar helper.src/hooks/useNotificationDeepLink.tsis a separate, independent mechanism that reacts to OS notification taps (see Notifications) and drivesnavigationRefdirectly rather than going throughuseAppNavigation.src/hooks/useUrlDeepLink.tsreacts to incominghttps://ep{year}.europython.eu/{session,speaker}/{slug}and.../scheduleuniversal links (native only) and also drivesnavigationRefdirectly. Unlike the other two, it resolves againstuseConferenceData()(URLs carry aslug, routes are keyed byid) and buffers on data-readiness rather than nav-readiness: a pending target is retried whenever the loaded conference data orconferenceYearchanges, switching the year first viasetConferenceYearif the link's year doesn't match what's currently loaded. This needsapple-app-site-association/assetlinks.jsonhosted oneuropython.euto actually fire — see Configuration — and has no web equivalent (a different-origin PWA can't intercept another site's URLs).
src/store/settings.tsx(SettingsProvider/useSettings) — persists to AsyncStorage keyapp:settings:conferenceYear,themeMode(system/light/dark/night),timeZonePreference(conference/local),onboardingSeen,notificationsEnabled,breakNotificationsEnabled,hapticsEnabled,notificationLeadMinutes.notificationsEnabled,breakNotificationsEnabled, andhapticsEnabledall default toPlatform.OS !== "web"(off by default on web). UI should gate onhydratedbefore trusting these values. Also exportsgetEffectiveTimeZone(preference, year)(preference === "local" ? undefined : getConferenceMeta(year).timeZone) and re-exportsgetConferenceMetafrom@config/conference.src/store/conferenceData.tsx(ConferenceDataProvider(year)/useConferenceData) — on every fetch cycle, loads conference data (loadConferenceDataWithMeta, see below) and Wi-Fi info (loadWifiInfo) in parallel. Tracksloading,refreshing,error,fromCache,fetchedAt,isOffline,lastFetchFailed,resolvedYear. Auto-refreshes silently everyDATA_REFRESH_INTERVAL_MS(10 minutes) viasetInterval, and again wheneverAppStatetransitions to"active"; both are throttled againstDATA_REFRESH_INTERVAL_MSto avoid duplicate fetches. Subscribes toNetInfoforisOffline, with anavigator.onLine === falsefallback check on web. Exposesrefresh()(force network fetch, ignores cache TTL) andrefreshIfStale()(respects the TTL — used for pull-to-refresh where forcing isn't desired).src/store/favorites.tsx(FavoritesProvider(year)/useFavorites) — starred session IDs as aSet<string>, persisted per year ateuropython:favorites:{year}. Mutations (toggleFavorite,setFavorite,clearFavorites) update state optimistically and roll back if the AsyncStorage write throws.
conference.ts—loadConferenceDataWithMeta(year, { forceRefresh })is the entry point. Cache key isep{year}:conferenceData:v{SCHEMA_VERSION}(SCHEMA_VERSION = 3, from@config/conference). If a cached copy exists, isn'tforceRefreshd, and is younger thanDATA_REFRESH_INTERVAL_MS(10 min), it's served without touching the network. Otherwise it fetchessessions.json,speakers.json,schedule.jsonin parallel frombuildBaseUrl(year)=`${API_BASE}/ep${year}/releases/current`(API_BASEis the hardcoded constant"https://static.europython.eu/programme"in@config/conference— there is no environment-variable override; see Configuration), normalizes the payload, and caches the result with afetchedAtISO timestamp. If the network fetch fails, it falls back to whatever cached data exists (regardless of age) and only throws if there's no cache at all. If the AsyncStorage write fails (quota), it purges old schema-version keys and other years' cache entries once, then retries the write.conferenceTransform.ts— pure normalization.normalizeSpeaker/normalizeSessionmap raw JSON fields to the app'sSpeaker/Sessionshapes.buildDays(sessionsById, rawSchedule)treatsschedule.jsonas the source of truth for room/time overrides: for every schedule event it either synthesizes aBreakSlot(idbreak-{date}-{idx}) or merges the event's rooms/start/end onto the matching session (rooms unioned viamergeRooms, session start/end widened to the earliest/latest slot seen, and — since a session can appear in the schedule more than once — each occurrence gets aslotIdof`${code}-${date}-${idx}`). Returns a freshsessionsByIdmap (does not mutate its input) plus aDaySchedule[]sorted by date.guards.ts— runtime type guards, notablyisWrappedSessions/isWrappedSpeakerswhich unwrap either a bareRecord<code, Raw*>or a{ sessions: {...} }/{ speakers: {...} }wrapper — the upstream API has been observed in both shapes historically, so the loader tolerates either.wifi.ts—loadWifiInfo()always tries the network first (venue Wi-Fi credentials can rotate independently of an app update) and only falls back to the AsyncStorage cache (wifiInfo:v1) on failure; unlike conference data, there's no TTL — every call attempts a fresh fetch.
src/hooks/useScheduleNotifications.ts (mounted via the no-render ScheduleNotificationManager in App.tsx) keeps local notifications in sync: if both notificationsEnabled and breakNotificationsEnabled are off, it clears everything. Otherwise it builds a deduped map of NotificationEntry (keyed by slotId ?? id for sessions, by event id for breaks) — a session is included if it's favorited or is a keynote (isKeynoteSessionType, matched via a case-insensitive substring check on sessionType) regardless of favorite status — then calls rescheduleSessionNotifications (src/utils/notifications.ts), which cancels all previously-scheduled notifications and schedules up to MAX_SCHEDULED_NOTIFICATIONS (180) of the soonest upcoming ones, leadMinutes before session start (breaks fire at their start time, no lead). This is a rebuild-from-scratch strategy, not an incremental diff. Notification taps are handled separately by useNotificationDeepLink.ts (see Navigation), which is a no-op on web (no OS notification tray to resolve a cold start from).
src/theme.ts defines lightPalette, darkPalette, and nightPalette (an AMOLED-black variant of dark), plus spacing/radius scales. createPaperTheme(mode) builds an MD3Theme from a palette, including elevation shades computed by a small tint() RGB-interpolation helper. useAppTheme() (src/hooks/useAppTheme.ts) exposes { paperTheme, palette } for components that need palette-only values (e.g. the favorite star color) that aren't part of Paper's theme surface. useAppNavTheme(themeMode) (src/hooks/useAppNavTheme.ts) is the higher-level hook actually used in App.tsx — it resolves themeMode === "system" against useColorScheme(), derives both the Paper theme and a matching React Navigation theme, and syncs native OS appearance (see above).
One file per route, each wrapped in ScreenContainer (app chrome: title/subtitle header, back button, optional info button, and a persistent "you are viewing old data" banner when conferenceYear !== DEFAULT_CONFERENCE_YEAR).
-
OnboardingScreen— slide deck from@data/onboarding; per-slideactionkeys (requestNotifications,promptDiscord) are resolved against a local action map. -
HomeScreen— hero card,OfflineNotice, a favorites-onlyUpcomingList, andNeedToKnowList. -
ScheduleScreen— search + day/track/level filters (state owned here, filter UI inScheduleFilters), computesbestDay(today if present indata.days, else the first day) and defaultsselectedDayto it both on mount and viauseFocusEffect; renders throughSessionList. -
SpeakersScreen— search overdata.speakersById, alphabetized, rendered directly withFlashList(not throughSessionList, which is schedule-specific). -
SpeakerDetailScreen— bio (MarkdownBody), social links, and that speaker's sessions (sorted withcompareSessionsByStart, no room-order tiebreaker passed since a speaker's own session list doesn't need one), share. -
SessionDetailScreen— abstract markdown, metadata, speaker list, favorite toggle, share, and calendar add/remove (checksisSessionInCalendaron mount to show the correct calendar icon state). -
FavoritesScreen(the "Agenda" tab's screen, exported asMyAgendaScreenfromstackConfigs.ts) — starred sessions viaSessionList, "add all to calendar" bulk action. -
SettingsScreen— year/theme selection, favorites export (viaShare.share, one line per favorite) and clear, notification toggles (session lead-time picker, break toggle, and a disabled placeholder "push notifications" row that isn't implemented), haptics toggle, time-zone preference, onboarding reset, data freshness + manual refresh. -
NotificationsScreen— lists currently-scheduled OS notifications viaNotifications.getAllScheduledNotificationsAsync(), normalized and sorted with the samecompareSessionsByStart/toSortableStartItemhelpers used for schedule sorting. -
CoCScreen— Code of Conduct in aWebViewwith injected JS to hide the site's header/nav chrome;onShouldStartLoadWithRequestonly allows the first same-origin load (blocks in-page navigation away from the CoC URL). -
CoCContactsScreen— a hardcoded primary contact card plus@data/coc_contacts.json, loaded throughloadJsonData(arequire()wrapper, see Utilities). -
VenueMapScreen— interior wayfinding for ICE Kraków Congress Centre, one continuous scroll with no tabs: Getting in (entrance photos), Floor plans (aChiprow over@data/venue.ts'svenueFloors, F0–F3, each with the official bundled floor-plan image and its room list), then Accessibility & wellbeing (accessibilityItemsas icon+title+text rows, quiet-room photos, an email action). All images render as tap-onlyVenueImageThumbnail(src/components/venue/VenueImageThumbnail.tsx, plainPressable+expo-image, no gesture handler — so a drag starting on an image still scrolls the page) with a magnify badge; tapping one opensVenueLightbox(src/components/venue/VenueLightbox.tsx), a full-screen overlay built from react-native-paper'sPortal+ a plainView(deliberately not Paper'sModal— its classicAnimated/useNativeDriveropacity wrapper blocksGallery's Reanimated-based rootonLayoutfrom ever measuring a non-zero size on native, silently clipping every image; also not a nestedGestureHandlerRootView— the app's single root-level one fromApp.tsxalready coversPortalcontent) usingreact-native-zoom-toolkit'sGalleryfor pinch-zoom and swipe-between-images — the only place pinch/pan gestures live. Any single tap (viaGallery'sonTap, safely bridged to JS viascheduleOnRNinternally) or the Android hardware back button (a directBackHandlerlistener, since there's noModalto provide one) closes it.VenueImageentries in@data/venue.tscarry knownwidth/heightso the lightbox sizes images synchronously instead of viareact-native-zoom-toolkit'suseImageResolution(unreliable for bundled assets on native). Reachable from Home via aNeedToKnowcard action (openVenueinuseAppNavigation.ts).react-native-zoom-toolkit@5.1.0ships a real bug inusePinchCommons'sreset()— it writesgestureEnd.value = 0unconditionally inside awithTimingcallback instead of gating onfinished, which under Reanimated 4.5 causes infinite self-recursion (Maximum call stack size exceeded) whenever an in-flight pinch/reset animation gets cancelled — e.g. exactly what happens whenGalleryunmounts on close (upstream: Glazzes/react-native-zoom-toolkit#126, unfixed as of this writing). Patched locally viapnpm patch— seepatches/react-native-zoom-toolkit.patch, registered inpnpm-workspace.yaml'spatchedDependenciesand reapplied automatically on everypnpm install, including CI/production builds. Drop the patch once a release containing the upstream fix ships.
layout/—ScreenContainer(app chrome, described above),PaddedScrollView(aScrollViewwith theme-consistent padding).home/—HomeHeroCard,UpcomingList(auto-refreshes "now" everyUPCOMING_REFRESH_INTERVAL_MS; can filter to favorites-only),NeedToKnowList(rendersInfoCards from@data/homeInfo, with a special case that substitutes live Wi-Fi credentials into the "Wi-Fi Info" card when available).schedule/—SessionList(the shared list component; see below),SessionListItem(React.memo-wrapped),BreakListItem,ScheduleFilters(composesScheduleDayChips+ScheduleTrackFilter+ScheduleLevelFilter, each in their own file),FavoriteToggleButton,SessionTypeLegendDialog.inputs/—SearchBar,ChipPicker(generic single-select chip row).settings/—SettingsSection(card wrapper with optional header action),SettingsRow/SettingsSwitchRow.status/—OfflineBanner(dumb banner),OfflineNotice(readsuseConferenceData()and decides visibility/message fromfromCache/isOffline/lastFetchFailed),StateMessage(centered loading/empty/error message),DataBoundary(loading/error/empty guard used by most data screens),InstallPrompt(PWA install banner, web-only — takes the full return value ofusePwaInstallPrompt()as props).- Shared/flat —
SpeakerListItem,SpeakerAvatar(renders anexpo-imagewithcachePolicy="memory-disk"when an avatar URL exists, elseAvatar.Textinitials),SocialLinksRow,DetailActionRow(icon-button stack for detail-screen headers; supports arender()escape hatch for non-icon actions like the favorite star),InfoCard,MarkdownBody(theme-awarereact-native-markdown-displaywrapper),ContactCard.
src/components/schedule/SessionList.tsx is used by ScheduleScreen and FavoritesScreen. It's a @shopify/flash-list FlashList (not FlatList/SectionList) rendering a flattened Row[] of { kind: "header" } / { kind: "item" } entries — sections are flattened rather than using FlashList's native section support, with getItemType distinguishing "header" / "break" / "session" rows for FlashList's recycling. Sessions are sorted via sortScheduleItems (see below) before grouping by start-time label. SpeakersScreen renders its own FlashList directly instead of going through SessionList, since it isn't schedule/section data.
schedule.ts—compareSessionsByStart(a, b, preferredRoomOrder): sorts by start time first; ties go to breaks before sessions, then to the year'spreferredRoomOrder(exact case-insensitive room-name match, unmatched rooms rank last and fall back to alphabetical), then to title.sortScheduleItems(items, conferenceYear)looks upgetPreferredRoomOrder(conferenceYear)and applies the comparator.groupBySessionStartLabelgroups pre-sorted items into{ title, data }sections for list rendering.toSortableStartItem/SortableScheduleItemadapt loosely-typed inputs (used to sort the scheduled-notifications list, which has no nativeSessionshape).format.ts—getRoomLabel(returns"Plenary"when a session spans multiple rooms),formatSessionTimeRange/formatSessionSubtitle,formatDurationFromMs,initialsFromName.time.ts—formatSessionStartLabel,formatSessionStartTime,formatDateISO,formatConferenceDayLabel(anchors at midday UTC to avoid date rollover across time zones),isUpcomingSession,formatFetchedAt,getLocalTimeZone.notifications.ts— permission request/caching (ensureNotificationPermission, cached in a module-level variable and reset viaresetNotificationPermissionCache),normalizeTrigger(best-effort parsing of Expo's notification trigger shapes into a date or relative-ms value),rescheduleSessionNotifications/clearScheduledSessionNotifications(both no-ops on web).calendar.ts—expo-calendarwrapper.ensureCalendarAccess(year)resolves or creates a per-year "EuroPython {year}" calendar (reusing an existing writable one where possible, with iOS/Android-specificsourcehandling), caches the resolved calendar id both in-memory and per-year event-id mappings in AsyncStorage (ep:calendarEvents:{year}). ExposesaddSessionToCalendar,removeSessionFromCalendar,isSessionInCalendar,getCalendarIdForYear.sessionTypes.ts—getSessionTypeAccent(substring-matches a session type string against@data/sessionTypes' color map) anduseSessionTypeLegendEntries(memoized legend list).haptics.ts— thinexpo-hapticswrappers, gated by a module-levelhapticsEnabledflag toggled viasetHapticsEnabledRuntime(called from an effect inSettingsProviderwhenever the persistedhapticsEnabledsetting changes). All calls swallow errors.share.ts—shareLinkwrapsShare.sharewith anAlert.alertfailure fallback.storage.ts—loadJsonFromStorage/saveJsonToStorage(AsyncStorage JSON helpers with fallback-on-error),loadJsonData(loader, fallback)(wraps arequire()call passed in by the caller, so Metro's static-require analysis still works while failures degrade gracefully — used forcoc_contacts.json).webAlertPolyfill.ts— a side-effect-only module (imported once inApp.tsx) that, on web only, monkey-patchesAlert.alertto usewindow.confirm/window.alert. React Native'sAlert.alerthas no web implementation by default, and several flows (useCalendarSync, notification permission prompts, favorites clear/export) depend on it — this is what makes those flows functional on web instead of silently no-op-ing.
Static, declarative content bundled with the app: homeInfo.ts ("Need to know" cards, with an actions array of { key, label } resolved dynamically against useAppNavigation()'s returned functions by key), onboarding.ts (slide copy + Discord link), sessionTypes.ts (accent color map + legend entries), coc.ts (CoC URL), coc_contacts.json (CoC team contact list), venue.ts (venueViews — ICE Kraków room/floor guide, entrance/quiet-room images from assets/venue/, and accessibility info for VenueMapScreen; EuroPython hasn't published an official interior floor plan, so this is designed to take a future per-floor plan as one more VenueView entry).
conference.ts—CONFERENCE_YEARS(currently[2026, 2025, 2024, 2023, 2022]),DEFAULT_CONFERENCE_YEAR(the max of that list),CONFERENCE_META(per-yeartitle/subtitle/timeZone/optionalpreferredRoomOrder),API_BASE(hardcoded, no runtime override),SCHEMA_VERSION(cache-key version, bump whenConferenceData's shape changes so stale cached payloads aren't parsed as the new shape),WIFI_INFO_URL(a conference-specific, effectively per-year URL that needs manual updating).constants.ts— notification defaults/caps, date formatting constants, refresh intervals (see Notifications and the conference-data pipeline above).
raw.ts — the upstream JSON shapes (RawSession, RawSpeaker, RawSchedule*), including the two possible top-level wrapper shapes handled by guards.ts. conference.ts — the app's normalized types: Session (with optional slotId for multi-slot sessions), Speaker, SpeakerRef (the trimmed shape embedded in Session.speakers), BreakSlot, ScheduleItem (Session | BreakSlot), DaySchedule, ConferenceData.
public/manifest.json,public/index.html(registersservice-worker.js), andpublic/workbox-config.js(Workbox precaching config, plus a runtimeCacheFirstrule for Pretalx speaker-avatar URLs underprogramme.europython.eu/media/avatars/) drive the installable PWA. The service worker is generated bypnpm build:web(expo export -p web && workbox generateSW public/workbox-config.js), not committed to source.workbox-config.js'sswDestfilename (dist/service-worker.js) must match the pathindex.htmlregisters.- Shell updates on already-installed PWAs:
workbox-config.jssetsskipWaiting/clientsClaim, so a new service worker activates as soon as the browser notices it — but the registration script inpublic/index.htmlis what makes an already-open installed PWA actually notice and pick it up. It callsreg.update()on load and again on everyvisibilitychangeto"visible"(covers resuming a backgrounded standalone PWA), and reloads the page exactly once whencontrollerchangefires after a prior controller existed (ahadControllerguard skips the spurious reload on first-ever install). Workbox's precache diffing means only changed files are re-downloaded on update, and a failed SW install leaves the previous precache serving — both handled by Workbox itself, no app code needed. Note the app ships as a single monolithic_expo/static/js/web/index-<hash>.jsbundle (no route-level code-splitting), so any code change still re-downloads that whole file on the next visit after a deploy — reducing that would require Metro/Expo bundle-splitting, not attempted here. - The PWA is deployed to GitHub Pages at the custom domain root
https://companion.europython.eu/(domain configured in repo Settings → Pages;.github/workflows/deploy-web.yamljust builds and uploadsdist/), matching localpnpm web/pnpm pwa, which also serve at root — soapp.config.js'sexpo.experiments.baseUrl/workbox-config.js'snavigateFallback(both read theWEB_BASE_URLbuild-time env var, the only env var read anywhere in the build) are effectively unused today, defaulting to""everywhere. That plumbing exists for subpath deployments and would needWEB_BASE_URLset again in CI if the app is ever redeployed under a subpath (it was, briefly, at the old project-page URLhttps://europython.github.io/europython-companion/before the custom-domain migration — that URL may still serve a stale pre-migration build and shouldn't be treated as current). See Configuration. usePwaInstallPrompt/InstallPromptimplement the "Add to Home Screen" banner: listens for thebeforeinstallpromptbrowser event (Chromium-based browsers) and separately detects iOS Safari (which has no such event) via a/iPhone|iPod/user-agent check — note this excludes iPad, which reports a desktop Safari UA string.webAlertPolyfill.ts(above) is what keepsAlert.alert-dependent flows working on web at all.