Skip to content

Repository files navigation

Curolia

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.

Monorepo

  • 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 assets
  • packages/plugin-contract — shared plugin manifest / contribution types (@curolia/plugin-contract)
  • packages/plugins/* — optional plugin packages (e.g. @curolia/plugin-ical); Edge sources sync into packages/supabase/supabase/functions/ via npx 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/mobile

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

Hybrid Mobile (PWA + Capacitor)

  • 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.json is next to those folders).
  • From the repo root, let Turbo prepare mobile prerequisites:
    • npx turbo run sync --filter=@curolia/mobile — builds web with apps/web/.env, regenerates native icons/splash, and runs cap sync
    • npm run open:ios -w @curolia/mobile
    • npm 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 emulator / simulator (local Supabase + live reload)

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 2

Save a file in the editor and the WebView reloads automatically.

Release versioning

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:

  1. resolve-version job (.github/actions/app-version) computes APP_VERSION once per deploy.
  2. Vercel sets APP_VERSION during vercel build → baked into VITE_APP_VERSION (About dialog, Bugsink release).
  3. Google Play passes the same integer as versionCode and versionName, and runs scripts/sync-native-version.ts so 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_VERSION

Android uses the same local defaults in apps/mobile/android/app/build.gradle; CI overrides with -PversionCode / -PversionName.

Mobile CI/CD offload

Native builds are integrated into .github/workflows/test.yml:

  • android job (Linux): gradlew under apps/mobile/android
  • ios job (macos-26, Xcode 26 preinstalled): simulator xcodebuild under apps/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.

Google Play (automated beta / internal testing)

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.

One-time Play Console setup

  1. 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.
  2. In Google Cloud Console, enable Google Play Android Developer API for the project linked to Play Console.
  3. Create a service account, download its JSON key, and in Play Console → Users and permissionsInvite new users, grant the service account Release to testing tracks (and Release apps to production only if you later point CI at production).
  4. 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
  1. Optional: for FCM push in release builds, base64-encode your Firebase **google-services.json** (base64 -i google-services.json | pbcopy).

GitHub production environment secrets

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.

Supabase (local, recommended for now)

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/supabase

Point the web app at the local API (defaults are stable):

  1. Create apps/web/.env (see [apps/web/.env.example](apps/web/.env.example)).
  2. Set VITE_SUPABASE_URL to the API URL from npm run db:status -w @curolia/supabase (usually http://127.0.0.1:54321).
  3. Set VITE_SUPABASE_PUBLISHABLE_KEY to 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 notifications (first mobile feature)

Push delivery is currently enabled for map_invitation notifications when the recipient has push enabled in settings.

  1. Ensure local Supabase and functions are running (npm run dev from repo root covers this):
npm run db:start -w @curolia/supabase
npx turbo run functions:sync
npm run functions:start -w @curolia/supabase
  1. 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_KEY

Restart **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
  1. 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
  1. 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_enabled is true.

Plugin sync jobs (background POI sync, etc.)

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:

  1. Add **PLUGIN_SYNC_DISPATCH_SECRET** to **packages/supabase/supabase/functions/.env**(generate withopenssl rand -base64 32; same pattern as **PUSH_DISPATCH_SECRET\*\*).
  2. After migrations, sync the secret into the local DB (so pg_cron can call dispatch):
 npm run db:sync-dispatch-secret -w @curolia/supabase
  1. Restart **functions serve** after editing **.env**.

Production (manual, one-time per secret rotation):

  1. Set **PLUGIN_SYNC_DISPATCH_SECRET** in Supabase Edge Function secrets (Dashboard → Edge Functions → Secrets). Generate with openssl rand -base64 32.
  2. Copy the same value into **private.worker_config** so pg_cron can authenticate dispatch calls. In the Supabase SQL editor (or any linked supabase db query --linked session), 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 + Edge config (local)

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:

  1. 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.
  2. **apps/web/.env**: VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY (see [apps/web/.env.example](apps/web/.env.example)).
  3. **packages/supabase/supabase/functions/.env**: copy from [.env.example](packages/supabase/supabase/functions/.env.example). Always set **PLUGIN_OAUTH_ENCRYPTION_KEY** (generate with openssl 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.
  4. **redirect_uri / Kong:** locally, Edge may see SUPABASE_URL as **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).

Hosted Supabase later

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.

Deploy (Vercel)

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: Supabase (GitHub Actions) + web (Vercel Git)

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

GitHub environment bootstrap (from scratch)

Create a GitHub Actions environment named production and add these secrets before running the full CI/CD pipeline:

  • Supabase deploy:
    • SUPABASE_ACCESS_TOKEN
    • SUPABASE_PROJECT_REF
    • SUPABASE_DB_PASSWORD
  • Vercel deploy:
    • VERCEL_TOKEN
    • VERCEL_ORG_ID
    • VERCEL_PROJECT_ID
  • Frontend runtime/build env (mirrored from Vercel Production env):
    • VITE_SUPABASE_URL
    • VITE_SUPABASE_PUBLISHABLE_KEY
  • Google Play deploy (see Google Play under Hybrid Mobile):
    • ANDROID_KEYSTORE_BASE64
    • ANDROID_KEYSTORE_PASSWORD
    • ANDROID_KEY_ALIAS
    • ANDROID_KEY_PASSWORD
    • GOOGLE_PLAY_SERVICE_ACCOUNT_JSON
    • GOOGLE_SERVICES_JSON_BASE64 (optional, for FCM push in release builds)

Optional hardening:

  • keep production secrets scoped to the production environment (not repo-wide)
  • require manual approval for the production environment if you want a deploy gate

Vercel-to-GitHub env sync (manual)

When env vars are already configured in Vercel UI, copy these frontend vars manually into the GitHub production environment secrets:

  • VITE_SUPABASE_URL
  • VITE_SUPABASE_PUBLISHABLE_KEY
  • VITE_UMAMI_WEBSITE_ID (optional; omit to disable analytics)

Plugin OAuth + Edge config (production)

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:

  1. In each provider’s developer console, register the Supabase callback URL https://<project-ref>.supabase.co/functions/v1/plugin-oauth?action=callback where required.
  2. Vercel Preview + Production: VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, and optionally VITE_UMAMI_WEBSITE_ID for analytics.
  3. **config.toml**: plugin-oauth keeps **verify_jwt = false\*\* (browser redirect has no JWT); other functions verify JWT in the handler if needed.
  4. After setting Edge Function secrets, update **private.worker_config** for plugin sync dispatch (see Plugin sync jobs).
  5. Deploy: GitHub workflow runs **functions:sync**, **supabase db push**, **supabase functions deploy --use-api**, then Vercel prebuilt deploy.

TODO

Geoapify Places: multi-category requests

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:

  1. Empty responses — some pairs return zero features when matches exist nearby (e.g. healthcare + tourism at Zeist; pairwise tests in chat ae41d8aa).
  2. Skewed ranking — a single combined list favours some groups (e.g. Utrecht Domplein: mostly tourism artwork/statues while catering-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).

Dead code / unused exports cleanup

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-splash directly
  • apps/web/src/components/map/map-toolbar.tsx — unused re-export; callers use @curolia/ui/map-toolbar directly
  • apps/web/src/components/pins/pin-photo-lightbox.tsx — unused re-export; callers use @curolia/ui/pin-photo-lightbox directly

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 under packages/ui/src/storybook/

Expected Knip false positives (ignore or configure out):

  • Supabase Edge Function entrypoints under packages/supabase/supabase/functions/ and synced copies under packages/plugins/*/supabase/functions/
  • Codegen / script entrypoints (e.g. packages/supabase/scripts/extract-plugin-oauth-registry.ts, plugin oauth-registry.ts sources)
  • apps/web/src/curolia-ui-styles.d.ts — TypeScript module shim for import "@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.

Roadmap

  • Public HTTP API — service role + API keys behind a small Node layer or Edge Functions when you need non-Supabase clients.

About

Remember every place you go

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages