Architecture Overview¶
Tempo is a self-hosted running tracker built as a full-stack application with a clear separation between frontend and backend.
System Components¶
Frontend¶
- Framework: Next.js 16 with React 19
- Language: TypeScript
- Styling: Tailwind CSS with semantic identity tokens (
canvas,ink,muted,volt, and related). Geist is the UI typeface. Do not use tutorialblue-600as the brand accent. - UI kit: Hand-rolled primitives in
frontend/components/ui/—PageShell(densitycontrol|overview),Card,Button,Dialog,EmptyState,Tabs - Appearance: Dark-first. Tailwind
classstrategy on<html>(dark). Preference issystem|dark|lightinlocalStorage(tempo-appearance); not UserSettings. Seefrontend/lib/appearance.ts. - Icons: Tabler Icons (
@tabler/icons-react) for UI icons. Brand marks live infrontend/public/(tempo-mark-volt.png,tempo-mark-ink.png). - State Management: TanStack Query for server state
- Maps: Leaflet/React-Leaflet. Carto Dark Matter in Dark appearance, Voyager in Light; polyline and Highlight from tokens; OSM + CARTO attribution. Operators configure a CARTO basemaps API key via
CartoBasemaps:ApiKey/CartoBasemaps__ApiKeyon the API; the command center reads it fromGET /settings/carto-basemaps. - Charts: Recharts; series colors from identity tokens. Workout overview charts HR, pace, elevation, cadence, and power via
getWorkoutTimeSeriesinfrontend/lib/api.ts(pages until complete or 20,000 samples). - Highlight: Shared overview focus (
splitIdxand/orelapsedSeconds) infrontend/lib/workoutHighlight.ts— map, splits, and charts follow together.
Backend¶
- Framework: ASP.NET Core 9 Minimal APIs
- Language: C#
- Database: PostgreSQL 16 with JSONB fields for raw workout data
- ORM: Entity Framework Core
- Logging: Serilog with named profiles (
standard|debug) viaTempo:Logging:Profile
Database¶
- PostgreSQL 16 with JSONB support
- Hybrid storage: Core stats in dedicated columns, raw data in JSONB
- Automatic migrations on startup
Key Architectural Patterns¶
1. Minimal APIs¶
Endpoints are organized in static extension methods that extend WebApplication:
MapWorkoutsEndpoints- Workout management endpointsMapSettingsEndpoints- Settings and configuration endpointsMapShoesEndpoints- Shoe management endpointsMapAuthEndpoints- Authentication endpointsMapVersionEndpoints- Version information endpoints
Each extension method:
- Creates a MapGroup for route organization (e.g., /workouts/*, /settings/*, /auth/*)
- Uses WithTags() for Swagger documentation grouping
- Defines endpoints as private static methods with XML documentation comments
- Uses dependency injection from the service container
- Maps private methods to HTTP verbs using MapGet, MapPost, MapPut, MapPatch, MapDelete
- All endpoint methods return Task<IResult> and use Results.* helper methods
2. Service Layer¶
GpxParserService/FitParserService— decode adapters:TrackPoints, raw JSON, optional device summary (and GPX name). They do not exposeCalculateSplitsand do not hand FITRecordMesgto callers. The FIT SDK comes from theGarmin.FIT.SdkNuGet package (Dynastream.Fitnamespace). FIT session JSON may include rawworkoutRpe(Borg CR10 × 10).StravaCsvParserService— parses Strava export CSV metadata for bulk ZIP import.TrackGeometry— in-process:TrackPoints in; elevation gain,WorkoutRoute(empty when no GPS),WorkoutSplits (Haversine or cumulativeDistanceMstream),WorkoutTimeSeriesout. NoDbContext.WorkoutIntake— decode adapters (GPX/FIT file →DecodedWorkout; HealthKit JSON viaHealthKitWorkoutDecoder) feedPersistAsync(geometry, duplicate policy, default shoe, weather, relative effort, incremental best efforts). Persist is the single pipeline; HTTP import is a thin adapter. Bulk calls intake per activity file. FIT sessionworkoutRpefillsWorkout.Rpe(1–10) when null.TrackPointRehydration— stored Workout fields →TrackPoints for crop and split recalc.ImportJobService— create/chunk/complete/current/get/cancel, one-active-job rules, archive staging undermedia/imports/{jobId}/.ImportJobWorker— hosted service; wakes on channel, new DI scope per job; branches onkind(strava_bulk|tempo_export).IntervalsIcuSyncService— one tick: decrypt key, list recent activities (oldest= max(connect−2d, lastSuccess−2d, today−14d)), skip non-runs / junkfile_typeand existingintervals_icuidentities before file fetch, thenWorkoutIntake.ProcessAsyncwith overlay identityintervals_icu. Stats-key attach does not renameWorkout.Source. 401/decrypt disables and keeps the ciphertext; 429/5xx stay enabled.IntervalsIcuSyncWorker— hosted service (not registered in Testing); 15-minute timer (IntervalsIcu__PollIntervalMinutes) plus Sync now channel; new DI scope per tick. Not an ImportJob.StravaBulkImportOrchestrator— Strava ZIP extract/CSV loop callingBulkImportService+ Workout intake; writes job counters.BulkImportService— ZIP safety,activities.csv, non-run skip, per-file intake mapping, Strava media copy.ImportService— Tempo export ZIP restore (ImportExportAsyncwith progress + cancel); not Workout intake.
Most services are registered as Scoped in Program.cs. Configuration objects (MediaStorageConfig, ElevationCalculationConfig), ImportJobQueue, and IntervalsIcuSyncQueue are Singleton. ImportJobWorker and IntervalsIcuSyncWorker are hosted services.
3. Hybrid Data Storage¶
- Core Stats: Stored in dedicated database columns for efficient querying
- Raw Data: Stored as JSONB in PostgreSQL for flexibility
- Allows querying both structured and unstructured data efficiently
4. Media Storage¶
- Workout media on filesystem:
media/{workoutId}/filename.ext - Import-job ZIP staging:
media/imports/{jobId}/archive.zip(deleted on completed, failed, cancelled, or startup interrupt) - Metadata for workout media stored in database for quick access
5. Automatic Migrations¶
Database migrations run automatically on API startup. The DatabaseMigrationHelper implements idempotent migrations that:
- Create the __EFMigrationsHistory table if missing
- Detect existing tables and columns by querying information_schema
- Mark migrations as applied if their corresponding tables/columns already exist
- Use hardcoded mappings to associate database objects with migration IDs
This ensures migrations can be safely applied even when database state doesn't match migration history.
6. Logging¶
- Serilog console output; levels owned by named profile
Tempo:Logging:Profile(standard|debug), not MELLogging:LogLevel - Startup logs
Logging profile: …once so pasted dumps declare the mode standard(default): Tempo Information; Microsoft / System Warning (hosting lifetime Information); request middleware logs 5xx / unhandled exceptions onlydebug: Information including EF SQL and non-probe request traces (not Serilog level Debug)- Successful
/healthand/readyrequest lines omitted on both profiles - Official Compose Postgres uses
log_checkpoints=off(not driven by the API profile)
Data Model¶
Core Entities¶
- Workout: Core entity with stats (distance, pace, elevation, heart rate, etc.) and JSONB fields for raw GPX/FIT/Strava data
- TrackPoint: In-memory sample on a path (not a table). Geometry and parsers use it; see
CONTEXT.md. - WorkoutRoute: One-to-one relationship storing GeoJSON LineString coordinates
- WorkoutSplit: One-to-many segment rows with
Kind(distance|device_lap); Idx unique per kind - WorkoutTimeSeries: One-to-many relationship for time-series data (heart rate, pace, elevation over time)
- WorkoutMedia: One-to-many relationship for photos/videos attached to workouts
- Shoe: Running shoe entity for tracking shoe mileage and assignments
- User: User accounts for authentication
- UserSettings: Single-row table for user preferences (heart rate zones, unit preferences, default shoe). Command-center appearance is not UserSettings.
- ImportJob: Background import (
strava_bulk|tempo_export) with status, byte/progress counters, ErrorDetailsJson (Strava), ResultJson (Tempo), and archive path. At most one row inreceiving|queued|running. - IntervalsIcuConnection: Optional 0-or-1 row for a BYO intervals.icu API key (encrypted). Not UserSettings.
- Intervals.icu sync:
IntervalsIcuSyncWorker(15-minute timer + Sync now channel) decrypts the key, lists recent activities, fetches FIT/GPX, and persists throughWorkoutIntakewith overlay identityintervals_icu. Listoldestis max(connect−2d, lastSuccess−2d, today−14d). 401/decrypt disables (key stays); Sync now coalesces while a tick is in flight or pending. Non-runs, junkfile_type, and existing identities skip before file fetch; stats-key attach does not renameWorkout.Source. Not an ImportJob.
Data Flow¶
Workout Import Flow¶
- File uploaded to
POST /workouts/import, or HealthKit JSON posted toPOST /workouts/import/healthkit - HTTP maps
IFormFiletoWorkoutIntake(stream + filename), orHealthKitWorkoutDecodermaps JSON →DecodedWorkout+ overlay - File decode adapter: GPX or FIT →
DecodedWorkout(TrackPoints, raw JSON, optional device summary); HealthKit skips file parse PersistAsync→TrackGeometry.Derivebuilds elevation, route (omitted when there are no GPS coordinates), splits (Haversine or DistM stream), and time series (device summary wins for distance/duration when present)- Duplicate policy: HealthKit UUID first when present (
skippedbefore geometry/enrichment); then Workout external identity when present (skippedbefore geometry; on a stats-key match, attach a row only if this Workout has no row for that source; if the pair is already stored on another Workout, skip the owner — identity wins). Then the start/distance/elapsed stats key (StartedAt,DistanceM,DurationS). Incomplete raw JSON/bytes canupdated; complete duplicatesskipped(HealthKit vs complete GPX/FIT is alwaysskipped, but may stampHealthKitUuidonto the existing row for client badging). Intake never overwrites a stored externalExternalIdand does not renameWorkout.Sourceon attach. - Weather, default shoe, relative effort, incremental best efforts
- Workout persisted with JSONB raw data and
WorkoutRoute
Bulk Import Flow¶
- Command center creates a receiving job and PUTs 512 KiB ZIP chunks (or Bruno/curl POSTs the whole ZIP to
/workouts/import/bulk) - Complete (or adapter accept) queues the job;
ImportJobWorkerrunsStravaBulkImportOrchestrator - Orchestrator uses
BulkImportServicefor ZIP safety,activities.csv, non-run skip, per-file Workout intake, and Strava media - Intake outcomes map to job counters (
created/updated/skipped/ error). Poll GET untilcompletedorfailed
Tempo Export Restore Flow¶
- Command center creates a receiving job with
kind: tempo_exportand PUTs 512 KiB ZIP chunks (or Bruno/curl POSTs the whole ZIP to/workouts/import/export) - Complete (or adapter accept) queues the job;
ImportJobWorkerrunsImportService.ImportExportAsync - Restore walks settings, shoes, workouts, routes, splits, time series, media, best efforts, and raw files with progress/cancel between items
- Nested
statistics/warnings/errorMessagesland on the job document. Same global one-active-job mutex as Strava bulk
Authentication¶
- JWT-based authentication with httpOnly cookies
- Registration only available when no users exist (single-user deployment)
- Password hashing using BCrypt
- All workout and settings endpoints require authentication (except
/health,/ready, and/version) - First-run onboarding:
User.OnboardingCompleted(new registrationsfalse; migration backfills existing userstrue).GET /auth/meexposesonboardingCompleted;POST /auth/onboarding/completesets ittrueonly (idempotent). The command center hard-gates app routes to/onboardinguntil complete.
Database Indexing¶
The TempoDbContext configures several important indexes:
- Workout indexes: StartedAt, composite index on (StartedAt, DistanceM, DurationS) for duplicate detection
- JSONB GIN indexes: On RawGpxData, RawFitData, RawStravaData, RawHealthKitData, and Weather fields
- HealthKit UUID: Unique index on HealthKitUuid for import idempotency
- Workout external identity: Unique (Source, ExternalId) and unique (WorkoutId, Source)
- WorkoutSplit: Unique composite index on (WorkoutId, Kind, Idx)
- WorkoutTimeSeries: Composite index on (WorkoutId, ElapsedSeconds)
- User: Unique index on Username
Key File Locations¶
- API Endpoints:
api/Endpoints/*.cs— auth/onboarding inapi/Endpoints/AuthEndpoints.cs - Models:
api/Models/*.cs(User.OnboardingCompleted) - Workout intake / geometry:
api/Services/WorkoutIntake.cs,api/Services/HealthKitWorkoutDecoder.cs,api/Services/TrackGeometry.cs,api/Services/TrackPointRehydration.cs - Import jobs:
api/Services/ImportJobService.cs,api/Services/ImportJobWorker.cs,api/Services/StravaBulkImportOrchestrator.cs,api/Services/BulkImportService.cs,api/Services/ImportService.cs - Services:
api/Services/*.cs - Database Context:
api/Data/TempoDbContext.cs - Frontend API Client:
frontend/lib/api.ts(includesgetWorkoutTimeSeries,completeOnboarding) - Auth gate:
frontend/components/AuthGuard.tsx,frontend/contexts/AuthContext.tsx - Onboarding:
frontend/app/onboarding/page.tsx,frontend/components/onboarding/ - Appearance:
frontend/lib/appearance.ts - Highlight:
frontend/lib/workoutHighlight.ts - Frontend Pages:
frontend/app/*/page.tsx— Workout overview composer isfrontend/app/dashboard/[id]/page.tsx - Frontend Components:
frontend/components/*.tsx - UI kit:
frontend/components/ui/