A crowdsourced, real-time map of essential services in Lebanon.
Kharta (Arabic for map) lets people report and verify the live status of the services that matter most during shortages and crises: fuel stations, pharmacies, bakeries, hospitals, and water sources. Anyone can check the map; anyone with a Lebanese phone number can report "this station is open, 95 and diesel available" or "this bakery is out of bread" — and neighbors confirm or dispute each report.
🎬 Desktop demo video (MP4, 35 s) · 📱 Mobile demo video (MP4, 35 s)
| Map — English | Map — Arabic (full RTL) |
|---|---|
![]() |
![]() |
| Service detail + community reports | Status report form |
|---|---|
![]() |
![]() |
Regenerate these assets anytime with the stack running:
cd frontend && npm i --no-save playwright && npx playwright install chromium && node scripts/capture-media.mjs(then convert with ffmpeg — seescripts/capture-media.mjsheader).
Lebanon's compounding crises made basic logistics a daily struggle: which station has fuel today, which pharmacy still stocks medication, which bakery has bread, which hospital is taking patients. That information exists — distributed across millions of people — but evaporates in WhatsApp groups within minutes. Kharta turns it into a live, self-correcting public map:
- Crowdsourced: every report comes from someone on the ground.
- Self-expiring: a fuel report dies after 3 hours; a hospital report after 12. The map never silently lies about freshness — stale statuses turn grey and ask for an update.
- Self-moderating: confirmations build reporter reputation; trusted users' downvotes and flags auto-hide junk.
- Bilingual: full Arabic (RTL) and English UI.
- Built for bad networks: PWA with offline caching, tiny app shell, skeleton screens, compressed GeoJSON.
| Layer | Tech |
|---|---|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, MapLibre GL JS + OpenFreeMap tiles (no API key), TanStack Query, Zustand, i18next, vite-plugin-pwa |
| Backend | Python 3.11, FastAPI, SQLAlchemy 2.0 (async), Pydantic v2, python-jose (JWT) |
| Data | PostgreSQL 15 + PostGIS (geospatial), Redis 7 (cache + rate limiting + OTP) |
| Infra | Docker Compose, Alembic migrations, Gunicorn/Uvicorn |
frontend (5173) ──/api──▶ backend (8000) ──▶ postgres + postgis (5432)
└───────▶ redis (6379)
- Docker Desktop (or Docker Engine + Compose v2) — the easy path; nothing else needed.
- For manual (non-Docker) setup: Python 3.11+, Node 20+, PostgreSQL 15 with the PostGIS extension available, Redis 7.
No map API key is required: tiles come from OpenFreeMap (free, no account, no usage limits).
cp .env.example .env
make up # build + start postgres, redis, backend, frontend
make migrate # apply database migrations
make seed # 30 demo service points across LebanonOpen http://localhost:5173 — the map of Lebanon with seeded fuel stations, pharmacies, bakeries, hospitals and water points. API docs: http://localhost:8000/docs.
Log in with any Lebanese-format number (e.g. 70 123 456); in development the OTP
code is always 1234 — no SMS is sent.
No make on Windows? Use the underlying commands:
docker compose up -d --build --wait
docker compose exec backend alembic upgrade head
docker compose exec backend python -m scripts.seedOther targets: make logs, make down, make reset (wipes volumes, rebuilds,
re-migrates, re-seeds).
The map uses MapLibre GL JS (the open-source fork of Mapbox GL) with vector tiles from OpenFreeMap — free for any use, no account, no credit card, no request limits. Attribution to OpenStreetMap contributors is rendered automatically by the map style.
Want a different look or provider? Set VITE_MAP_STYLE to any MapLibre-compatible
style URL:
- OpenFreeMap variants (still keyless):
https://tiles.openfreemap.org/styles/bright,…/styles/positron - MapTiler / Mapbox / Stadia styles (these need their own API keys)
- A fully self-hosted style — OpenFreeMap publishes its tile data if you want zero third-party dependencies in production.
1. Postgres + Redis. Install PostgreSQL 15 with PostGIS and Redis 7, then:
CREATE USER kharta WITH PASSWORD 'kharta_dev_password';
CREATE DATABASE kharta OWNER kharta;
\c kharta
CREATE EXTENSION postgis;
CREATE EXTENSION "uuid-ossp";2. Backend.
cd backend
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # adjust DATABASE_URL / REDIS_URL
alembic upgrade head
python -m scripts.seed
uvicorn app.main:app --reload # http://localhost:80003. Frontend.
cd frontend
npm install
cp .env.example .env # add VITE_MAPBOX_TOKEN
npm run dev # http://localhost:5173The dev server proxies /api/* to http://localhost:8000 (configurable via
VITE_API_PROXY_TARGET).
Root .env (consumed by docker-compose.yml):
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
kharta / kharta_dev_password / kharta |
Database credentials |
JWT_SECRET |
dev placeholder | Change for any real deployment. Signs access tokens |
ADMIN_PHONES |
+96170000001 |
Comma-separated phones granted admin on login |
ENV |
development |
production disables fixed OTP + /docs |
VITE_MAP_STYLE |
OpenFreeMap liberty | Optional override for the map style URL |
Backend-only (see backend/.env.example): DATABASE_URL, REDIS_URL, CORS_ORIGINS,
JWT_EXPIRES_DAYS, OTP_DEV_CODE, OTP_TTL_SECONDS, MAP_CACHE_TTL_SECONDS,
RATE_LIMIT_ENABLED, SQL_ECHO.
Frontend-only (see frontend/.env.example): VITE_MAP_STYLE,
VITE_API_PROXY_TARGET, VITE_API_URL.
Every report carries expires_at, set server-side by category — the half-life of the
information:
| Category | Report lives for |
|---|---|
| Fuel | 3 h |
| Bakery | 4 h |
| Pharmacy | 6 h |
| Water | 8 h |
| Hospital | 12 h |
Expired reports stop coloring the map (the point goes grey/"unknown") and the service sheet shows a stale warning asking visitors to submit an update.
- A report confirmed by 2+ upvotes earns its author +1 reputation (awarded once per report, processed as a background task after votes land).
- At 20 reputation a user becomes trusted.
- 3 downvotes from trusted users auto-hide a report.
- 2 flags from trusted users auto-hide a report.
- Admins (
ADMIN_PHONES) can list flagged reports (GET /admin/flags), hide/unhide any report (PATCH /admin/reports/{id}), and verify service points (PATCH /admin/services/{id}/verify) so they show a "verified" badge. - Users can't vote on their own reports. Banned users (
is_banned) cannot log in.
- All POST endpoints are rate-limited via Redis: 10/min per IP anonymous, 30/min per user authenticated, plus 10 reports/user/hour and 3 OTP requests/phone/10 min. Redis outages fail open (availability first).
- OTP codes are stored hashed with a 5-minute TTL and 5 attempts max.
- Coordinates for new locations must fall inside Lebanon's bounding box.
- PostGIS
GISTindex +ST_DWithingeography query, single round-trip with aLEFT JOIN LATERALfor each point's latest active report, capped at 1000 features. - Responses cached in Redis for 60 s per quantized viewport (center rounded to ~1 km, radius to 1 km, categories sorted), gzip-compressed.
- Frontend: map stays mounted across navigation, data refetches are quantized and deduplicated by TanStack Query, statuses auto-refresh every 60 s.
- PWA service worker: app shell precached, map tiles cache-first (7 days), map data network-first with offline fallback to the last snapshot.
Phone numbers are the only PII collected. They are never exposed in any public API
payload (reports show display names only), never written to client-side logs, and
masked (+961*****123) in server logs.
| Method & path | Auth | Description |
|---|---|---|
POST /auth/request-otp |
– | {phone} → {otp_id}; dev code is 1234 |
POST /auth/verify-otp |
– | {otp_id, code} → {access_token, user} |
GET /auth/me |
✓ | Current profile |
GET /map/services?lat&lng&radius_km&categories |
– | GeoJSON FeatureCollection with latest statuses (cached 60 s) |
GET /map/services/{id} |
– | Full detail + last 5 reports |
POST /map/services |
✓ | Submit new (unverified) service point |
POST /reports |
✓ | Submit status report (10/h per user) |
POST /reports/{id}/vote |
✓ | {vote: "up"|"down"} |
POST /reports/{id}/flag |
✓ | Flag for moderation |
GET /admin/flags |
admin | List flagged reports |
PATCH /admin/reports/{id} |
admin | Set is_hidden |
PATCH /admin/services/{id}/verify |
admin | Mark verified |
Interactive docs with schemas: http://localhost:8000/docs (development only).
Example: adding clinic.
- Backend constants — add
CLINIC = "clinic"toCategoryinbackend/app/constants.py, and its expiry window toEXPIRY_HOURSinbackend/app/services/expiry.py. - Migration — the category list is enforced by a DB check constraint:
docker compose exec backend alembic revision -m "add clinic category", then in the migration drop and recreateck_service_points_categorywith the new tuple. - Frontend — add
'clinic'to theCategoryunion infrontend/src/types/index.tsand toCATEGORIES,CATEGORY_HEX,CATEGORY_BG,CATEGORY_TEXTinfrontend/src/lib/constants.ts; add an icon incomponents/icons.tsx(CATEGORY_ICONS). - Translations — add
categories.clinicto all four files underfrontend/src/i18n/{en,ar}/map.json. Both languages are required. - Seed (optional) — add demo points in
backend/scripts/seed.py.
The map layers, filters, pins, and forms all derive from those constants — no other code changes needed.
- Every user-facing string goes through i18next, in both
enandarfiles. Test with the language toggled — RTL layout uses logical (start/end) utilities. - All DB access through SQLAlchemy bound parameters — never interpolate values into SQL.
- Never log phone numbers (client or server); use
mask_phoneserver-side if needed. - Keep
/map/servicesfast: anything added there must stay index-friendly and cacheable.
The dev setup makes two deliberate simplifications you must replace:
- SMS delivery —
backend/app/services/otp.py:send_smsis a stub. Wire it to Twilio (or a local SMS gateway with better Lebanon delivery rates) and setENV=productionso codes are random instead of1234. - Secrets — generate a strong
JWT_SECRETand front the API with a reverse proxy (the backend image already runs Gunicorn with Uvicorn workers). Tiles need no key; for full independence you can self-host OpenFreeMap's published tile data.
Also worth doing: pin CORS_ORIGINS to your real origin, schedule a periodic
DELETE FROM status_reports WHERE expires_at < now() - interval '30 days' if you don't
want history to grow unbounded, and add monitoring on /health.
- Two columns were added beyond the original schema sketch:
users.is_admin(admin authorization) andstatus_reports.reputation_awarded(idempotent reputation awards). - No automated test suite yet — the highest-value additions would be API tests for the vote/moderation/reputation flows and the expiry utility.
- Admin actions are API-only (use
/docs); a small admin UI is a natural next step. - WebSocket/SSE push for live status changes would remove the 60 s polling interval.
MIT © 2026 Omar Brome






