Place memory app: pins (visits and spots) grouped in maps (collections you own or share), with a MapLibre view, blog, tags, photos, and plugins. Stack: npm workspaces, Turborepo, Vite + React + TypeScript, shadcn/ui, Supabase (Auth, Postgres, Storage), MapLibre.
apps/web— SPA package**@curolia/web** (turbo --filter=@curolia/web)apps/mobile— Capacitor host**@curolia/mobile**(android/,ios/live here)packages/supabase/supabase/— Supabase project (migrations,config.toml,functions/) via**@curolia/supabase**packages/brand/— app logo + theme config (**@curolia/brand**) and generators for web/native branding assetspackages/plugin-contract— shared plugin manifest / contribution types (@curolia/plugin-contract)packages/plugins/*— optional plugin packages (e.g.@curolia/plugin-ical); Edge sources sync intopackages/supabase/supabase/functions/vianpx turbo run functions:sync. Structured plugin payloads attached to pins (and future entities) use**public.plugin_entity_data**(see migrations). Plugins that need OAuth or external dashboards document setup in their own README (e.g.[packages/plugins/google-photos/README.md](packages/plugins/google-photos/README.md),[packages/plugins/spotify/README.md](packages/plugins/spotify/README.md),[packages/plugins/lastfm/README.md](packages/plugins/lastfm/README.md)).
Plugin architecture details: [packages/plugin-contract/README.md](packages/plugin-contract/README.md).
See [AGENTS.md](AGENTS.md) for codegen rules (including never hand-editing database.types.ts).
Root scripts are Turborepo + Prettier only; see **AGENTS.md → Monorepo scripts**. The root **turbo.json** owns cross-package ordering: branding runs before the web plugin registry, web checks/builds depend on codegen, and mobile sync depends on the web build.
Common commands (from repo root):
npm ci
npm run dev # Turbo dev: Supabase stack/functions, Vite, Storybook
npm run build # turbo run build
npx turbo run lint typecheck test build # CI shape; Turbo pulls required codegen
npx turbo run sync --filter=@curolia/mobile # prepare native project; Turbo builds web and native assets first
npm run open:ios -w @curolia/mobile
npm run open:android -w @curolia/mobileThe production Vercel job runs **npx turbo run codegen** after install, then **vercel build**with**apps/web/vercel.json** **buildCommand**: **npm run build**(the**@curolia/web\*\* Vite/TSC pipeline only).
- Web app ships as a PWA (installable + offline static shell caching).
- Native shells live under
**apps/mobile/ios**and**apps/mobile/android**and reuse**apps/web/dist**(capacitor.config.jsonis next to those folders). - From the repo root, let Turbo prepare mobile prerequisites:
npx turbo run sync --filter=@curolia/mobile— builds web withapps/web/.env, regenerates native icons/splash, and runscap syncnpm run open:ios -w @curolia/mobilenpm run open:android -w @curolia/mobile
- Signed-in app routes use
StackLayout: the geographic map view and blog for the active collection stay mounted as the base layer; settings, pin detail, and other screens stack on top (back restores the base instantly). Mobile / native add slide transitions and hide the floating nav on stack screens; desktop keeps the nav and swaps stack layers without animation. Android hardware back pops the stack or exits on the base map/blog routes.
For iOS development, install Xcode 26+ (required by Capacitor 8) and CocoaPods. For Android, install Android Studio SDK tools.
Native shells use the Vite dev server with the same apps/web/.env as the browser (127.0.0.1:54321). On Android emulators, the app rewrites that to 10.0.2.2 at runtime — no separate env file or dev script.
Start local Supabase first (npm run dev from repo root, or npm run db:start -w @curolia/supabase).
npm run dev -w @curolia/web # terminal 1
npm run dev:android -w @curolia/mobile # or dev:ios — terminal 2Save a file in the editor and the WebView reloads automatically.
Curolia uses a single monotonic integer for every surface (web, Android, iOS) — not semver. The same commit deployed to production gets the same number everywhere.
| Context | Version source |
|---|---|
Production deploy (deploy.yml) |
github.run_number of that workflow run |
| Local / PR builds | dev (DEFAULT_APP_VERSION in scripts/resolve-app-version.ts) |
Flow on deploy:
resolve-versionjob (.github/actions/app-version) computesAPP_VERSIONonce per deploy.- Vercel sets
APP_VERSIONduringvercel build→ baked intoVITE_APP_VERSION(About dialog, Bugsinkrelease). - Google Play passes the same integer as
versionCodeandversionName, and runsscripts/sync-native-version.tsso the iOS Xcode project matches for future TestFlight/App Store jobs.
Helpers:
npm run version:resolve # print resolved version (respects APP_VERSION env)
npm run version:sync-native # write resolved version into iOS MARKETING_VERSION / CURRENT_PROJECT_VERSIONAndroid uses the same local defaults in apps/mobile/android/app/build.gradle; CI overrides with -PversionCode / -PversionName.
Native builds are integrated into .github/workflows/test.yml:
androidjob (Linux):gradlewunderapps/mobile/androidiosjob (macos-26, Xcode 26 preinstalled): simulatorxcodebuildunderapps/mobile/ios/App(no signing)
Both jobs depend on the main ci job, then run **npx turbo run sync --filter=@curolia/mobile** so Turbo prepares native assets and builds the web output before Capacitor sync.
GitHub Actions APK/IPA: VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are injected per job (see workflow ci, android, ios). They must point at your hosted Supabase project (not 127.0.0.1). Those jobs use **environment: production** so GitHub environment secrets work; a workflow-wide env block **cannot** read environment-only secrets. If the production environment limits which branches may deploy, pull-request runs might not see those secrets — use repository secrets for PR CI, relax the rule, or add a separate environment for builds.
After [.github/workflows/test.yml](.github/workflows/test.yml) passes on a push to main, [.github/workflows/deploy.yml](.github/workflows/deploy.yml) can build a signed Android App Bundle and publish it to Google Play when repository variable **ENABLE_PLAY_STORE_DEPLOY** is **true**(unset or any other value skips the job). By default the track is**internal** (Play Console → Testing → Internal testing). Set **PLAY_STORE_TRACK** to alpha, beta, or production to change the target.
The job runs after Supabase deploy (alongside Vercel). See Release versioning below for how web, Android, and iOS share the same version number per deploy.
- Create the app in Google Play Console with package name
**com.curolia.app**and complete the initial store listing / content rating / data safety requirements so the app can accept uploads. - In Google Cloud Console, enable Google Play Android Developer API for the project linked to Play Console.
- Create a service account, download its JSON key, and in Play Console → Users and permissions → Invite new users, grant the service account Release to testing tracks (and Release apps to production only if you later point CI at production).
- Generate an upload keystore (keep it safe — you cannot replace it after the first Play upload):
keytool -genkeypair -v \
-keystore curolia-release.keystore \
-alias curolia \
-keyalg RSA -keysize 2048 -validity 10000
base64 -i curolia-release.keystore | pbcopy # paste into GitHub secret- Optional: for FCM push in release builds, base64-encode your Firebase
**google-services.json**(base64 -i google-services.json | pbcopy).
Add these alongside the existing deploy secrets:
| Secret | Purpose |
|---|---|
ANDROID_KEYSTORE_BASE64 |
Base64-encoded upload keystore (.jks / .keystore) |
ANDROID_KEYSTORE_PASSWORD |
Keystore password |
ANDROID_KEY_ALIAS |
Key alias (e.g. curolia) |
ANDROID_KEY_PASSWORD |
Key password (often same as keystore password) |
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON |
Full JSON key for the Play Console service account |
GOOGLE_SERVICES_JSON_BASE64 |
(optional) Firebase config for push notifications |
Optional repository variables:
| Variable | Default | Purpose |
|---|---|---|
ENABLE_PLAY_STORE_DEPLOY |
(off) | Set to true to run the Play Store job on deploy; otherwise skipped |
PLAY_STORE_TRACK |
internal |
Play track: internal, alpha, beta, or production |
Promote a tested build to production from Play Console, or set PLAY_STORE_TRACK=production when you are ready for CI to ship production releases.
Prerequisites: Docker (or another engine the Supabase CLI can use).
From the repo root:
npm install
npm run db:start -w @curolia/supabase
npm run db:reset -w @curolia/supabase # optional: wipe local DB and re-apply all migrations
npm run db:status -w @curolia/supabasePoint the web app at the local API (defaults are stable):
- Create
apps/web/.env(see[apps/web/.env.example](apps/web/.env.example)). - Set
VITE_SUPABASE_URLto the API URL fromnpm run db:status -w @curolia/supabase(usuallyhttp://127.0.0.1:54321). - Set
VITE_SUPABASE_PUBLISHABLE_KEYto the anon key from that same command.
Then run **npm run dev** from the repo root (Turbo runs **@curolia/supabase#stack** once: supabase start+**functions:sync**, then **supabase functions serve**, Vite, and Storybook (port 6006) in parallel after that). Open the web app URL from Vite's output. Studio lists tables and auth users. Mailpit catches auth emails if you turn confirmations back on.
Stopping: Ctrl+C stops the Turbo dev tasks; **npm run db:stop -w @curolia/supabase** stops Docker when you're done.
Edge Functions: **npm run dev** pulls plugin handlers in via **stack**. After changing files under **packages/plugins/\*/supabase/functions/**, restart **npm run dev**(or run**npx turbo run functions:sync**and restart**functions serve\*\* only).
Push delivery is currently enabled for map_invitation notifications when the recipient has push enabled in settings.
- Ensure local Supabase and functions are running (
npm run devfrom repo root covers this):
npm run db:start -w @curolia/supabase
npx turbo run functions:sync
npm run functions:start -w @curolia/supabase- Set local function secrets for the dispatcher. The Supabase CLI has no
secrets set --local; use an env file next to the functions instead:
cp packages/supabase/supabase/functions/.env.example packages/supabase/supabase/functions/.env
# Edit `.env`: set PUSH_DISPATCH_SECRET and FCM_SERVER_KEYRestart **npm run functions:start -w @curolia/supabase** (or your dev stack) after changing packages/supabase/supabase/functions/.env. 2. Apply migrations and regenerate DB types:
npm run db:migrate -w @curolia/supabase
npm run db:types -w @curolia/supabase- Build web, sync native shells, and run on device/emulator:
npx turbo run sync --filter=@curolia/mobile
npm run open:android -w @curolia/mobile
# or npm run open:ios -w @curolia/mobile on macOS- Trigger dispatch (example):
curl -X POST http://127.0.0.1:54321/functions/v1/push-dispatch \
-H "Authorization: Bearer <PUSH_DISPATCH_SECRET>" \
-H "Content-Type: application/json" \
-d '{"limit":50}'Notes:
- Device tokens are stored in
public.push_tokens. - Pending deliveries are queued in
public.push_notification_outbox. - The web/native app registers tokens only on native platforms and only when
notification_push_enabledis true.
Plugin-owned **plugin-sync-dispatch** / ***-dispatch** workers process rows in **public.plugin_sync_jobs**. **pg_cron** invokes dispatch using **private.worker_config** (not the browser).
Local:
- Add
**PLUGIN_SYNC_DISPATCH_SECRET** to**packages/supabase/supabase/functions/.env**(generate withopenssl rand -base64 32; same pattern as**PUSH_DISPATCH_SECRET\*\*). - After migrations, sync the secret into the local DB (so pg_cron can call dispatch):
npm run db:sync-dispatch-secret -w @curolia/supabase- Restart
**functions serve**after editing**.env**.
Production (manual, one-time per secret rotation):
- Set
**PLUGIN_SYNC_DISPATCH_SECRET**in Supabase Edge Function secrets (Dashboard → Edge Functions → Secrets). Generate withopenssl rand -base64 32. - Copy the same value into
**private.worker_config**so pg_cron can authenticate dispatch calls. In the Supabase SQL editor (or any linkedsupabase db query --linkedsession), run:
update private.worker_config
set value = '<your-plugin-sync-dispatch-secret>'
where key = 'plugin_sync_dispatch_secret';
update private.worker_config
set value = 'https://<project-ref>.supabase.co/functions/v1'
where key = 'plugin_sync_functions_base';Replace <project-ref> with your Supabase project ref and use the same secret as step 1. The deploy workflow does not run this automatically.
Plugin OAuth is handled by the **plugin-oauth** Edge Function; encrypted tokens live in **user_plugin_oauth_tokens**. Provider client IDs/secrets and dashboard steps are per plugin — see:
Common steps:
- Run Supabase and functions (see above). After changing files under
**packages/plugins/*/supabase/functions/**, run**npx turbo run functions:sync**and restart**functions serve\*\*if needed. **apps/web/.env**:VITE_SUPABASE_URL,VITE_SUPABASE_PUBLISHABLE_KEY(see[apps/web/.env.example](apps/web/.env.example)).**packages/supabase/supabase/functions/.env**: copy from[.env.example](packages/supabase/supabase/functions/.env.example). Always set**PLUGIN_OAUTH_ENCRYPTION_KEY**(generate withopenssl rand -base64 32) and**PUBLIC_APP_ORIGIN**(e.g.http://127.0.0.1:5173). Add provider vars for the plugins you use (**GOOGLE_***,**SPOTIFY_***,**LASTFM_API_KEY**). For push dispatch set**PUSH_DISPATCH_SECRET**/**FCM_SERVER_KEY**; for plugin sync jobs set**PLUGIN_SYNC_DISPATCH_SECRET**(see Plugin sync jobs above). Restart**npm run functions:start -w @curolia/supabase** after edits.**redirect_uri/ Kong:** locally, Edge may seeSUPABASE_URLas**http://kong:8000**.**plugin-oauth**maps that to**http://127.0.0.1:54321**for the OAuth callback when the hostname iskong. Override with**SUPABASE_PUBLIC_PORT**or**PLUGIN_OAUTH_CALLBACK_URL**if needed.
On first signup, a profile, personal map, and owner membership are created automatically (via the migration trigger).
Use supabase link against your cloud project, then supabase db push for migrations. Copy that project’s URL and anon/publishable key into apps/web/.env for deployed or hybrid setups.
Configure the Vercel project Root Directory to **apps/web** so it picks up [apps/web/vercel.json](apps/web/vercel.json).
That file installs from the repo root, runs **npm run build** inside **apps/web** (**buildCommand**). **codegen** is executed through Turbo in CI before vercel build so generated assets exist. **outputDirectory** is **dist**relative to**apps/web\*\*.
Set the same Supabase env vars for Production (and Preview): VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY.
Link the CLI from apps/web (creates apps/web/.vercel/) if you deploy locally: cd apps/web && npx vercel link.
Production web deploy is orchestrated by GitHub Actions (.github/workflows/deploy.yml) from **apps/web** using the vercel npm scripts (vercel pull / vercel build / vercel deploy --prebuilt).
Vercel Git auto-deploy is disabled (apps/web/vercel.json → git.deploymentEnabled: false) so deployments happen only through the deploy workflow.
The [.github/workflows/deploy.yml](.github/workflows/deploy.yml) workflow runs after [.github/workflows/test.yml](.github/workflows/test.yml) succeeds on a push to main. It runs **npx turbo run functions:sync** (copies plugin packages’ function sources into packages/supabase/supabase/functions/), then **supabase db push** and **supabase functions deploy --use-api** from packages/supabase. This keeps deployed Edge code aligned with packages/plugins/*, not only last-run sync output. Plugin sync dispatch credentials (**PLUGIN_SYNC_DISPATCH_SECRET** / **private.worker_config**) are configured manually — see Plugin sync jobs above.
supabase deploy runs before the vercel and **play-store** jobs so database/functions are updated before the production web deployment and Google Play upload (see Google Play under Hybrid Mobile; Play Store is gated by **ENABLE_PLAY_STORE_DEPLOY**).
GitHub secrets for the production environment (or repository): SUPABASE_ACCESS_TOKEN, SUPABASE_PROJECT_REF, SUPABASE_DB_PASSWORD. The database password is the Supabase project Database password (Settings → Database). **PLUGIN_SYNC_DISPATCH_SECRET** is set only in Supabase (Edge Function secrets + **private.worker_config**), not in GitHub Actions.
Create a GitHub Actions environment named production and add these secrets before running the full CI/CD pipeline:
- Supabase deploy:
SUPABASE_ACCESS_TOKENSUPABASE_PROJECT_REFSUPABASE_DB_PASSWORD
- Vercel deploy:
VERCEL_TOKENVERCEL_ORG_IDVERCEL_PROJECT_ID
- Frontend runtime/build env (mirrored from Vercel Production env):
VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEY
- Google Play deploy (see Google Play under Hybrid Mobile):
ANDROID_KEYSTORE_BASE64ANDROID_KEYSTORE_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORDGOOGLE_PLAY_SERVICE_ACCOUNT_JSONGOOGLE_SERVICES_JSON_BASE64(optional, for FCM push in release builds)
Optional hardening:
- keep production secrets scoped to the
productionenvironment (not repo-wide) - require manual approval for the
productionenvironment if you want a deploy gate
When env vars are already configured in Vercel UI, copy these frontend vars manually into the GitHub production environment secrets:
VITE_SUPABASE_URLVITE_SUPABASE_PUBLISHABLE_KEYVITE_UMAMI_WEBSITE_ID(optional; omit to disable analytics)
Put OAuth and plugin secrets in the Supabase project (Dashboard → Edge Functions secrets or supabase secrets set), not in Vercel. Browser/build vars stay **VITE_SUPABASE_*** only.
Generate **PLUGIN_OAUTH_ENCRYPTION_KEY** with openssl rand -base64 32. Include **PUBLIC_APP_ORIGIN** (your deployed web origin) plus whichever providers you enable, for example:
cd packages/supabase && npx supabase secrets set \
PLUGIN_OAUTH_ENCRYPTION_KEY=<base64 32-byte key> \
PLUGIN_SYNC_DISPATCH_SECRET=<base64 32-byte key> \
PUBLIC_APP_ORIGIN=https://<your-vercel-domain> \
GOOGLE_CLIENT_ID=... \
GOOGLE_CLIENT_SECRET=... \
SPOTIFY_CLIENT_ID=...
SPOTIFY_CLIENT_SECRET=... # required for spotify Edge (track/playlist metadata)Provider-specific redirect URIs and dashboards: Google Photos, Spotify, Last.fm. The **plugin-oauth** callback path is always **/functions/v1/plugin-oauth?action=callback** on your Supabase API URL.
Production checklist:
- In each provider’s developer console, register the Supabase callback URL
https://<project-ref>.supabase.co/functions/v1/plugin-oauth?action=callbackwhere required. - Vercel Preview + Production:
VITE_SUPABASE_URL,VITE_SUPABASE_PUBLISHABLE_KEY, and optionallyVITE_UMAMI_WEBSITE_IDfor analytics. **config.toml**:plugin-oauthkeeps**verify_jwt = false\*\*(browser redirect has no JWT); other functions verify JWT in the handler if needed.- After setting Edge Function secrets, update
**private.worker_config**for plugin sync dispatch (see Plugin sync jobs). - Deploy: GitHub workflow runs
**functions:sync**,**supabase db push**,**supabase functions deploy --use-api**, then Vercel prebuilt deploy.
The POI plugin queries Geoapify’s Places API from packages/plugins/poi/supabase/functions/poi/index.ts. Do not pass many top-level category groups in a single categories= parameter.
Geoapify confirmed (Jun 2026) two related limitations when diverse categories are combined:
- Empty responses — some pairs return zero features when matches exist nearby (e.g.
healthcare+tourismat Zeist; pairwise tests in chat ae41d8aa). - Skewed ranking — a single combined list favours some groups (e.g. Utrecht Domplein: mostly
tourismartwork/statues whilecatering-only at the same spot returns cafés/restaurants).
Current workaround (max two Places requests for nearby):
| Request | Categories | Notes |
|---|---|---|
| 1 — destinations | commercial, catering, service, amenity, … (no tourism) |
Fills the nearby list first |
| 2 — tourism | tourism |
Merged only after destination slots are taken |
healthcare is still omitted — it needs its own request and would exceed the two-call budget; re-enable when we accept a third call or Geoapify fixes combined search.
Text search uses Nominatim (one request), biased to the pin and clipped to POI_TEXT_SEARCH_RADIUS_M (5 km) — wider than the 40 m nearby list radius.
Regression checks: Utrecht Domplein (cafés not drowned by artwork), Zeist hospital coords (healthcare batch when re-enabled).
A one-off scan with Knip (npx knip from the repo root) flagged likely leftovers from refactors. Nothing is wired into CI yet — add a root **knip.json** (workspace entry points + ignore patterns) before treating output as authoritative.
Likely safe to remove (verify first):
apps/web/src/components/layout/curolia-loading-splash.tsx— unused re-export; callers use@curolia/ui/loading-splashdirectlyapps/web/src/components/map/map-toolbar.tsx— unused re-export; callers use@curolia/ui/map-toolbardirectlyapps/web/src/components/pins/pin-photo-lightbox.tsx— unused re-export; callers use@curolia/ui/pin-photo-lightboxdirectly
Review before deleting (may be intentional public API or stale helpers):
- Unused exports in
apps/web/src/lib/(e.g.map-view-params.ts,pin-dates.ts,photon-geocode.ts,pin-text-search.ts, …) - Unused exports in
apps/web/src/components/pins/pin-links-list.tsx,apps/web/src/plugins/registry.ts, Storybook helpers underpackages/ui/src/storybook/
Expected Knip false positives (ignore or configure out):
- Supabase Edge Function entrypoints under
packages/supabase/supabase/functions/and synced copies underpackages/plugins/*/supabase/functions/ - Codegen / script entrypoints (e.g.
packages/supabase/scripts/extract-plugin-oauth-registry.ts, pluginoauth-registry.tssources) apps/web/src/curolia-ui-styles.d.ts— TypeScript module shim forimport "@curolia/ui/styles", not a runtime import- Storybook-only files and tests
- “Unused dependencies” for side-effect imports (e.g.
@fontsource-variable/geist) until Knip is configured per workspace
Already cleaned up: Vite starter files in apps/web/src/assets/, thin route wrappers, unused login-layout exports. Marketing/legal pages live in @curolia/site; About dialog is composed in apps/web.
- Public HTTP API — service role + API keys behind a small Node layer or Edge Functions when you need non-Supabase clients.