Real-time location sharing. Create a link, share it, let others follow your position on a map — no account required.
- Host opens the app, sets an expiry time (15min to 24h), clicks "Create sharing link"
- Host copies the link and shares it (WhatsApp, SMS, etc.)
- Viewers open the link and see the host's position updating on a map in real time; a "Show trail" button appears once the first position arrives to toggle the route polyline
- Host can stop sharing at any time by clicking "Stop sharing"
Web version: The app requests a Wake Lock to prevent auto-sleep, but manually locking the screen pauses tracking — a fundamental browser limitation. Keep the screen open for uninterrupted sharing.
Android app: True background tracking. The app continues sending position updates even with the screen locked, powered by a foreground service via Capacitor +
@capgo/background-geolocation.
- Frontend: React 18 + TypeScript + Vite + react-leaflet + OpenStreetMap
- Backend: Supabase (Postgres + Realtime Postgres Changes)
- Native (Android): Capacitor +
@capgo/background-geolocation(foreground service, distance-based updates) - Testing: Vitest (unit/integration, 100% coverage gate) + Playwright (E2E)
- Deploy: GitHub Pages (via GitHub Actions) / Android APK via Android Studio
- Node.js >= 22
- A Supabase project (free tier works)
git clone <repo-url> cd open-live-tracking npm install
cp .env.example .env
Edit .env with your Supabase project credentials (Settings → API):
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
Run the migration in Supabase → SQL Editor:
# Contents of supabase/migrations/001_create_sessions.sqlOr with the Supabase CLI:
supabase db push
The migration creates the sessions table, RLS policies, enables Realtime Postgres Changes, and adds a cleanup_expired_sessions() function.
Confirm Realtime is enabled: Supabase → Database → Replication → sessions should be listed under the supabase_realtime publication.
npm run dev # HTTP — works for desktop testing npm run dev:https # HTTPS via mkcert — required for geolocation on other devices
Open http://localhost:5173.
To test from a mobile device on the same Wi-Fi network, use npm run dev:https. On first run, mkcert generates a locally-trusted certificate (may prompt for sudo). Access the app at https://<your-local-ip>:5173.
npm run test # Unit + integration tests (fast) npm run test:coverage # Same + 100% coverage gate (fails if below) npm run test:e2e # Playwright E2E against a real Supabase project npm run test:e2e:ui # Playwright with interactive UI npm run lint # ESLint, zero warnings allowed npm run build # TypeScript check + production build
The coverage gate covers all statements, branches, functions, and lines. /* v8 ignore */ is only allowed for unreachable defensive guards — never for business logic paths.
The android/ directory is generated by Capacitor and tracked in git. To build and deploy to a device:
npm run build # build web assets into dist/ npx cap sync android # copy dist/ + sync plugins into android/ npx cap open android # open Android Studio
In Android Studio: Run ▶ to install on a connected device or emulator. For a signed release APK: Build → Generate Signed Bundle / APK.
First-time setup (Ubuntu/Linux via snap):
# 1. Install Android Studio sudo snap install android-studio --classic # 2. Download standalone cmdline-tools (find latest version at dl.google.com/android/repository/repository2-3.xml) mkdir -p ~/Android/Sdk/cmdline-tools curl -o /tmp/cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-<version>_latest.zip cd ~/Android/Sdk/cmdline-tools && unzip /tmp/cmdline-tools.zip && mv cmdline-tools latest # 3. Install SDK components export JAVA_HOME=/snap/android-studio/current/jbr export ANDROID_HOME=~/Android/Sdk export PATH=$PATH:$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0"
Add to your shell config (~/.config/fish/config.fish or equivalent):
set -x ANDROID_HOME $HOME/Android/Sdk set -x JAVA_HOME /snap/android-studio/current/jbr set -x CAPACITOR_ANDROID_STUDIO_PATH /snap/android-studio/current/bin/studio.sh set PATH $PATH $ANDROID_HOME/platform-tools $ANDROID_HOME/cmdline-tools/latest/bin $JAVA_HOME/bin
Accept any SDK license prompts that appear on first sync.
Permissions granted by the plugin at runtime: ACCESS_FINE_LOCATION (foreground) and ACCESS_BACKGROUND_LOCATION (background). The plugin's requestPermissions: true option prompts the user automatically on first use.
Host (web browser)
└─ navigator.geolocation.watchPosition (restarts on visibilitychange, throttle 3s)
└─ useGeolocation — web path
Host (Android native app)
└─ BackgroundGeolocation.start (foreground service, distanceFilter 10m)
└─ useGeolocation — native path (screen locked ✓, Doze mode ✓)
Both paths → useSession.sendPosition(PositionPayload)
└─ tracking.ts: updatePosition()
└─ Supabase: UPDATE sessions SET last_lat=..., last_lng=...
Supabase Realtime (Postgres Changes on sessions table)
└─ useRealtimePosition (viewer hook)
└─ ViewerPage → trail[] accumulated in state (client-side only, not persisted)
└─ MapView (marker + optional Polyline when showTrail=true)
Position updates go via Postgres Changes (not Broadcast channels). The host writes to the sessions row; the viewer subscribes to UPDATE events on that row. This means:
- Late-joining viewers always get the current position from the initial
SELECT - No separate "replay" mechanism needed
- Only the latest position is stored, no history
- Viewers accumulate the trail client-side in memory (ViewerPage state); refreshing the page clears it
idle → restoring → active → stopped
→ creating ↗ ↘ error
restoring: on page reload, the app attempts to recover an active session from sessionStorage (key: olt_session_token). This handles the case where the browser background-killed the tab while sharing was active.
loading → invalid (no such token)
→ waiting (valid session, no position yet)
→ live (position available, recently updated)
→ stale (no update for >30s)
→ expired (expires_at passed)
→ revoked (active=false, host stopped sharing)
Each session uses a nanoid(21) token (~126 bits of entropy) as the URL path. The token is both the session identifier and the access control mechanism. Expiry and revocation are validated server-side via Supabase RLS and the active column.
Known limitations: The RLS SELECT policy uses using(true) — any Supabase client with the anon key (which is embedded in the JS bundle and therefore public) can read all active sessions, not just the one whose token they know. The UPDATE policy has the same bearer-token scope: a viewer who knows the URL can technically modify the session row directly via the Supabase API. For a production deployment, route viewer fetches and host mutations through an authenticated Edge Function instead of direct client access.
All Supabase calls are isolated in src/lib/tracking.ts. Public interface:
createSession(opts: { expiresInMinutes: number }): Promise<SessionRow> getSession(token: string): Promise<SessionRow | null> updatePosition(token: string, position: PositionPayload): Promise<void> revokeSession(token: string): Promise<void> isSessionValid(session: SessionRow): boolean
To swap to Firebase, Node+WebSocket, or another backend:
- Rewrite
src/lib/tracking.ts - Rewrite
src/lib/supabase.ts(or remove it) - Rewrite
src/hooks/useRealtimePosition.tsfor your transport - No changes needed to components or pages
create table public.sessions ( token text primary key, created_at timestamptz not null default now(), expires_at timestamptz not null, active boolean not null default true, last_lat numeric, last_lng numeric, last_accuracy numeric, last_updated_at timestamptz );
See supabase/migrations/001_create_sessions.sql for the full migration (RLS policies, Realtime, cleanup function).
Schedule cleanup_expired_sessions() to mark expired sessions as inactive:
select public.cleanup_expired_sessions();
Use Supabase's built-in cron (Database → Functions) or pg_cron if enabled.
GitHub Actions runs on every push to main and develop, and on pull requests to main.
The four main jobs run in parallel:
| Job | What it does |
|---|---|
| Lint | ESLint, zero warnings |
| Unit Tests | Vitest with 100% coverage gate |
| Build | tsc --noEmit + vite build with correct GitHub Pages base path |
| E2E Tests | Playwright against the real Supabase project |
Deploy to GitHub Pages runs after all four pass, but only on push to main.
Add these to your GitHub repository (Settings → Secrets and variables → Actions):
| Secret | Where to find it |
|---|---|
VITE_SUPABASE_URL |
Supabase → Settings → API → Project URL |
VITE_SUPABASE_ANON_KEY |
Supabase → Settings → API → Project API keys → anon |
Use a dedicated Supabase test project for CI, separate from production.
In your repository settings: Pages → Source → GitHub Actions.