Skip to content

Repository files navigation

PasarGuru

Discover Singapore's hawker centres and wet markets — find one near you, check if it's open, and get a heads-up before it closes. iOS and Android, built with Expo.

Why

Most stalls at Singapore wet markets rest on Mondays, and markets have quarterly cleaning closures. The schedule is publicly available on data.gov.sg but not easy to check quickly. This app gives you the answer at a glance.

Features

  • Instant open/closed status for the markets you follow
  • Covers all 123 NEA-managed hawker centres and wet markets
  • Detects Monday rest days (stallholder convention, not NEA-mandated), quarterly cleaning, and other maintenance closures
  • Upcoming closures list, and the next date each market reopens
  • Map of every market, sorted-by-distance search when you allow location
  • Local closure reminders: 7pm the evening before and 6am the morning of
  • Tap an address to open it in Apple Maps or Google Maps — whichever you pick in Settings
  • Bilingual: English and Chinese
  • Works offline — the dataset is cached and revalidated in the background
  • Senior-friendly: honours Dynamic Type at every size, high contrast, light and dark

Data Source

Dates of Hawker Centre Closure from Singapore's National Environment Agency via data.gov.sg. There is no backend of any kind: the app fetches that dataset directly and does everything else on device.

Layout

app/          expo-router routes: a tab shell, plus a detail screen and an add-markets modal
components/   screen components; components/ui/ is the shared primitives and theme consumers
lib/core/     pure closure + reminder logic, no React and no React Native — see Testing
lib/theme/    light and dark tokens, and the matching react-navigation theme
lib/store/    app state as an external store read through useSyncExternalStore
lib/          storage, the NEA fetch, i18n, date formatting, notifications, background refresh
brand/       the icon's design sources, and the script that derives assets/ from them
assets/      the icon and splash masters the native build reads — all generated by brand/
docs/        the privacy policy, served via GitHub Pages

lib/core/ is the one directory with no dependency on React Native. Its files import each other with explicit .ts specifiers so Node can run them directly; everything outside imports without the extension.

npm run icons regenerates assets/; brand/README.md covers the prerequisites and why each master looks the way it does.

Testing

Unit tests cover the closure logic (date parsing, open/closed detection, boundary conditions) and the notification schedule. Node's built-in runner executes the .ts files directly by stripping types, so there is nothing to install and no test framework in the tree.

npm test
npm run typecheck   # the app program, then lib/core against Node's globals

Both run on push and PR via GitHub Actions. tsconfig.test.json deliberately gives lib/core/ Node's globals and not React Native's, so an accidental import from lib/ fails typecheck rather than at runtime.

The screens are not unit-tested; verifying them means a device build. Notifications especially cannot be checked in a simulator.

E2E (Maestro)

Screen flows are covered by Maestro YAML in e2e/flows/. The CLI is a standalone tool — nothing to install into the project:

brew tap mobile-dev-inc/tap && brew install maestro   # needs Java 17+, Xcode, Android SDK

Build a standalone dev app (embeds its JS, no Metro needed), then run the flows against it:

# iOS — boot a simulator first
APP_VARIANT=development npx expo run:ios --configuration Release
npm run e2e:ios

# Android — boot an emulator (API 29–34; 35/36 unsupported by Maestro)
APP_VARIANT=development npx expo run:android --variant release
npm run e2e:android

Flows target elements by testID (stable across languages) and run against com.sfdye.pasarguru.dev, the dev build identity. Notifications and deep-link handling remain untestable in simulators.

Develop

npm install
npm run ios       # build, install on a device, then start Metro
npm run android
npm start         # Metro alone, once the app is already installed

Those install a separate app called "PasarGuru Dev": app.config.ts gives the dev build its own name and bundle identifier when APP_VARIANT=development is set, which the scripts above do, so it sits alongside a TestFlight build instead of replacing it. Build it once and leave it there — a JS change only needs npm start and a reload, and only a change to app.json, a native dependency or a config plugin needs another build. With no Metro running the dev app has nothing to load, because a debug build fetches its JS at launch rather than embedding it.

Expo Go cannot run this app. @maplibre/maplibre-react-native is a third-party native module and is not compiled into Expo Go, so the map fails there no matter which Expo Go version is installed — the error is usually a misleading "download the latest version of Expo Go". Use a dev build, which the commands above produce. A local iOS build also needs CocoaPods once (brew install cocoapods) and a device on an iOS version Xcode supports.

Native project

ios/ and android/ are generated from app.json (through app.config.ts) and gitignored, so nothing in them is a source of truth — edit app.json, never the native folders. npm run ios and EAS both regenerate them, so there is no prebuild step to run by hand.

