A personal travel journal: record trips, track what you spent, keep the photos and the story together, and watch a map fill in as you go.
Everything is private to your account.
| Layer | Choice | Why |
|---|---|---|
| Backend | Python 3.12 · FastAPI | Async, typed, generates its own OpenAPI docs |
| Database | PostgreSQL 17 · SQLAlchemy 2.0 (async) · Alembic | Relational integrity for money and ownership; versioned schema |
| Auth | Argon2id + JWT access tokens + rotating refresh cookies | Memory-hard hashing; short-lived tokens; stolen-token detection |
| Frontend | React 19 · TypeScript · Vite 6 | Type safety across the API boundary; fast builds |
| Data layer | TanStack Query 5 | Caching, background refetch, no hand-rolled loading state |
| Styling | Tailwind CSS 3 | The original theme, expressed as semantic tokens |
| Maps | Leaflet · OpenStreetMap | No API key, no billing account |
| Tests | pytest (real Postgres) · Vitest + Testing Library | Tests exercise the actual dialect and the real DOM |
The visual theme is unchanged from the original app — the same deep-green
navigation, teal accents, Outfit typeface and category colours. They are now
declared once as tokens (brand, forest, money, place, transit, hurdle)
in frontend/tailwind.config.js instead of being repeated per component.
Docker is the only prerequisite; Node and Python are not needed on the host.
docker compose up --build| Service | URL |
|---|---|
| App | http://localhost:5173 |
| API | http://localhost:8000 |
| API docs (Swagger) | http://localhost:8000/docs |
| Database | localhost:5432 — travel / travel |
Migrations run automatically on backend start, so the first boot leaves you with a usable schema.
docker compose run --rm --no-deps \
-v "$PWD/scripts:/app/scripts:ro" backend python /app/scripts/seed_demo_data.pyThen sign in with demo@traveljournal.app / DemoTraveller2026.
docker compose down # stop, keep data
docker compose down -v # stop and delete the database volumeAll application code lives under a src/ directory. Tests, migrations, config and
scripts sit outside it.
.
├── backend/
│ ├── src/app/ ← all backend source
│ │ ├── main.py app factory, middleware, health checks
│ │ ├── core/ settings, security, logging, errors, rate limits
│ │ ├── db/ engine, session lifecycle, declarative base
│ │ ├── models/ SQLAlchemy tables
│ │ ├── schemas/ Pydantic request/response contracts
│ │ ├── services/ business logic (routers stay thin)
│ │ └── api/v1/ HTTP routes
│ ├── tests/ ← outside src
│ ├── migrations/ ← Alembic, outside src
│ ├── pyproject.toml
│ └── Dockerfile
│
├── frontend/
│ ├── src/ ← all frontend source
│ │ ├── main.tsx, App.tsx entry and routing
│ │ ├── pages/ one file per screen
│ │ ├── components/
│ │ │ ├── ui/ design-system primitives
│ │ │ ├── layout/ shell, navbar, route guards
│ │ │ ├── trips/ trip card, map, form editors
│ │ │ └── charts/ chart primitives
│ │ ├── hooks/ TanStack Query hooks
│ │ ├── context/ auth/session state
│ │ ├── lib/ API client, formatting, validation, tokens
│ │ └── types/ API type mirror
│ ├── tests/ ← outside src
│ ├── Dockerfile, nginx.conf
│ └── tailwind.config.js
│
├── scripts/ ← seed + one-shot MongoDB migration
├── docker-compose.yml ← development
├── docker-compose.prod.yml ← production
└── .env.example
The auth design assumes the frontend can be compromised by XSS and aims to limit the damage.
Passwords are hashed with Argon2id (64 MB memory, 3 passes, unique salt). Registration enforces a minimum of 10 characters with upper, lower and numeric characters, and rejects the common credential-stuffing list. Hashes are transparently upgraded on login if the cost parameters change.
Access tokens are short-lived (15 min) JWTs held in memory only. Nothing
authentication-related is written to localStorage, because anything JavaScript
can read, an injected script can read too.
Refresh tokens are opaque 288-bit random strings delivered as httpOnly,
SameSite cookies scoped to the auth routes. Only a SHA-256 hash is stored, so a
database dump cannot be replayed against the API.
Rotation with reuse detection. Every refresh invalidates the token it consumed and issues a successor in the same family. If a token that was already spent is presented again, that means it leaked — so the entire family is revoked and the session dies. Concurrent requests on the client share a single in-flight refresh, so a dashboard firing five requests at once cannot trip this by accident.
Ownership isolation. Every query is scoped by user_id. Another account's trip
returns 404, not 403 — a 403 would confirm the record exists.
Also in place: per-account/per-IP rate limits on login, registration and uploads;
generic login errors plus a dummy hash verification so the endpoint cannot be used
to enumerate accounts; a 403 on inactive accounts; all sessions revoked on
password change; uploads validated by decoding the image bytes rather than
trusting the extension or Content-Type, stored under random names in per-user
directories, with path-traversal rejected on delete; security headers and HSTS;
strict CORS; and a settings validator that refuses to boot production with a
development secret, an insecure cookie flag, or a wildcard origin.
Backend — 76 tests against a real PostgreSQL database, because the schema uses
ARRAY columns and to_char; a SQLite suite would pass while production broke.
Each test runs in a transaction that is rolled back.
docker compose exec -T db psql -U travel -d postgres -c "CREATE DATABASE traveljournal_test;"
docker compose run --rm --no-deps \
-e TEST_DATABASE_URL="postgresql+asyncpg://travel:travel@db:5432/traveljournal_test" \
-e ENVIRONMENT=test \
-v "$PWD/backend/tests:/app/tests:ro" backend python -m pytest -qCoverage includes password hashing, the full rotation/reuse-detection flow, account
enumeration resistance, rate limiting, per-user isolation on every endpoint, upload
validation (including a PHP payload renamed to .png), derived-field arithmetic,
and a regression test for an analytics query that once double-counted every total.
Frontend
docker compose run --rm --no-deps frontend npx vitest run
docker compose run --rm --no-deps frontend npx tsc --noEmitcp .env.example .env # then fill in POSTGRES_PASSWORD, SECRET_KEY, and the URLs
docker compose -f docker-compose.prod.yml up -d --buildThe production stack differs in ways that matter:
- Only nginx publishes a port. Postgres and the API stay on the internal network.
- The frontend is a static build served by nginx, which also proxies
/api— so the API is same-origin, there is no CORS preflight, and the refresh cookie is first-party. - The API runs as a non-root user with 4 workers behind
--proxy-headers. - Interactive API docs are disabled.
- The app refuses to start if
SECRET_KEYis a development default,COOKIE_SECUREis false, orCORS_ORIGINScontains a wildcard.
Put TLS in front of nginx (a reverse proxy or a load balancer). COOKIE_SECURE=true
means the refresh cookie will not be sent over plain HTTP, so sessions will not
persist without it.
Rate-limit counters are in-process. Running more than one API container makes each
one enforce its own budget — point storage_uri in
backend/src/app/core/rate_limit.py at Redis before scaling out.
The previous version had no accounts, so all documents are assigned to one user you
nominate. Existing photo files keep working: they are still served from /uploads,
and only the URL host is rewritten.
# Preview without writing anything
docker compose run --rm --no-deps \
-e MONGO_URL=mongodb://host.docker.internal:27017 \
-e OWNER_EMAIL=you@example.com -e OWNER_PASSWORD='YourStrongPass1' \
-v "$PWD/scripts:/app/scripts:ro" \
backend sh -c "pip install -q motor==3.6.0 && python /app/scripts/migrate_from_mongo.py --dry-run"Drop --dry-run to commit. It is safe to re-run — a trip is skipped if the owner
already has one with the same title and start date. The script also repairs legacy
data: free-text dates are parsed across several formats, 0.0 coordinates (which
meant "unset") become NULL, expense categories are inferred from item names,
comma-joined lists are split and de-duplicated, and every trip total is recomputed
from its line items rather than trusted.
Swagger UI is at /docs in development. Every endpoint below except
register/login/refresh requires Authorization: Bearer <access_token>.
POST /api/v1/auth/register create an account and sign in
POST /api/v1/auth/login sign in
POST /api/v1/auth/refresh rotate the refresh cookie, get a new access token
POST /api/v1/auth/logout end this session
POST /api/v1/auth/logout-all end every session
GET /api/v1/auth/me the signed-in user
POST /api/v1/auth/change-password change password, revoking all sessions
GET /api/v1/users/me profile
PATCH /api/v1/users/me update profile
GET /api/v1/trips list — search, filter, sort, paginate
POST /api/v1/trips create (with expenses, companions, photos)
GET /api/v1/trips/map only trips that have coordinates
GET /api/v1/trips/{id} one trip, in full
PATCH /api/v1/trips/{id} partial update
POST /api/v1/trips/{id}/favourite toggle favourite
DELETE /api/v1/trips/{id} delete (cascades to children)
GET /api/v1/wishlist list
POST /api/v1/wishlist add a destination
PATCH /api/v1/wishlist/{id} update
DELETE /api/v1/wishlist/{id} remove
POST /api/v1/uploads one photo
POST /api/v1/uploads/batch up to 20 photos
GET /api/v1/uploads/limits constraints for the client to pre-check
GET /api/v1/stats statistics page data
GET /api/v1/stats/expenses expense analytics
GET /api/v1/stats/overview timeline summary strip
GET /api/v1/gallery every photo, flattened with trip context
GET /health liveness
GET /health/ready readiness (503 when the database is down)
Errors always use the same envelope, so the client never special-cases an endpoint:
{ "error": { "code": "not_found", "message": "Trip not found", "details": {} } }details carries per-field messages on validation failures, which the forms map
straight onto the offending inputs.
Money is never a float. Amounts are NUMERIC(12,2) in Postgres, Decimal in
Python, and strings in JSON. 0.1 + 0.2 != 0.3 in binary floating point, and a
journal that silently loses paise is worse than one that is slightly more annoying
to parse. toNumber() in frontend/src/lib/format.ts is the single place that
converts.
Analytics are computed in SQL. The old frontend fetched every trip and reduced in JavaScript. That is fine for ten trips and wasteful for a thousand, so the aggregation moved into the database and each page loads from one response.
Charts use one colour, not seven. The category breakdown is a ranked bar list
where each row is labelled with its name and amount — so bar length carries the
magnitude and colour has no job to do. A seven-hue categorical palette was tried
first and failed colour-vision separation (purple and blue were indistinguishable
under deuteranopia); since the rows are directly labelled, it was also redundant.
Every chart ships a screen-reader <table> of the same numbers.
Derived values are stored, not recomputed per read. num_days and
total_expense are written on every mutation. The trip list would otherwise
recompute them for every row on every request.
The photo requirement is gone. The old form refused to save without a photo, which blocked recording a trip you had no pictures of. Only title, location and start date are required now.
Agent-driven journalling: a chat interface where describing a trip in prose creates and edits the entries directly. The groundwork is already here — a typed relational schema with real constraints, a service layer that owns every write, and per-user scoping enforced below the HTTP layer, so an agent's tool calls land on the same validated paths as the UI rather than reaching into the database.