Skip to content

Latest commit

 

History

History
140 lines (101 loc) · 20.2 KB

AGENTS.md

File metadata and controls

140 lines (101 loc) · 20.2 KB

AGENTS.md

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.

Commands

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 (Maestro)

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 in package.json dependencies. Requires Java 17+.
  • Run against the standalone dev build (com.sfdye.pasarguru.dev): APP_VARIANT=development npx expo run:ios --configuration Release then npm run e2e:ios. Android: --variant release on an API ≤ 34 emulator.
  • Flows target elements by testID (Maestro's id: selector), not by text — accessibility labels are localized. Add a testID to 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:ios and npm run e2e:android locally 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/ into store/ios/screenshots/{en-US,zh-Hans}/{iphone,ipad}/) → npm run metadata:ios (auto-resizes via screenshots:resize:ios then 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:ios uploads with --replace so 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 Release before 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:ios installs 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.dev then simctl install the fresh DerivedData/PasarGuruDev-*/Build/Products/Release-iphonesimulator/PasarGuruDev.app on the iPad sim — uninstall, don't just install over: a leftover app can OTA-pull stale JS from the development channel on launch (a 2026-09 iPad capture shipped old zh copy this way).

Editing across the two TypeScript programs

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 .ts specifiers (./market-logic.ts) so node --test can 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 marked import type.
  • New logic worth unit-testing belongs in lib/core/; anything touching a device API cannot go there.
  • Do not add an include entry to tsconfig.json — expo start rewrites that file, comments and all, when include names something it did not put there.

Builds

  • app.json is the base config and the only place plugin config belongs. app.config.ts layers the dev variant over it (.dev ids, "PasarGuru Dev") when APP_VARIANT=development, which the npm run scripts and the EAS development profile set — so a release build is the one that sets nothing. Keep slug and extra.eas.projectId out of the override.
  • The build number is two keys in app.json — ios.buildNumber (a string) and android.versionCode (a number) — which must stay equal. Bump only with npm run release; never by hand, and never re-add autoIncrement.
  • production is the TestFlight profile; TestFlight and the App Store take the same binary. Do not add a testflight profile or Expo's preview variant — the apk profile already covers internal testing.
  • EAS Update ships JS/assets over the air (translations, zh-names fixes). runtimeVersion policy appVersion makes the per-release version bump the OTA boundary; production carries channel: "production", inherited by apk. eas update bundles the working tree (unlike eas build), so publish from a clean tree. Native deps, SDK upgrades, and app.json native config still need a store build. README's OTA section has the commands.
  • Branch config on APP_VARIANT, never EAS_BUILD_PROFILE — a local expo run:ios doesn'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 Release gives a standalone dev app with no new EAS profile. Judge performance only on Release builds.
  • distribution: internal ad-hoc signs for registered UDIDs; production provisions no devices and is TestFlight-only, not sideloadable.
  • Every icon raster in assets/ is generated: npm run icons derives them from brand/ (Pillow + librsvg, hand-run). Edit the master in brand/, never the output. The 96px notification glyph is the exception — a hand-drawn SVG. onemap-logo.png, app-store-badge.svg and google-play-badge.png are 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.

Store metadata (asc for iOS, fastlane for Android)

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 with metadata: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 bumping version by hand, git mv the existing version/ dir to match; the dir is singular, unlike asc's docs) — files are kept byte-identical to asc metadata pull output (compact, no trailing newline) so pulls never diff, which is also why descriptions carry \n escapes: JSON strings cannot hold literal newlines; screenshots/{en-US,zh-Hans}/{iphone,ipad}/. Android in fastlane/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 .p8 in ~/.appstoreconnect/private_keys/; store-ios.mjs exports them as ASC_* env, no asc auth login needed); Play service account JSON at fastlane/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).

State: an external store, not context

lib/store/ is a hand-rolled external store read through useSyncExternalStore. Import from the barrel lib/store.

  • state.ts owns the single State object plus getState/subscribe/setState. setState derives lang from langPref and t from lang, excluding both from its patch type; t is a stable per-language reference components memoise on.
  • hooks.ts exposes 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.ts owns every side effect: persistence, the NEA fetch, the SGT-midnight timer, the AppState foreground listener, and watchSchedule(). initStore() is called once from app/_layout.tsx and 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.
  • mapView is the persisted last camera position. MarketMap reads 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 following ConstrainedCamera correction 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 in parseMapView (lib/core/map-view.ts).
  • themePref ('light'|'dark'|'system') follows the langPref pattern: 'system' means the device decides. useTheme combines it with useColorScheme() via resolveTheme (lib/core/theme-pref.ts); no derivation in setState because the device scheme is a hook. Storage stores 'system' as the key's absence. app.json userInterfaceStyle stays "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.

Timezone model

Status is a civil date question, so the app never mixes instants with calendar days:

  • sgToday() returns a Date whose local Y/M/D match Singapore's. Pass these civil dates around, not new 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.ts is display formatting only, hand-rolled rather than Intl.DateTimeFormat because Hermes on Android depends on device ICU data.

Notifications

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.

Handing a market to a map app

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.ts is pure: resolveMapProvider(pref, installed) and mapUrl. lib/maps.ts supplies Platform.OS and the canOpenURL probes.
  • Stored preference is MapProvider | 'auto'; 'auto' resolves to Google Maps when installed, else Apple Maps. The Apple probe is hardcoded true — iOS keeps maps:// registered even when the app is "deleted", so canOpenURL can't detect it. Which apps are installed is not app state: openInMaps probes at tap time; Settings uses useMapProvider(pref), which returns null while probing.
  • canOpenURL answers false for schemes not in LSApplicationQueriesSchemes (app.json), so comgooglemaps is listed there — dropping it silently turns "installed" into "missing". Adding a third map app means adding its scheme.

UI conventions

  • components/ui/ is the primitive layer (Text, Button, Card, Row, Notice, Segmented, EmptyState, Fab, Icon), imported from components/ui.
  • Use Text from components/ui, never react-native's. Pick a variant and a tone, not raw fontSize/color. Never cap maxFontSizeMultiplier on 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 mode theme.shadow is a hairline border — a shadow is invisible against black.
  • useThemedStyles(factory) memoises on the factory, so declare the factory at module scope.
  • Fab places itself against the tab bar: mount it as the last child of a flex: 1 container on a tab screen and add FAB_CLEARANCE to the scrolling child's contentContainerStyle. Do not add useSafeAreaInsets/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 with useWindowDimensions().fontScale, never PixelRatio.getFontScale() at render.
  • Anything tappable inside a gesture takes Pressable from the gesture wrapper (SwipeToDeleteRow re-exports it), never from react-native.
  • The back button is a bare chevron everywhere via headerBackButtonDisplayMode: 'minimal' on the root <Stack>; a new nested Stack that pushes screens must repeat it — screenOptions don't reach nested navigators.
  • One ThemeProvider at the root themes the native chrome. react-navigation is vendored inside expo-router 57 — import from expo-router; there is no @react-navigation/* package. Leave tab bar labels to the default; a custom tabBarLabel bypasses iOS's fixed-height bar behaviour.
  • SettingsSection takes an optional icon (IconName) rendered beside the overline title. Settings is a hub of chevron rows pushing sub-pages (language, appearance, maps, about) in the settings Stack.

Map (MapLibre + OneMap)

  • Tiles end at SG_BOUNDS (lib/core/map-bounds.ts). The raster source's bounds only stops requests, so OneMap still serves non-PNG boundary tiles that MapLibre logs as errors — silenced in dev by configureMapLogging() (lib/maplibre.ts), called at module scope from MarketMap to 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.
  • ConstrainedCamera keeps empty background unreachable: maxBounds gets SG_BOUNDS inset by half the viewport span, and constrain() eases the centre back after an outgrowing zoom-out. A reported centre outside SG_BOUNDS is startup garbage — ignore it, don't clamp it. Keep its state in that component; a settle in MarketMap would re-serialise the tile style and all features.
  • Initial view priority: saved mapView → user's current location (first visit, via awaitingFix) → 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. The attribution={false} on Map is deliberate: the overlay replaces the text-only native button with the logo the licence demands.

i18n

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).

Dataset handling

  • Market identity is the raw NEA name string; favourites are stored as those strings. parseMarketName splits "Blk 1 Foo Rd (Bar Market)" into street plus friendly name and decodes HTML entities.
  • normalizeMarkets runs 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 by getDisplayName — the raw name string (identity) is never rewritten.
  • A market can leave the dataset: favourites pointing at a missing market are pruned on load, and useMarket returns null. Handle that in any new screen.
  • MAX_FAVORITES (lib/core/favorites.ts) caps the list; it's a reminder-queue bound alongside HORIZON_DAYS and MAX_SCHEDULED_REMINDERS, and reminder-schedule.test.ts asserts a full list still fits. Enforcement is at the add only; removal always works. toggleFavorite raises the limit Alert itself.
  • fetchMarketsFromAPI returns null rather than throwing (10s timeout, one retry); the caller falls back to the cache and sets stale.
  • 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 (getMarketCategories in lib/core/market-category.ts): any row with market stalls appears under "wet", any with food stalls under "food". A combined centre shows in both.