Install native dependencies with npx expo install expo-foo rather than npm, so the version matches the SDK.

Build and release

npm i -g eas-cli
npm run release                                       # bump the build number, commit and tag it
eas build --profile production -p all                 # both stores on one build number
eas build:list                                        # recent builds and their status

# instead of -p all, one platform at a time on that same number:
eas build --profile production -p ios --auto-submit   # .ipa straight to TestFlight
eas build --profile production -p android             # .aab for Play

production is the TestFlight profile too — TestFlight and the App Store take the same binary. eas submit -p ios --latest submits a build that was made without --auto-submit. eas.json also carries an apk profile for a standalone Android .apk and a development profile for installing a dev client over the air, neither of which a release needs.

EAS builds from committed git state, not the working tree, so commit before building — uncommitted files are silently absent in the cloud. That also means the gitignored ios/ is never uploaded and EAS prebuilds from app.json instead, which is what you want.

appVersionSource is local, so the build number lives in the repo rather than on EAS: app.json carries it as ios.buildNumber and android.versionCode, right beside version. npm run release runs typecheck and tests, sets both keys from one number, commits, and tags v<version>+<build>; it deliberately neither pushes nor builds, and it refuses to run if it finds the two keys already disagreeing. Because EAS builds committed state, a build made without that commit re-sends the previous number and the store rejects the upload.

One number set by hand instead of autoIncrement, which bumps each platform in its own step and so counts per platform on either version source — that is how Android ended up a number ahead of iOS, with the apk profile inheriting the flag through extends and widening the gap on every internal APK. A build number only ever goes up: both stores refuse one they have already seen for a version, so a number is spent even if the build that used it failed. Bump the user-facing version by hand per release — it is the only version string a user ever sees.

TestFlight needs a paid Apple Developer account; the certificate a local expo run:ios uses is development-only and expires. EAS stores signing credentials on its servers rather than on disk, so none of it belongs in this repo — and because the repo is public, leave appleId out of eas.json and let eas submit prompt, or pass EXPO_APPLE_ID.

Over-the-air updates

JS and asset changes — a translation fix, a market-name correction in zh-names.ts, a closure-logic quirk in normalizeMarkets — ship without a store release via EAS Update. expo-updates is baked into the binary, app.json sets a runtimeVersion policy of appVersion (an update targets the current version, so it reaches every install of that store release), and the production EAS profile carries channel: "production", which the apk profile inherits through extends. The first build that contains expo-updates still goes through the stores — a binary without the module can never receive an OTA, so the build already in TestFlight/Play is unreachable until users install the release that adds it.

eas update --channel production --message "Fix 芽笼巴刹 translation" --environment production

eas update bundles the working tree, not committed git state — the opposite of eas build, which uploads the committed tree — so a clean working tree matters at publish time even though nothing is built in the cloud. Users get the update on the next cold start (it downloads in the background) and run it on the cold start after that; reminder copy and the schedule rebuild on that cold start, because the store reschedules from a fresh key on launch.

What still needs a store release: a new native dependency, an SDK upgrade, or any app.json change that touches native config (permissions, icons, splash, infoPlist). Bumping version for such a release moves the runtime version, so later updates target the new version; to also reach users who have not updated from the store, republish the same update against the old runtime version.

eas channel:view production              # the branch the channel points at, and its latest update
eas branch:view production               # the update history on that branch
eas update:republish --branch production # re-push a previous update — the practical rollback
eas update:rollback                      # interactive: roll back to a prior update or to the embedded bundle

Install a local build on a device

eas build --local writes build-<timestamp>.ipa to the repo root (gitignored). Getting it onto a phone is a cable job — iOS will not install an .ipa that arrives by AirDrop. Connect and unlock the phone, then:

xcrun devicectl list devices
xcrun devicectl device install app --device <identifier> <path-to-ipa>

Which phones accept the file is fixed at build time by distribution: development and apk are internal, ad-hoc signed for exactly the UDIDs registered on the account; production provisions no devices and is TestFlight-only. Register a phone with eas device:create and rebuild. To read back what a given .ipa allows:

unzip -p <path-to-ipa> 'Payload/*.app/embedded.mobileprovision' | security cms -D -i /dev/stdin | plutil -p -

A development .ipa is a dev client with no JS bundle, so it needs npm start on the same network. For a self-contained dev app, skip EAS: APP_VARIANT=development npx expo run:ios --configuration Release builds, signs and installs in one step.

License

MIT

About

Discover Singapore wet markets and hawker centres — check opening status, get closing reminders, and navigate there

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages