Invariants and conventions an agent cannot infer from a single file. README.md owns the product, dev setup, the generated native projects and EAS release, and is not repeated here.
npm test # node --test over lib/core/*.test.ts — no framework in the tree
node --test lib/core/market-logic.test.ts # one file
node --test --test-name-pattern="parses D/M/YYYY" lib/core/market-logic.test.ts # one test
npm run typecheck # both TS programs: the app, then lib/core
npm run e2e # maestro test against a running dev build
npm run screenshots:ios # capture store screenshots (iOS)
npm run screenshots:android # capture store screenshots (Android)Those two are the whole of CI (.github/workflows/test.yml); there is no lint step. Screens are not unit-tested — verifying them means a device build, and notifications cannot be checked in a simulator.
e2e/flows/ holds Maestro YAML; e2e/utils/ holds shared subflows. e2e/screenshots/ holds store-screenshot capture flows. Local-only — not in CI.
- Install the CLI standalone (
brew tap mobile-dev-inc/tap && brew install maestro); nothing goes inpackage.jsondependencies. Requires Java 17+. - Run against the standalone dev build (
com.sfdye.pasarguru.dev):APP_VARIANT=development npx expo run:ios --configuration Releasethennpm run e2e:ios. Android:--variant releaseon an API ≤ 34 emulator. - Flows target elements by
testID(Maestro'sid:selector), not by text — accessibility labels are localized. Add atestIDto any new interactive element a flow needs to address. - Notifications, deep-link routing, and background refresh remain untestable in simulators.
- Before marking a PR ready for review, prompt the user to run
npm run e2e:iosandnpm run e2e:androidlocally against a fresh dev build. e2e/screenshots/produces 5 screenshots per locale (Discover, My Pasars, Market detail, Map, Settings). Re-capture after any UI or store-listing change. Capture flow:npm run screenshots:ios(iPhone) →npm run screenshots:ios:ipad(iPad, against iPad Pro 13-inch M4 simulator) →npm run screenshots:copy(copies from.maestro/tests/intostore/ios/screenshots/{en-US,zh-Hans}/{iphone,ipad}/) →npm run metadata:ios(auto-resizes viascreenshots:resize:iosthen uploads). iPhone screenshots resize to 1242×2688 (6.5"), iPad screenshots to 2064×2752 (13" iPad Pro M4). Maestro captures at the simulator's native resolution, which may not match any ASC-supported size.metadata:iosuploads with--replaceso uploads are idempotent — all remote screenshots are deleted before uploading, no duplicates.- A Release build embeds JS at build time — the installed binary carries stale code until you rebuild. Always run
APP_VARIANT=development npx expo run:ios --configuration Releasebefore capturing screenshots or running e2e. Kill Metro first (lsof -ti:8081 | xargs kill -9) so Maestro launches the standalone binary, not Metro-served JS. expo run:iosinstalls only on the simulator it launches — a Maestro flow "passing" on another simulator only proves some build is installed there. Before the iPad capture,xcrun simctl uninstall <udid> com.sfdye.pasarguru.devthensimctl installthe freshDerivedData/PasarGuruDev-*/Build/Products/Release-iphonesimulator/PasarGuruDev.appon the iPad sim — uninstall, don't just install over: a leftover app can OTA-pull stale JS from thedevelopmentchannel on launch (a 2026-09 iPad capture shipped old zh copy this way).
lib/core/ is typed against Node's globals and not React Native's, so an accidental react-native or lib/ import fails typecheck rather than at runtime on device. That boundary constrains edits:
- Inside
lib/core/, imports carry explicit.tsspecifiers (./market-logic.ts) sonode --testcan strip types and run the files directly. Everywhere else, no extension. lib/core/must stay erasable-syntax-only: no enums, no namespaces, no parameter properties, and type-only imports markedimport type.- New logic worth unit-testing belongs in
lib/core/; anything touching a device API cannot go there. - Do not add an
includeentry totsconfig.json—expo startrewrites that file, comments and all, whenincludenames something it did not put there.
app.jsonis the base config and the only place plugin config belongs.app.config.tslayers the dev variant over it (.devids, "PasarGuru Dev") whenAPP_VARIANT=development, which thenpm runscripts and the EASdevelopmentprofile set — so a release build is the one that sets nothing. Keepslugandextra.eas.projectIdout of the override.- The build number is two keys in
app.json—ios.buildNumber(a string) andandroid.versionCode(a number) — which must stay equal. Bump only withnpm run release; never by hand, and never re-addautoIncrement. productionis the TestFlight profile; TestFlight and the App Store take the same binary. Do not add atestflightprofile or Expo'spreviewvariant — theapkprofile already covers internal testing.- EAS Update ships JS/assets over the air (translations,
zh-namesfixes).runtimeVersionpolicyappVersionmakes the per-releaseversionbump the OTA boundary;productioncarrieschannel: "production", inherited byapk.eas updatebundles the working tree (unlikeeas build), so publish from a clean tree. Native deps, SDK upgrades, andapp.jsonnative config still need a store build. README's OTA section has the commands. - Branch config on
APP_VARIANT, neverEAS_BUILD_PROFILE— a localexpo run:iosdoesn't set the latter and would silently take the production branch. - A Debug build embeds no JS (
SKIP_BUNDLING=1) and is dead without Metro.APP_VARIANT=development npx expo run:ios --configuration Releasegives a standalone dev app with no new EAS profile. Judge performance only on Release builds. distribution: internalad-hoc signs for registered UDIDs;productionprovisions no devices and is TestFlight-only, not sideloadable.- Every icon raster in
assets/is generated:npm run iconsderives them frombrand/(Pillow + librsvg, hand-run). Edit the master inbrand/, never the output. The 96px notification glyph is the exception — a hand-drawn SVG.onemap-logo.png,app-store-badge.svgandgoogle-play-badge.pngare third-party compliance/licence artwork (OneMap, Apple, Google), not generated. Both store badges are deliberately tweaked from the official downloads (App Store SVG intrinsic size doubled for GitHub's shrink-only image sizing; Play PNG margin cropped) — don't restore them.
EAS keeps builds, binary submission, and OTA. iOS App Store state (metadata text, screenshots, categories, copyright, review notes) is pushed by scripts/store-ios.mjs through the asc CLI. Android stays on fastlane supply; asc deliberately has no Android support.
npm run metadata:ios/metadata:android— push listing state (no binary). iOS replaces stale screenshots; the push is dry-run-able withmetadata:ios:dry-run.npm run metadata:pull:ios/metadata:pull:android— re-sync after dashboard edits; skip this and a later push overwrites your manual changes.- iOS listing lives in
store/ios/:metadata/is asc's JSON layout (app-info/is app-level,version/<version>/is per store version — when bumpingversionby hand,git mvthe existingversion/dir to match; the dir is singular, unlike asc's docs) — files are kept byte-identical toasc metadata pulloutput (compact, no trailing newline) so pulls never diff, which is also why descriptions carry\nescapes: JSON strings cannot hold literal newlines;screenshots/{en-US,zh-Hans}/{iphone,ipad}/. Android infastlane/metadata/android/{en-US,zh-CN}/— note the different locale codes for Simplified Chinese. - Play feature graphic and 512px icon are derived from
brand/icon-master-1024.png(Pillow), not hand-drawn — regenerate if the mark changes. - Credentials (gitignored): ASC API key JSON at
store/ios/asc-api-key.json(key_id,issuer_id,key— derived from the.p8in~/.appstoreconnect/private_keys/;store-ios.mjsexports them asASC_*env, noasc auth loginneeded); Play service account JSON atfastlane/play-service-account.json. Reuse the Play key from EAS Submit credentials. - Manual, outside both tools: App Store review contact details, country availability, and the Play category and privacy-policy URL (Play Console).
lib/store/ is a hand-rolled external store read through useSyncExternalStore. Import from the barrel lib/store.
state.tsowns the singleStateobject plusgetState/subscribe/setState.setStatederiveslangfromlangPrefandtfromlang, excluding both from its patch type;tis a stable per-language reference components memoise on.hooks.tsexposes one hook per slice. Subscribe to the narrowest one —useIsFavorite(name)exists so a star tap re-renders one row instead of all picker rows.actions.tsowns every side effect: persistence, the NEA fetch, the SGT-midnight timer, theAppStateforeground listener, andwatchSchedule().initStore()is called once fromapp/_layout.tsxand is idempotent (Fast Refresh and StrictMode both call it twice).- Rescheduling notifications is a store subscriber, not a React effect, so it runs when no screen is mounted. It debounces and dedupes on
lang|favorites|markets.length|remindersEnabled. mapViewis the persisted last camera position.MarketMapreads it once at mount (never subscribes — a re-render would re-serialise the tile style and all market features), saves user-initiated settles after a 500ms wait (a followingConstrainedCameracorrection saves the final camera), retains in-progress pans for app backgrounding, and ignores MapLibre's non-user events, which can report a default camera.null(first-ever visit) means default to the user's current location. Validation lives inparseMapView(lib/core/map-view.ts).themePref('light'|'dark'|'system') follows thelangPrefpattern:'system'means the device decides.useThemecombines it withuseColorScheme()viaresolveTheme(lib/core/theme-pref.ts); no derivation insetStatebecause the device scheme is a hook. Storage stores'system'as the key's absence.app.jsonuserInterfaceStylestays"automatic"— it governs native chrome, not the JS UI override.
Launch sequence: read AsyncStorage → setState({ ready: true }), which lifts the splash via SplashGate → revalidate over the network only if the cache is older than 24 hours.
Status is a civil date question, so the app never mixes instants with calendar days:
sgToday()returns aDatewhose local Y/M/D match Singapore's. Pass these civil dates around, notnew Date().sgInstant(civil, hour)converts a civil date plus an SGT hour into a real instant — what a notification trigger needs. SGT is hardcoded UTC+8.- Add days with
new Date(y, m, d + i), never+ 86400000— fixed milliseconds can shift the calendar day across a DST boundary in the device's timezone. lib/date.tsis display formatting only, hand-rolled rather thanIntl.DateTimeFormatbecause Hermes on Android depends on device ICU data.
lib/core/reminder-schedule.ts builds the schedule purely (buildSchedule); lib/notifications.ts hands it to expo-notifications. Closures are grouped one entry per date (five favourites closing the same day → one notification), and each date gets two reminders: 7pm the evening before, 6am the morning of. Closures ≤ LONG_CLOSURE_DAYS (7) expand to daily pairs; longer closures notify only on their genuine first day — probed by checking whether the previous day was also closed, since getUpcomingClosures starts scanning tomorrow and would otherwise manufacture a fresh "first day" every rebuild. getUpcomingClosures returns only verified closures (cleaning, other works) — Monday warnings are excluded at the source, so reminders and the upcoming-closures list never contain them. The market-detail list reads getDisplayClosures instead: the closure in progress today appears first with its true start date, because the scan would pose it as starting tomorrow.
iOS silently keeps only the ~64 soonest pending requests, so rescheduleAll caps at 56 and cancels-then-rebuilds every time; the daily background task (lib/background.ts) tops the queue up as near ones fire. Background refresh is best-effort — cold-start rescheduling in the store is the reliable path, not the task.
Tapping the address opens Apple Maps or Google Maps; the setting is iOS-only (Android's geo: hand-off already uses the user's default).
lib/core/map-provider.tsis pure:resolveMapProvider(pref, installed)andmapUrl.lib/maps.tssuppliesPlatform.OSand thecanOpenURLprobes.- Stored preference is
MapProvider | 'auto';'auto'resolves to Google Maps when installed, else Apple Maps. The Apple probe is hardcodedtrue— iOS keepsmaps://registered even when the app is "deleted", socanOpenURLcan't detect it. Which apps are installed is not app state:openInMapsprobes at tap time; Settings usesuseMapProvider(pref), which returnsnullwhile probing. canOpenURLanswers false for schemes not inLSApplicationQueriesSchemes(app.json), socomgooglemapsis listed there — dropping it silently turns "installed" into "missing". Adding a third map app means adding its scheme.
components/ui/is the primitive layer (Text,Button,Card,Row,Notice,Segmented,EmptyState,Fab,Icon), imported fromcomponents/ui.- Use
Textfromcomponents/ui, neverreact-native's. Pick avariantand atone, not rawfontSize/color. Never capmaxFontSizeMultiplieron body copy — the audience is seniors and Dynamic Type must work at every size. - Colours and spacing come from
lib/theme(space,radius,HIT_SIZE,useTheme). In dark modetheme.shadowis a hairline border — a shadow is invisible against black. useThemedStyles(factory)memoises on the factory, so declare the factory at module scope.Fabplaces itself against the tab bar: mount it as the last child of aflex: 1container on a tab screen and addFAB_CLEARANCEto the scrolling child'scontentContainerStyle. Do not adduseSafeAreaInsets/tab-bar-height padding — the screen box already ends at the tab bar's top edge.- Today rows switch to a compact text hierarchy past
COMPACT_FONT_SCALE(1.4). Read the scale reactively withuseWindowDimensions().fontScale, neverPixelRatio.getFontScale()at render. - Anything tappable inside a gesture takes
Pressablefrom the gesture wrapper (SwipeToDeleteRowre-exports it), never fromreact-native. - The back button is a bare chevron everywhere via
headerBackButtonDisplayMode: 'minimal'on the root<Stack>; a new nestedStackthat pushes screens must repeat it —screenOptionsdon't reach nested navigators. - One
ThemeProviderat the root themes the native chrome. react-navigation is vendored inside expo-router 57 — import fromexpo-router; there is no@react-navigation/*package. Leave tab bar labels to the default; a customtabBarLabelbypasses iOS's fixed-height bar behaviour. SettingsSectiontakes an optionalicon(IconName) rendered beside the overline title. Settings is a hub of chevron rows pushing sub-pages (language,appearance,maps,about) in the settingsStack.
- Tiles end at
SG_BOUNDS(lib/core/map-bounds.ts). The raster source'sboundsonly stops requests, so OneMap still serves non-PNG boundary tiles that MapLibre logs as errors — silenced in dev byconfigureMapLogging()(lib/maplibre.ts), called at module scope fromMarketMapto keep MapLibre out of cold start. - Dark mode swaps the Default tileset for OneMap's Night variant; bounds, zoom range, and attribution are identical for both.
ConstrainedCamerakeeps empty background unreachable:maxBoundsgetsSG_BOUNDSinset by half the viewport span, andconstrain()eases the centre back after an outgrowing zoom-out. A reported centre outsideSG_BOUNDSis startup garbage — ignore it, don't clamp it. Keep its state in that component; a settle inMarketMapwould re-serialise the tile style and all features.- Initial view priority: saved
mapView→ user's current location (first visit, viaawaitingFix) → Singapore overview. MapAttribution(the OneMap logo + © SLA pill, bottom-left) is required by OneMap's Terms of Use — don't remove it or hide the native attribution behind it. Theattribution={false}onMapis deliberate: the overlay replaces the text-only native button with the logo the licence demands.
lib/i18n.ts holds two flat objects. en is the source of truth (StringKey = keyof typeof en) and zh is typed Record<keyof typeof en, string>, so a missing translation fails typecheck. Get t from useT(); interpolate with {name} placeholders. Market names have a separate Chinese lookup in lib/core/zh-names.ts, keyed by the friendly (parenthesised) part of the NEA name — reach it through getDisplayName/displayName so notifications aren't half-translated.
Closure reasons are worded once, in lib/core/reason-words.ts (in core because notificationCopy is there), and read back by i18n.ts for the status pill. Word a new reason there, not at the call site.
Famous-pasar blurbs follow the same pattern: lib/core/famous.ts holds FAMOUS_PASARS (ordered by editorial rank) with a Record<Lang, string> blurb per entry — both languages in core so a missing translation fails typecheck. Keys are the friendly name as it appears in the NEA dataset (verified against the live API), not the common name.
Language resolution: state.langPref (Lang | 'system') is the choice, lang follows from it, resolved through lib/lang.ts (separate module so headless background.ts can reach it without the store). Three rules: a missing pg_lang means 'system' (loadLangPref returns it rather than null — ?? 'en' once sent English reminders to Chinese phones); membership of the supported set is isLang(), never === 'en' || === 'zh'; re-passing an unchanged langPref to setState re-resolves deliberately (that's how foreground picks up a device-language change).
- Market identity is the raw NEA
namestring; favourites are stored as those strings.parseMarketNamesplits"Blk 1 Foo Rd (Bar Market)"into street plus friendly name and decodes HTML entities. normalizeMarketsruns at every ingress — network fetch and cache read. Dataset quirk fixes belong there.- Some NEA friendly names don't match common usage ("Kim Hua Market" for Maxwell, "Telok Ayer Food Centre" for Amoy). The fix is display-only via
lib/core/name-overrides.ts, consulted bygetDisplayName— the rawnamestring (identity) is never rewritten. - A market can leave the dataset: favourites pointing at a missing market are pruned on load, and
useMarketreturnsnull. Handle that in any new screen. MAX_FAVORITES(lib/core/favorites.ts) caps the list; it's a reminder-queue bound alongsideHORIZON_DAYSandMAX_SCHEDULED_REMINDERS, andreminder-schedule.test.tsasserts a full list still fits. Enforcement is at the add only; removal always works.toggleFavoriteraises the limit Alert itself.fetchMarketsFromAPIreturnsnullrather than throwing (10s timeout, one retry); the caller falls back to the cache and setsstale.- AsyncStorage keys are namespaced
pg_. - One NEA row = one closure schedule = one notification source = one favourite. Do not merge rows that are separate blocks (even if same complex — NEA describes them as "standalone" with different addresses) or split combined rows (one building, one schedule). Preserve the dataset's row structure.
- Discover wet/food filter is presence-based, not ratio-based (
getMarketCategoriesinlib/core/market-category.ts): any row with market stalls appears under "wet", any with food stalls under "food". A combined centre shows in both.