# Architecture This is an overview of how GarageStack's services fit together, for contributors who need the map before their first PR. For end-user setup, see [README.md](../README.md); for local dev without a real car, see [DEMO.md](DEMO.md). ## Services ```text ┌──────────────────────┐ │ SAIC / MG cloud │ └──────────┬────────────┘ │ polls ┌──────────▼────────────┐ │ saic-mqtt-gateway │ (third-party image, pinned version) └──────────┬────────────┘ │ publishes telemetry / subscribes to commands ┌──────────▼────────────┐ │ Mosquitto │ MQTT broker └────┬─────────────┬─────┘ │ │ subscribes │ │ publishes commands ┌────▼───┐ ┌───▼────┐ │ Worker │ │ Api │◄──── frontend (Vue SPA, via nginx) └────┬───┘ └───┬────┘ │ writes │ reads/writes, │ │ LISTENs for pg_notify ┌────▼─────────────▼────┐ │ PostgreSQL │ └─────────────────────────┘ ``` - **saic-mqtt-gateway** (`saicismartapi/saic-python-mqtt-gateway`, pinned in `docker-compose.yml`) polls the SAIC/MG cloud API on GarageStack's behalf and publishes telemetry to MQTT, and relays commands published back to MQTT to the cloud API. This is the only piece of the stack that isn't part of this repo. - **Mosquitto** is the MQTT broker all telemetry and commands flow through. Requires auth; not exposed to the LAN by default. Its password and ACL files are generated on every start by [docker/mosquitto-auth.sh](../docker/mosquitto-auth.sh), shared by Compose and the all-in-one image. An optional, restricted second login lets Home Assistant consume the gateway's MQTT discovery directly (see [HOME_ASSISTANT.md](HOME_ASSISTANT.md)). - **Worker** (`GarageStack.Worker`) subscribes to MQTT and writes telemetry to Postgres. Runs on its own, no inbound HTTP. - **Api** (`GarageStack.Api`) serves the REST API and SignalR hub the frontend talks to, and separately publishes outbound MQTT messages for remote commands (lock, climate, etc.). - **Postgres** is the only datastore. The Api also uses it as a pub/sub channel (`pg_notify`/`LISTEN`) to learn about writes the Worker makes, so it can push live updates without polling the DB. - **frontend** is a Vue 3 SPA built and served by nginx; the only service with a public port by default. ## Data flow: a telemetry update reaching the browser 1. `saic-mqtt-gateway` polls the SAIC cloud and publishes one MQTT message per changed field to a topic like `saic/{user}/vehicles/{vin}/drivetrain/soc`. 2. `MqttConsumerService` ([src/GarageStack.Worker/Mqtt/MqttConsumerService.cs](../src/GarageStack.Worker/Mqtt/MqttConsumerService.cs)) is subscribed to `saic/#`. It keeps the connection alive and offers every message to a chain of handlers, the first one to claim it winning: Home Assistant discovery, the car's capability configuration, MG app messages, command results, and telemetry last. `TelemetryHandler` ([src/GarageStack.Worker/Mqtt/TelemetryHandler.cs](../src/GarageStack.Worker/Mqtt/TelemetryHandler.cs)) maps the payload onto a `TelemetrySnapshot` field via `TelemetryMapper` and writes it to Postgres. A single poll cycle produces several MQTT messages in quick succession, so messages arriving within a 15s window are merged into one row instead of one row per field (see the comments on `MergeOrAddTelemetryAsync` in that file for why this needs a per-vehicle lock). The handlers share a `VehicleResolver`, which caches the vehicle id per VIN. 3. Each write calls `pg_notify('telemetry_updated', vehicleId)`. 4. `TelemetryNotificationService` ([src/GarageStack.Api/Services/TelemetryNotificationService.cs](../src/GarageStack.Api/Services/TelemetryNotificationService.cs)), a background service in the Api process, holds a `LISTEN telemetry_updated` connection. On notification it debounces briefly (to coalesce a poll cycle's several writes into one update), re-reads the merged latest snapshot, and broadcasts it over SignalR to browsers subscribed to that vehicle's group. 5. The frontend's `useSignalR` composable ([frontend/src/composables/useSignalR.ts](../frontend/src/composables/useSignalR.ts)) receives the `telemetryUpdated` event and updates the UI. There is no polling fallback - if the SignalR connection drops, the dashboard goes stale until it reconnects. The same `pg_notify`/`LISTEN`/SignalR pattern also carries `notification_created` (push/in-app alerts), `trip_completed` and `command_result` (see below) events. The channel names live in `PgChannels` and every publisher goes through the `NotifyAsync` extension in [src/GarageStack.Data/Extensions/PgNotifyExtensions.cs](../src/GarageStack.Data/Extensions/PgNotifyExtensions.cs), which is a no-op on the in-memory provider used by demo mode and the tests. The `notification_created` payload is the `NotificationCreatedPayload` record in Core, serialized by the Worker and deserialized by the Api, so the two processes cannot disagree about its shape. Push notification texts are written by the Worker, which has no browser request to take a language from; they come from `Resources/NotificationStrings*.resx` in the Worker project and the language is the `NOTIFICATION_LANGUAGE` deployment setting. The marker types for these resources (`NotificationStrings`, and `WidgetStrings` in the Api) must stay in their assembly's root namespace: the resource localizer maps a marker in a sub-namespace to a resource path that does not exist and silently falls back to the keys. Messages from the official MG app are the one notification the Worker does not write itself: `VehicleMessageHandler` ([src/GarageStack.Worker/Mqtt/VehicleMessageHandler.cs](../src/GarageStack.Worker/Mqtt/VehicleMessageHandler.cs)) passes on SAIC's own text from the gateway's `events/vehicleMessage` event. The gateway publishes its latest message again every time it starts, so the handler remembers the newest message id per vehicle (`Vehicle.LastMessageId`) and pushes each message once; its summary explains how it pairs the event with that id. `TelemetryHandler` is also the one place that watches the engine. A start sends the engine-start notification at once (with a one-hour cooldown). A stop ends the trip: it raises `trip_completed` and writes `Vehicle.LastParkedAt`, which the five-minute notification check reads to give a driver ten minutes to unload before it warns about an open door or window. ## Sending a command to the car (the reverse path) The frontend calls `POST /api/vehicles/{vin}/commands/{command}` on the Api, which publishes an MQTT message via `MqttPublisher` ([src/GarageStack.Api/Services/MqttPublisher.cs](../src/GarageStack.Api/Services/MqttPublisher.cs)) - a separate MQTT client from the Worker's, since the Api only ever publishes and the Worker only ever consumes. `saic-mqtt-gateway` picks the message up and relays it to the SAIC cloud API. `VehicleCommandGate` ([src/GarageStack.Api/VehicleCommandGate.cs](../src/GarageStack.Api/VehicleCommandGate.cs)) serializes commands per VIN server-side, since the real gateway only processes one command at a time. The command names and their gateway topics live in `VehicleCommands` ([src/GarageStack.Core/Helpers/VehicleCommands.cs](../src/GarageStack.Core/Helpers/VehicleCommands.cs)), one table for both directions, and `VehicleCommandSender` is the one place a command is checked and published. The gateway answers every command on the same topic with `/set` swapped for `/result`: `Success`, or `Failed: ` once the SAIC API gives up (it polls for up to 30 seconds). The Worker parses that (`GatewayCommandResult`) and raises `command_result`. The Api releases the command gate for that VIN and pushes a `commandResult` event over SignalR, named by the command the browser sent. `useVehicleCommand` then settles the matching pending command: a refusal clears it and shows the reason. A success clears a command that no telemetry reports (the horn, the charge current limit). A command that telemetry does report stays pending until the telemetry shows it, but a batch can already move on to its next command. Without an answer, the 45-second hold on both sides still applies, which is also what happens in demo mode. Two details matter here. The gateway publishes results retained, so the broker replays the last one per command on every subscribe; the Worker drops those replays, or a restart would show an old failure as new. And an answer names a command but not a request, so a browser only takes an answer for a command it is still waiting on. ### Climate schedules The gateway has charging and battery-heating schedules of its own, but none for climate, so GarageStack runs climate schedules itself. The Api keeps them (`ClimateScheduleEndpoints`) and works out each one's next run on every save (`ClimateScheduleCalendar`): a schedule is a time on the clock in the IANA time zone the browser had when it was made, so 07:30 stays 07:30 across daylight saving time. A time skipped when the clocks go forward runs just after the jump, and a time that happens twice when they go back runs the first time only. The Worker runs them. `ClimateScheduleService` looks every 15 seconds for schedules that are due and claims each one before it runs (a repeating schedule moves on to its next run, a one-off switches itself off), so a restart in the middle never runs a schedule twice. A run found more than 10 minutes late is recorded as missed instead: a car warmed long after its driver left is no use. `ClimateScheduleRunner` skips a run while the engine runs, or when the schedule only wants cold or hot days and an outside temperature from the last three hours says otherwise. Otherwise it sends the commands one at a time (temperature first, since the gateway only stores it while climate is off, then the climate mode, then the extras), sends a command again after 30 seconds, up to three times, when the car refused it only because it was still busy with another (SAIC return code 8, as a car woken from a long sleep can answer), stops at the first one refused or unanswered otherwise, records the outcome on the schedule, and sends one push in the `climate-schedule` category. Only the Api publishes commands, through the same `VehicleCommandSender` and gate as the browser. So the Worker asks for each command on the `vehicle_command_requested` channel, the one pg channel that is a request rather than news, and waits for the gateway's answer itself (`CommandAnswerWaiter`, completed by `CommandResultHandler`). The Api handles each request apart from its LISTEN loop, because a request may wait up to 90 seconds for the gate, and the `command_result` that frees the gate must not queue up behind it. A request the Api never sees (it is down, or reconnecting to Postgres) ends as no answer after 150 seconds rather than as a late start. Because answers carry no request id, a command sent by hand at the same moment as a scheduled run can count as that run's answer; the command still reaches the car, only the recorded outcome can be off. The Api (edits) and the Worker (claims and outcomes) both write a schedule, so `ClimateSchedule.Version` is a concurrency token: an edit is applied again on top of a claim it raced with, and a claim on a schedule edited since it was read is left for the next look. ## Trips The gateway reports positions, not trips. `TripSegmenter` in Core cuts GPS fixes into trips. A trip ends when the car stands still for five minutes or no fix arrives for half an hour. A trip containing a jump faster than 250 km/h is thrown out as a positioning glitch, and one under 0.1 km never went anywhere. The Worker and the Api both use this one class, so they cannot disagree about where a trip starts and ends. - **Saving.** `TripRecorderService` in the Worker runs `TripRecorder` every five minutes. It reads a vehicle's fixes from `Vehicle.TripsRecordedUntil` onwards, a week at a time, and saves every trip that no later fix can change, because the car has been parked for five minutes or nothing has arrived for half an hour. Then it moves that line up: to the first fix of the trip still being driven, or else to the end of what it read. The trips and the line are saved in one transaction. An install that has never saved a trip has no line yet, so the first run starts at the vehicle's first fix and saves its whole history. - **Storage.** A trip is saved together with its fixes (`Trip.PointsJson`), so showing it does not mean reading and re-cutting raw telemetry. A unique index on `(VehicleId, StartedAt)` turns any slip in the line into an error rather than a duplicate. - **Serving.** The Api's trips endpoint goes through `TripRepository`. It serves the saved trips from before the line, and cuts everything after the line live; the trip being driven always comes from that live part. The line is read first, and only saved trips from before it are served: otherwise a save landing between the two reads would show its trips twice. - **Summaries.** Every trip carries the figures read off its fixes (where it ended, top speed, average speed while moving and how many readings that average covers), so a caller that only needs those asks for `?points=false` and gets no fixes at all. The statistics page works from these. `GET .../trips/latest` answers with the newest trip alone, the one being driven if there is one, and is what the dashboard asks for. The frontend sees one list and can tell the two kinds apart only by `id`, which is null for a trip that has not been saved yet. ### Trip log The trip log (`/trip-log`, `TripEndpoints`) shows only saved trips, because only a saved trip has somewhere to keep what the driver records about it. - **Odometer.** The recorder stores a reading for each end of a trip as it saves the trip. The start reading is the last one at or before the first fix. The end reading is the last one up to five minutes after the last moving fix. The car reports its final mileage a poll or two after it stops, and the next trip cannot start within those five minutes. The odometer only counts up, so the latest reading is the reading at that moment (`ITelemetryRepository.GetOdometerAtAsync`). - **Addresses.** `TripPlaceService` looks up both ends through `GeocodeService`, so it shares that cache and its budget of three upstream lookups per request. It then stores the answers on the trip (`StartPlaceJson`, `EndPlaceJson`). The geocode cache forgets a place after 90 days, which is too short for a log kept for a tax year. A "nothing mapped here" answer is shown but not stored, so the trip gets its address once OpenStreetMap has one. The browser asks in rounds until every end is known, the same way the map's trip list does, and stops when place names are switched off. - **Purpose and notes** are the only fields a driver changes. The purpose is stored by name and travels as `business`, `commute` or `private`. `TripPurposeJsonConverter` refuses numbers, which the stock enum converter would accept. - **Export** is built in the browser from the list on screen (`utils/tripLogCsv.ts`), so it matches what the driver checked. Fields are separated by commas in English and by semicolons in Dutch, where the comma is the decimal separator. The file starts with a UTF-8 byte order mark for Excel. Text that a spreadsheet would run as a formula gets a leading apostrophe. The `AddTripLog` migration clears the trips saved before it and resets the recording line, and the Worker saves them again with their new columns. Nothing is lost: until that migration, nothing a driver could enter was kept on a trip. ## Data kept over time Telemetry is the one table that grows with every poll. The Worker's `HousekeepingService` runs every six hours and keeps it in check. - **Compaction.** Telemetry older than `TELEMETRY_FULL_DETAIL_DAYS` (a year by default) is folded into one row per quarter of an hour by `TelemetryCompaction` in Core. It is the same last-value-wins merge that folds a poll's messages into one row, over a longer window. Each window keeps its last row, filled in with the newest earlier value of every field that row lacks. "The latest reading at or before a moment" (the odometer, the last position, a schedule the car reported once) therefore answers the same at the end of every window, and never with a reading taken later. A window is cut in two where a counter that starts again from zero drops (the day's distance and energy, the counters since the last charge, the journey distance), so the total of each day and each charge survives. A quarter of an hour divides every UTC offset, so a window never spans the car's local midnight. - **The line.** `TelemetryCompactor` works through a whole UTC day at a time from `Vehicle.TelemetryCompactedUntil`, and saves each day together with the line in one transaction, so a run can stop anywhere and the next one carries on. It never passes `TripsRecordedUntil`: the trip recorder still needs every fix it has not cut into trips. A vehicle that has never recorded a trip is not compacted at all. Saved trips keep their own fixes (`Trip.PointsJson`), so compaction changes nothing about them. It does mean old trips can no longer be cut again from telemetry, one more reason never to clear saved trips in a migration. - **What it costs.** Nothing in the app reads finer detail than this past 90 days. The map asks for 90 days at most. The statistics page reads any period, up to ten years, but folds every reading into its day (`DailyStatsBuilder`), and a day's averages and counter totals come out the same from quarter-hour rows. That is why a lower setting is raised to 90. What is lost is the order of events within a quarter of an hour, such as a short charge that started and stopped inside one. - **Caches and deleted notifications.** The same job removes place names and snapped trips a day after they expire: nothing reads an expired one, and a lookup may be refreshing it at that moment. It removes notifications the user deleted once they are 30 days old; the notification cooldowns read that history, deleted rows included, at most a week back. Map POI tiles are left alone, because the map shows an expired tile's stations while it fetches the tile again. PostgreSQL reuses the space of removed rows for new ones rather than handing it back to the disk. To shrink the database files after the first compaction of a long history, run `VACUUM FULL "TelemetrySnapshots";` once. It locks the table for as long as it runs, so pick a quiet moment. ## Projects under `src/` | Project | Contains | Depends on | | --- | --- | --- | | `GarageStack.Core` | Domain models (`Models/`), repository/service interfaces (`Interfaces/`), and pure helpers with no I/O (`Helpers/`) - the shared vocabulary every other project builds on. | nothing (leaf project) | | `GarageStack.Data` | EF Core: `AppDbContext`, migrations, concrete repository implementations, and `Demo/` (in-memory fakes used when `DEMO_MODE=true`). | `Core` | | `GarageStack.Worker` | The MQTT-ingestion process: `Mqtt/MqttConsumerService` and the message handlers beside it, plus the periodic jobs (maintenance reminders, POI pre-caching, push-notification checks, saving finished trips, housekeeping), each a `PeriodicBackgroundService`. | `Core`, `Data` | | `GarageStack.Api` | ASP.NET Core minimal APIs (`Endpoints/`), the SignalR hub (`Hubs/`), and Api-only services (outbound MQTT publishing, POI/charging-station lookups, the Postgres-LISTEN-to-SignalR bridge). | `Core`, `Data` | | `GarageStack.Tests` | xUnit tests across all of the above. | all four | ## Configuration Deployments configure GarageStack through environment variables named for people (`MQTT_HOST`, `OIDC_AUTHORITY`), not in .NET's `Section__Key` form. `EnvironmentAliases` ([src/GarageStack.Core/Configuration/EnvironmentAliases.cs](../src/GarageStack.Core/Configuration/EnvironmentAliases.cs)) is the one table that maps each name onto its setting; both hosts add it as their last configuration source. An empty variable counts as unset, and a setting given by its own key wins over the name, which is how the all-in-one entrypoint passes the values it derives. The compose files therefore pass the variables straight through, and the defaults live in the code. `EnvironmentAliasesTests` fails when `.env.example`, the Unraid template or a compose file mentions a variable that is neither in the table nor consumed by the deployment itself. The upstream clients and the push sender take typed options (`GeocodingOptions`, `MapMatchingOptions`, `OverpassOptions`, `OpenChargeMapOptions`, `VapidOptions` in `Core/Configuration`), each built once by a `From(IConfiguration)` that reads blank values as defaults. The operator-facing list of every variable is [CONFIGURATION.md](CONFIGURATION.md). ## Errors Every error the API answers with is a ProblemDetails body. Refusals the browser can explain carry a `code` beside the English `detail` (`maintenance.nameRequired`, `command.invalidValue`, `csrf.originNotAllowed`), built through `ApiProblems` in the Api. The frontend's `ApiError` keeps that code, and `useErrorMessage` shows the translation under `errors.codes.` when there is one, else a message for the status, so a validation message reaches the user in their own language. A new refusal needs its code in both `en.json` and `nl.json`. ## Caching There's no Redis (or other external cache) in the stack, by design rather than oversight: this is a single-instance, single-user deployment, so a distributed cache buys nothing a process-local one doesn't already provide. The caches, each in memory or in Postgres: - `ITelemetryRepository.GetMergedLatestAsync` (the hot path behind `/status`, the homepage widget, and every SignalR broadcast) uses a short-TTL `IMemoryCache` entry, invalidated immediately on every telemetry write so it can never serve data older than the write that triggered a SignalR broadcast. - Map POI tiles (charging stations, fuel stations, service areas, speed cameras) are cached in Postgres itself (`PoiCacheTile`/`PoiItem`), not in memory, since that data needs to survive process restarts and be queried by bounding box - a job a plain in-memory cache isn't suited for anyway. The brand filter list is a DISTINCT over the `Brand` column, extracted from the upstream metadata at ingest, with a short in-memory cache on top that every tile upsert invalidates. - Reverse-geocoded place names (`GeocodeCacheEntry`) are cached in Postgres for the same reason, keyed by a per-precision coordinate grid plus language. Nominatim's usage policy requires caching rather than re-requesting, and a 90-day TTL is honest for data that changes when a street is renamed. Only the cache key is quantised to the grid; the coordinate sent upstream is the caller's own, so an answer describes the real spot rather than a grid corner. - Snapped trip lines (`MapMatchCacheEntry`) are cached in Postgres too, keyed by a hash of the fixes that were sent rather than by vehicle or timestamp: a finished trip is the same trace however often it is selected, and two browsers looking at it share one row. The line is stored as an encoded polyline, which keeps a few thousand vertices to a few kilobytes both in the table and on the wire. The speed limits along it travel beside it as run-length pairs (`SpeedLimitRuns`) for the same reason: a limit holds for a whole stretch of road, so a trip is a few dozen runs rather than a value per vertex. `MapMatchDefaults.CacheVersion` is part of the trace hash, so asking the matcher for something new (as the limits did) leaves the older rows unreachable instead of serving answers that no longer hold everything the client expects. - The status query's fallbacks are index-backed. Charging and heating schedules are only published when the user changes one, so most vehicles have no such row at all: a filtered index (`IX_TelemetrySnapshots_VehicleId_RecordedAt_Schedule`) keeps that lookup from reading the entire history on every cache miss, which is after every telemetry write. - Session revocation (`TokenRevocation`) is checked by the cookie handler on every authenticated request. The answer is cached in memory: a revocation made by this process is written to the cache immediately, a "not revoked" answer is trusted for five minutes, so the check costs no database round trip per request. ### Map POI tiles All four POI types share the same tile-based PostgreSQL cache: - The map is divided into a 0.5 deg x 0.5 deg grid (roughly 55 x 40 km at European latitudes). - Each tile is fetched once and stored for 7 days; subsequent requests for the same area are served from the database with no external API call. - The background Worker pre-populates tiles around the car two minutes after it starts and every 6 hours after that: the OpenStreetMap layers the car has a use for (fuel, service areas, and speed cameras unless the deployment switched them off), and, for a plug-in car, charging stations when an Open Charge Map key is set. - The `MaxOnDemandTiles` cap (1 per API request) prevents Overpass rate-limiting when many uncached tiles are requested at once; the frontend chains requests automatically with back-off when more tiles remain. If GarageStack ever needs to run more than one Api/Worker instance, revisit this: per-vehicle in-memory state (this cache, the revocation cache, `VehicleCommandGate`, `NotificationCooldownGate`, the shared `UpstreamRateGate` behind the Overpass/OCM/Nominatim clients) would all need to move to something shared. ## Authentication `src/GarageStack.Api/Authentication/` wires both sign-in methods into a single session scheme, so nothing downstream has to care which one was used. - **Sessions** are ASP.NET Core cookie-auth tickets (`garagestack-auth`), encrypted with the Data Protection keys on disk. `AuthenticationSetup` answers unauthenticated API calls with 401 instead of the framework's default redirect to a login page, checks every request's session id against the `RevokedTokens` table, and keeps the principal slim: display name, subject, session id. - **OIDC** uses the stock `AddOpenIdConnect` handler (authorization code + PKCE, `response_mode=query` so the callback carries `SameSite=Lax` cookies on plain HTTP). `OidcOptions` binds the `OIDC_*` environment variables and fails fast on a half-finished configuration; `OidcAccessPolicy` decides whether an authenticated user is also authorised, and is the piece worth reading first. - **Password login** is the fallback. `PasswordLoginOptions.Resolve` switches it off as soon as OIDC is configured, unless `AUTH_PASSWORD_LOGIN_ENABLED` explicitly asks for both, and the API refuses to start when that leaves no way in at all. Both `/api/auth/oidc/login` and the callback answer failures with a redirect to `/login?error=...` rather than an exception: they are browser navigations, and a JSON 500 is useless to the person looking at it (and, with auto-login on, loops). The callback lives at `/api/auth/oidc/callback` rather than the handler's default `/signin-oidc` because nginx only proxies `/api/` and `/hubs/` to the API. Operator-facing documentation is in [`AUTHENTICATION.md`](AUTHENTICATION.md); `AuthenticationFlowTests` drives the whole round-trip against a fake provider. ## Settings A user's settings (theme, language, units, the dashboard layout, the map's layers and filters) belong to the account they sign in with and follow it to every device. The browser owns what they mean; the server only keeps them. - **Stores.** `settingsUi`, `settingsDashboard` and `settingsMap` each hold one section. `persistSettings` in `settingsShared.ts` keeps a section in localStorage, which is what a page starts with and all there is while signed out, and hands it to `settingsSync`. - **Sync.** `settingsSync` reads `GET /api/settings` alongside the router guard's session check, and the guard lets no protected view mount until it has been taken in (the dashboard arranges its cards on a first visit, and must not do so over a layout saved elsewhere). From then on each local write sends only the keys that differ from what the account was last known to hold (`PATCH /api/settings/{section}`). A key changed on this page wins over the account's copy; every other key takes the account's. The page reads again when it comes back into view (at most every 30 seconds) or back online, and an account with no copy of a section yet is filled from this browser's own. - **Server.** `UserSettings` holds one row per account per section: a JSON object the server does not interpret beyond the fixed section list, key names, and a 16 KB cap. `UserSettingsRepository.MergeAsync` merges a save key by key, with a version column and a short randomised retry, so devices saving at the same moment keep each other's keys. Accounts are keyed by `SessionPrincipal.ResolveAccountKey`, a SHA-256 of the sign-in method and subject: no user name or email address is stored, and a password account and a provider account of the same name stay apart. - **A new setting** needs no backend change: a field in its store's interface, defaults and parse function travels with the section. The parse function is what reads the account's copy too, so it must fall back on values it does not know, which an older or newer build on another device may have saved. ## Database Code-first EF Core migrations live in `src/GarageStack.Data/Migrations/`. `Program.cs` runs `db.Database.MigrateAsync()` on Api startup in normal operation; in `DEMO_MODE` it calls `DemoSeeder.SeedAsync()` instead, which creates the in-memory schema and seeds fake data, bypassing a real Postgres server entirely. The Data project has its own `DesignTimeDbContextFactory`, so the EF tooling never needs the Api's configuration: ```sh dotnet tool restore dotnet ef migrations add --project src/GarageStack.Data --startup-project src/GarageStack.Data dotnet ef migrations has-pending-model-changes --project src/GarageStack.Data --startup-project src/GarageStack.Data ``` `dotnet tool restore` installs the `dotnet-ef` version pinned in `.config/dotnet-tools.json`, which matches the EF Core packages. CI runs the second command, and the pending-model-changes warning is not suppressed anywhere, so a model change without a migration fails before it reaches a real database. ### Adding a telemetry field A new value arriving from the car passes through several layers, in this order: 1. `TelemetrySnapshot` (the model) plus a migration, as above. 2. `TelemetryMapper` in the Worker, which maps the MQTT topic onto the new property. 3. The frontend's `TelemetrySnapshot` interface in `services/vehicleApi.ts`, mirrored by hand. 4. Wherever it should show up: a card in `frontend/src/cards/registry.ts`, and the demo data in `src/GarageStack.Data/Demo/` so the field is visible without a car. 5. Only for a counter that starts again from zero (per day, per charge, per journey): its entry in `TelemetryCompaction`'s resetting counters, so compaction keeps the total it reached before each reset. What it does *not* need is a mention in the merge loops (`TelemetryFields`) or the repository's "does this row carry anything" filters, or in `WidgetStatusDto`: the first two are derived from the model itself, and the widget deliberately serves a curated subset. Charts are the same story in reverse: add the field to `TelemetryHistoryPoint` and the daily statistics query starts reading rows that carry it; `DailyStatsBuilder` and `DailyStat` then decide what a day makes of it. ## Frontend REST calls go through `frontend/src/services/` - `apiCore.ts` centralizes the `fetch` wrapper (cookie-based auth, shared 401 handling, the JSON request helpers), and each domain area (`vehicleApi.ts`, `maintenanceApi.ts`, `notificationsApi.ts`, `mapApi.ts`, etc.) builds on it. Real-time updates use `useSignalR.ts` as described above, not polling. The vehicle store owns `activeVehicle`/`activeVin` (the one car this instance follows) and `effectiveVehicleType` (the user's manual override, else the drivetrain the API detected from the gateway's `hw_version` series code and the car's `BType` configuration code, in `VehicleTypeHelper`); views read those rather than indexing into the vehicle list or repeating the override logic. The TypeScript interfaces in `services/` mirror the API's DTOs by hand; the daily statistics endpoint (`/stats/daily`) returns one `DailyStat` per day in the browser's time zone, not snapshots: averages of the levels and the day's energy counter total, worked out on the server from every reading. The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does the database. The chosen units live in the UI settings store and are applied at the last moment by `UnitFormatter` in `utils/units.ts`, reached through the `useUnits` composable: a value on screen goes through `units.format(quantity, metricValue)` (or `measure` when the number and its unit are styled apart), never through a hand-written `km` or `bar`. A distance or temperature typed into a form goes back through `useMeasureField`, which returns the untouched metric value it loaded rather than a round-tripped approximation. The region, the clock and every unit default to automatic. `utils/region.ts` detects the region once per page load, from the browser's time zone first (`utils/timeZoneRegions.ts`, generated by `frontend/scripts/gen-time-zone-regions.mjs` from ICU's CLDR data) and from its languages after that, since an `en-US` browser in the Netherlands is common and its time zone is not. The store resolves the settings into `effectiveRegion`, `effectiveHourCycle` and `effectiveUnits`, and hands the region and clock to `utils/format.ts`. Every date and time on screen goes through that file (`formatDate`, `formatTime`, `formatDateTime`, `formatTimeOfDay` for a stored `HH:mm`), never through `toLocaleString` directly: dates are written in the interface language in the region's order (`en-NL` reads "09/10/2026"), times on the chosen clock, and numbers stay in the interface language wherever the browser is. Dashboard cards are described once, in `frontend/src/cards/registry.ts`: each entry carries the card's icon, whether it is visible by default for a given drivetrain, and whether the current telemetry has anything to show. The card ids, the default layout and the "does this card have data" checks are all derived from that list, so a new card is one entry plus its markup in `DashboardCardContent.vue`. The map's point-of-interest layers work the same way: `composables/poiTileLayer.ts` holds the fetch-by-tile, cache and cluster logic, and charging stations, fuel stations, service areas and speed cameras are four configurations of it. Every map is Leaflet, and everything drawn on one (markers, clusters, routes, the heatmap) is a Leaflet layer. On the map view, `composables/useTripLayers.ts` draws the trips and frames the map, and the panels, trip list and legends around it are components in `components/map/`. The basemap underneath them is not: `composables/useBasemap.ts` adds a MapLibre GL layer to Leaflet's tile pane, which renders vector tiles themed to match the app and labelled in the interface language. MapLibre and its tile worker are loaded lazily through `utils/maplibreLayer.ts` and never reach a page without a map; if that load fails, or the browser has no WebGL, the same composable falls back to raster tiles, so a map view is never empty. The dashboard's location card asks for those raster tiles directly, since a preview that cannot be panned gains nothing from the vector renderer. How a map looks (dark, light, or full colour with the **Colourful maps** setting) is decided once, by `basemapVariant()` in `utils/basemapStyle.ts`, and applied by `useBasemap` to every map. A vector map gets that variant's style; a raster map gets a `data-raster-basemap` attribute, which `main.css` turns into a filter that greys the full-colour tiles toward the dark or light style. ## Tests Backend tests (xUnit) and frontend unit tests (Vitest) cover logic in isolation. On top of those, `frontend/e2e/` holds Playwright smoke tests that run against the demo stack from `docker-compose.demo.yml`, which is the production frontend image with the real nginx config. That distinction matters: the Content-Security-Policy is served by nginx, so it does not exist in the Vite dev server or in jsdom. A change that every other check accepts can still break the shipped app, which is exactly what happened when the policy began covering `index.html` and blocked FontAwesome's runtime stylesheet. The smoke tests fail on any policy violation, and they check the handful of things that only appear in a real browser, such as icon sizing and the rotated map marker. CI runs them in the `e2e` job; see [CONTRIBUTING.md](../.github/CONTRIBUTING.md) for running them locally. ## Build conventions Backend projects share `Directory.Build.props` (analyzers on, warnings are errors, `.editorconfig` style rules enforced in the build) and `Directory.Packages.props` (central package versions). `dotnet format GarageStack.slnx` applies the formatting rules; CI verifies them. The frontend is linted by oxlint and ESLint, its stylesheets by Stylelint, and formatted by Prettier, all verified in CI. Versions are pinned once: package versions in `Directory.Packages.props` (the release workflow reads MinVer's version from there too, so the tag and the stamped assemblies cannot come from different MinVer majors), and pnpm in `frontend/package.json`'s `packageManager` field, which the Docker builds and the CI action both read. Runtime defaults belong to the app rather than to the deployment files: the compose files and the all-in-one entrypoint pass a setting through only when the operator sets one, so numbers like the tyre pressure thresholds exist in exactly one place.