Architecture, conventions and workflows for hacking on TourGaze. For the product overview see README.md.
A monorepo with three top-level pieces:
server/ Spring Boot 3 (Java 25) — REST API + parsing + storage
frontend/ Vue 3 + Vite + TypeScript — the SPA
spec/ openapi.json — generated from the backend, consumed by the frontend
| Package | Responsibility |
|---|---|
controller |
REST endpoints. Return DTOs only — never entities. |
dto |
Wire shapes (records). |
entity |
JPA entities (the domain model). Weather is an @Embeddable. |
enums |
Domain enums (ActivityType) — @Schema(enumAsRef) + @JsonValue. |
parser |
Ride-file parsing — TrackParser registry + TrackFileParser providers (FitParser, GpxParser, KmzParser), ParseResult, TrackPoint, SourceFormat. |
service |
Business logic (import, media, similarity, weather, proposals, …). |
service.mapper |
MapStruct entity→DTO mappers. |
repository |
Spring Data JPA repositories. |
store |
StorageService — paths under ~/.tourgaze/. |
util |
Geo (distance), GeoHash, ShortId. |
views/ (routed pages) · components/ (incl. ActivityMap, MapRenderer,
EliteStats, panels) · composables/ (track data, HR zones, replay cameras) ·
api/client.ts (typed fetch wrappers) · types/schema.ts (generated) ·
enums/generated/ (generated) · lib/geo.ts (distance) · workers/ (off-thread
segment builder).
Prerequisites: JDK 25+, Node 20+.
cd server && mvn spring-boot:run # → http://localhost:8085
cd frontend && npm install && npm run dev # → http://localhost:5173 (proxies /api → 8085)The Vite dev server proxies /api/** only — hit the backend directly on
:8085 for /v3/api-docs, etc.
- H2 file DB at
~/.tourgaze/db. Flyway owns the schema;spring.jpa.hibernate.ddl-auto=validate(Hibernate only checks the live schema matches the entities — it never alters it). src/main/resources/db/migration/V1__baseline.sqlis the full schema, generated from the entities via Hibernate schema export sovalidateaccepts it. Pre-1.0 you may regenerate V1 the same way; post-release add forwardV2__*.sqlmigrations and never edit V1. Add@Indexfor new lookups.- Regenerate V1: run on a throwaway in-mem DB with
--spring.jpa.properties.jakarta.persistence.schema-generation.scripts.action=create--…scripts.create-target=./target/V1__baseline.sql, then copy intodb/migration/. - For a clean wipe: delete
~/.tourgaze/db/and restart (Flyway rebuilds from V1). ⚠️ The dev DB holds real rides — never bulk-delete rows.
The backend is the source of truth for API types. Regenerate after any DTO/enum/
endpoint change — don't hand-edit schema.ts:
# 1. Spec — boots the app on a throwaway in-memory DB (port 8089), pulls
# /v3/api-docs, writes spec/openapi.json, stops. Doesn't touch :8085 or real data.
cd server && mvn -Pspec verify -DskipTests
# (manual fallback: curl -s http://localhost:8085/v3/api-docs -o ../spec/openapi.json)
# 2. Types — schema.ts (openapi-typescript) + enums/generated/* (per OpenAPI enum)
cd frontend && npm run generate # = gen:api + gen:enums- DTOs at the boundary. Controllers expose DTOs, never entities (avoids
lazy-loading leaks / over-posting). Straight entity→DTO projections go through a
MapStruct mapper in
service.mapper(ActivitySummaryMapper,MarkerMapper). Computed/aggregate DTOs stay hand-built in their service (SimilarRideDto,InboxItemDto,RideMetadataDto) — don't force those through MapStruct. - Enums are Java enums with a lowercase
@JsonValuewire value (matches the DB column, so no migration) + a lenient@JsonCreator(unknown →OTHER/null) +@Schema(enumAsRef). They surface in the frontend as generated unions and asenums/generated/*(enum + List + Labels). - New ride formats: implement
TrackFileParserinio.github.tourgaze.parserand register it as a@Component, then add the extension toSourceFormat. No format conditionals anywhere else (provider pattern). - Distance: use
util.Geo.distanceM/distanceKm(GeographicLib) on the backend andsrc/lib/geo.tson the frontend. Don't re-roll haversine. - vue-query keys must be reactive values, not getters:
queryKey: computed(() => ['media', id]). Invalidate with the same value; userefetchType: 'all'when a list query may be inactive. - Match the surrounding code's style; comments explain why, not what.
cd frontend && npx vue-tsc --noEmit && npm run lint
cd server && mvn verify
cd frontend && npx playwright test # e2e — needs a running backend with dataBackend formatting is enforced by Spotless (mvn spotless:apply).
cd server && mvn clean package # fat jar in server/target
cd server && mvn -Pwindows-portable package # jpackage app-image (Windows)The frontend builds with npm run build; the with-frontend/portable profiles
bundle it into the distributable.
The Docker image and the jpackage app-image already build & use a JDK AOT cache
(JEP 483/514) that memory-maps the app's classes + linked state instead of re-loading
them — worth ~1s off cold start (≈3.0s vs ≈4.1s here). To get the same for a plain
java -jar run, generate a cache once per jar build, then launch with it:
cd server
# 1. Train — one boot that exits on context refresh, dumping target/app.aot
java -XX:AOTCacheOutput=target/app.aot -Dspring.context.exit=onRefresh \
-jar target/tourgaze-server-*.jar --app.start-browser=false --app.backup.on-startup=false
# 2. Run with the cache
java -XX:AOTCache=target/app.aot -jar target/tourgaze-server-*.jarRegenerate app.aot whenever the jar or the JDK changes — a stale/mismatched cache is
ignored with a warning (never fatal), so it can't break startup. Requires JDK 24+.
TourGaze ships as a single container (matros convention): the fat JAR already
bundles the built SPA under /static, so one image serves the REST API and
the UI on port 8085. Everything stateful (H2 DB, media, cache, tiles) lives
under TOURGAZE_DATA_DIR (/data in the container) — mount it as a volume.
Build the JAR first, then the image (infra/Dockerfile copies server/target/*.jar):
cd frontend && npm ci && npm run build
mvn -f server/pom.xml clean package -DskipTests
docker compose -f infra/docker-compose.yml up --build # → http://localhost:8085The image is published as mschwehl/tourgaze:latest. infra/docker-compose.yml
wires a named tourgaze-data volume (swap for a bind mount ../data:/data to keep
the DB host-visible). The entrypoint runs headless and passes --app.start-browser=false.
A production chart lives in helm/tourgaze/ (Deployment + Service + Ingress + PVC):
helm install tourgaze ./helm/tourgaze \
--set ingress.hosts[0].host=tourgaze.example.comKey values.yaml knobs: image.repository/tag, persistence.{enabled,size,storageClass}
(the /data PVC — set a real storageClass in production), service.port, and
ingress.{enabled,className,hosts,tls}. The default ingress sets a 100 MB body limit
so FIT/GPX/KMZ + photo uploads aren't rejected. Single replica only — H2 is a local
file DB, so replicaCount must stay 1 (no horizontal scaling without swapping the
datastore).
Spring Boot 3 · Java 25 · H2 · MapStruct · Garmin FIT SDK · jpx · GeographicLib · metadata-extractor · commons-imaging · springdoc-openapi · Caffeine · Vue 3 · Vite · TypeScript · TanStack Query · MapLibre GL · ECharts · Tailwind · openapi-typescript · Playwright.
Per-dependency copyrights & licenses (incl. the Garmin FIT SDK and the runtime map/open-data services — OSM, Overpass, Mapzen terrain, Open-Meteo) are listed in THIRD-PARTY-NOTICES.md.