diff --git a/README.md b/README.md index 181ab468..1d18b744 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - [Prerequisites](#prerequisites) and [Installation](#installation) - [Updating](#updating), [Monitoring](#monitoring) and [Backup and restore](#backup-and-restore) - [Push notifications](#push-notifications) -- [Trip log](#trip-log) +- [Trip log](#trip-log), [Climate schedules](#climate-schedules) and [Maintenance](#maintenance) - [Dutch registration (RDW)](#dutch-registration-rdw) - [Homepage dashboard widget](#homepage-dashboard-widget) and [Read-only API tokens](#read-only-api-tokens) - [Map overlays](#map-overlays) @@ -60,18 +60,18 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **Live dashboard** -- Real-time vehicle telemetry displayed as configurable cards. Cards are automatically shown or hidden based on your vehicle type (HEV, PHEV, BEV) and can be reordered or toggled individually in the dashboard's edit mode. The car drawn on the dashboard takes your car's colour (Settings > Car colour), and the vehicle type can be set by hand under Settings > Vehicle type when detection gets it wrong. - **Tyre history** -- Tap a tyre's pressure on the dashboard's car to see a month of daily pressures for all four tyres, and how each one moved against the other three over the last two weeks: a tyre falling behind the others while they hold is a slow puncture, which also sends a notification. - **Trip history** -- Browse past journeys on an interactive map, each drawn on the roads it took and coloured by speed or by the speed limit, with a heatmap of the roads you drive most. Each trip is saved to the database a few minutes after the car parks. After upgrading from a version that did not save trips, the existing history is saved in the background on the Worker's first start. -- **Trip log** -- Mark each trip as business, commute or private (commutes between your home and work are marked automatically), add notes, and export a month or a year as a spreadsheet with the addresses and odometer readings a tax trip log (such as the Dutch *rittenregistratie*) asks for. See [Trip log](#trip-log). +- **Trip log** -- Mark each trip as business, commute or private (commutes between your home and work are marked automatically), add notes, hide a GPS glitch, join two trips a fuel stop split, and export a month or a year as a spreadsheet with the addresses and odometer readings a tax trip log (such as the Dutch *rittenregistratie*) asks for. See [Trip log](#trip-log). - **Maintenance** -- Keep the car's service items (oil, tyres, the yearly inspection, ...) with a distance interval, a time interval or both. GarageStack works out from the odometer and your service log when each one is due, shows the most urgent on the dashboard, and sends a notification when an item reaches 90 % of its interval. Log each service with its date, odometer reading and notes. - **Dutch registration (RDW)** -- For a car with a Dutch licence plate, a dashboard card with the date the APK runs out and the days left, open recalls with what can go wrong and what the dealer does about it, and whether the car is insured, from the [RDW's open data](https://opendata.rdw.nl). Notifications come for a new recall, the APK a month and a week ahead, and a car with no insurance on record. Off until `RDW_ENABLED=true`; the plate, entered under Settings, is the only thing sent. See [Dutch registration (RDW)](#dutch-registration-rdw). - **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km on a plug-in car, L/100 km on a hybrid), fuel use, electric share, average driving speed, and more, for any period from a week to several years. - **Remote commands** -- Trigger climate pre-conditioning (normal climate, fan only or front defrost), lock or unlock the car, open the boot, and activate the horn and lights remotely from the dashboard. On a plug-in car also start or stop charging, lock or unlock the charging cable, switch battery heating on or off, set the charging and battery heating schedules, and set the charge current limit and, where the car takes one, the charge target. Opening the boot and unlocking the cable ask for a second tap. Each command reports whether the car carried it out, and if it refused, the reason the MG servers gave. - **Climate schedules** -- Switch climate on at a set time on chosen weekdays, or once. A schedule sets the temperature or mode, seat heating and the rear defroster, and can skip mild days by the car's outside temperature. Schedules run on the server, so no browser needs to be open, and each run sends a push notification saying whether the car started climate. -- **Push notifications** -- Browser and in-app alerts for key events: engine started, low or high tyre pressure, a slow puncture, low EV battery, low fuel, a weak 12V battery, car left unlocked, doors or windows left open, the car moving while parked, a charge that finished or stopped early, maintenance coming due, the APK running out or a recall on a Dutch plate, a climate schedule that ran, the messages the official MG app receives, and data that stopped arriving because the MG sign-in failed or the car went quiet. Each device chooses which ones it gets. -- **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance. +- **Push notifications** -- Browser and in-app alerts for key events: engine started, low or high tyre pressure, a slow puncture, low EV battery, low fuel, a weak 12V battery, car left unlocked, doors or windows left open, the car moving while parked, arriving at or leaving home or work, a charge that finished or stopped early, maintenance coming due, the APK running out or a recall on a Dutch plate, a climate schedule that ran, the messages the official MG app receives, and data that stopped arriving because the MG sign-in failed or the car went quiet. Each device chooses which ones it gets. +- **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance, along with the APK days left, open recalls, the maintenance due next and the last trip. - **Log viewer** -- The Logs page shows the entries of the API's and the Worker's log files, newest first and older ones loading as you scroll, filtered by service, level and text, with exceptions folded open on request and a live mode that adds what was written every ten seconds. The files themselves stay on the server, one per day (`LOG_RETAINED_FILES`). - **Full data export** -- Settings > Your data downloads everything GarageStack keeps as one zip: per car its trips, maintenance, schedules, places and every telemetry reading (as CSV), and your notifications and settings. - **Read-only API tokens** -- Make a token under Settings > API tokens and let a script, a dashboard or a watch read what the app shows, without signing in. A token can only read: it cannot send commands or change anything, and the full data export and the logs stay with a signed-in session. -- **Home Assistant** -- Your car appears in Home Assistant automatically through MQTT discovery, sharing GarageStack's broker and MG session instead of needing a second integration. See [`HOME_ASSISTANT.md`](documentation/HOME_ASSISTANT.md). +- **Home Assistant** -- Your car appears in Home Assistant automatically through MQTT discovery, sharing GarageStack's broker and MG session instead of needing a second integration. GarageStack adds sensors of its own to the car: the APK and its days left, open recalls, the maintenance due next and the last trip. See [`HOME_ASSISTANT.md`](documentation/HOME_ASSISTANT.md). - **Progressive Web App (PWA)** -- Installable on mobile or desktop for a native app-like experience, complete with a home screen icon and push notification support. Where the browser allows it, GarageStack offers to install itself, and the offer can be turned down for good. Installing and push need HTTPS, see [HTTPS](#https). - **Charging stations** -- Overlay nearby EV charging stations on the map, sourced from the [Open Charge Map](https://openchargemap.org) database. Station data is cached in the database for 7 days; on page load the map immediately shows all stations within 100 km of your car that are already cached. Markers show operational status; clicking a marker displays the station name, operator, address, and available connector types with power ratings. Requires a free OCM API key (`OPENCHARGEMAP_API_KEY`). Unlike fuel stations and service areas, charging station tiles are loaded on demand as you browse the map and are not pre-populated by the background Worker. - **Fuel stations** -- Overlay nearby petrol and diesel stations on the map (HEV and PHEV only; not shown for BEV). Sourced from OpenStreetMap via the Overpass API -- no API key required. POI data is cached in the database for 7 days and pre-populated by the Worker for a 100 km radius around the car's last known position so the overlay is instant on first view. @@ -253,7 +253,7 @@ The frontend is served on port `8080` by default (configurable via `FRONTEND_POR ## Updating -Read the release notes first: anything an upgrade needs from you is listed under **Upgrade Notes** at the top. Database migrations run automatically on startup and only forwards, so to go back to an older version, restore the backup you took before upgrading (see [Backup and restore](#backup-and-restore)). A new PostgreSQL major version is never installed by an update: moving from 18 to the next one means a `pg_dump` from the old version and a restore into a fresh volume on the new one. +Read the release notes first: anything an upgrade needs from you is listed under **Upgrade Notes** at the top. Database migrations run automatically on startup and only forwards, so to go back to an older version, restore the backup you took before upgrading (see [Backup and restore](#backup-and-restore)). A new PostgreSQL major version is never installed by an update: moving from 18 to the next one means a `pg_dump` from the old version and a restore into a fresh volume on the new one, step by step in [Moving to a new PostgreSQL major version](docker/all-in-one/README.md#moving-to-a-new-postgresql-major-version). **Docker Compose** builds GarageStack from the cloned source, so update the source and rebuild. `docker compose pull` fetches the gateway, Postgres and Mosquitto images: @@ -364,6 +364,8 @@ GarageStack checks your vehicle's state every 5 minutes and sends both a browser | Door left open | Any door, boot, or bonnet open while engine is off | | Window left open | Any window open while engine is off | | Moved while parked | The car reports a position more than 300 m from where it parked, with the engine off: towed, lifted onto a truck, or taken on a ferry. Sent once per parking, with the car's first report after the move (a sleeping car only reports now and then) | +| Arrived home or at work | The car parks at a home or work place with **Notify on arrival and departure** switched on (Trip log > Home and work), judged by the position of the poll it parked in. Not sent for a drive that never left the place it started in | +| Left home or work | The car drives out of such a place: sent on its first position outside the place's radius, so starting the engine to warm the car up sends nothing | | Charging complete | Charging stops while the cable is still connected (plug-in vehicles) | | Charging interrupted | Charging stops because of a fault the car names: the charger, an overheating port, a plug not fully in, or a voltage mismatch (plug-in vehicles; unplugging mid-charge sends nothing) | | Maintenance due | A maintenance item reaches 90 % of its interval, or passes it (checked every 6 hours, 7-day cooldown per item) | @@ -408,16 +410,44 @@ Note: "keys left in the car" is not currently supported because the SAIC MQTT ga - **Purpose.** Mark a trip as business, commute or private. Choosing the same purpose again clears it. When trips in the period have no purpose yet, one button gives them all the same one; trips that already have a purpose keep it. - **Home and work.** Set your home and work places once (**Home and work** at the top, or click a trip's start or end address to use it). A car has one home and can have several work places. Every trip between home and a work place is marked as a commute: the trips already in the log, and each new one as soon as it is saved. A trip counts as ending at a place when it ends within its radius (250 m unless you change it), so parking down the street or in the office garage still counts. Trips you classified yourself, or deliberately left unclassified, are never changed; an automatic commute shows "Automatic", and choosing another purpose overrides it. Moving or removing a place updates the automatic ones. Business and private stay yours to choose. The places are kept per car in GarageStack's own database. +- **Arriving and leaving.** Switch on **Notify on arrival and departure** for a place, and the car parking there, or driving away from it, sends a notification (see [Push notifications](#push-notifications)). It is off for every place until you switch it on, and each device can still leave those two types out. - **Notes.** Record anything a trip log should say, such as who you visited or why you took another route. Notes save when you leave the field. +- **Hide a trip.** A trip the GPS got wrong, or one that should not count, can be hidden: it leaves the map, the statistics, the dashboard, the totals and the export, and is listed only under **Show hidden trips**, where **Show again** brings it back. Nothing is deleted. +- **Join two trips.** A short stop, such as filling up on the way to work, splits one drive into two trips. **Join with next trip** (tap twice) makes them one, from the first one's start to the second one's end, with both notes kept. A purpose you chose for either is kept; otherwise it is labelled again from the new ends, so home to the fuel station to work becomes a commute. A join cannot be undone. - **Totals.** The distance per purpose for the period is shown at the top. - **Distance.** A trip counts for the distance on its odometer when the car reported a reading at both ends. Otherwise it counts for the distance along its GPS fixes, which is a little short because the fixes cut corners. - **Addresses** are looked up once through the same OpenStreetMap geocoder as the map, and then kept with the trip for good, so a log exported next year still shows them. With place names switched off (Settings > Map, or `GEOCODING__ENABLED=false`), ends show as coordinates and no lookups are made. -- **Export CSV** saves the period as a spreadsheet: date, departure and arrival time, from and to address, odometer start and end, distance, purpose and notes. The file follows the interface language: comma-separated in English, and semicolon-separated with decimal commas in Dutch, so it opens in columns in Excel either way. +- **Export CSV** saves the period, hidden trips left out, as a spreadsheet: date, departure and arrival time, from and to address, odometer start and end, distance, purpose and notes. The file follows the interface language: comma-separated in English, and semicolon-separated with decimal commas in Dutch, so it opens in columns in Excel either way. Only trips the Worker has saved can be logged, so the trip being driven, and one finished in the last few minutes, appear once the car has been parked for a while. --- +## Climate schedules + +**Climate schedules** in the sidebar switch the car's climate on at a time you set, so it is warm (or cool) when you leave. They run on the GarageStack server: no phone or browser has to be open. + +- **When.** A time on the chosen weekdays, or once, on the next day that time comes round, when no weekday is chosen. The time is kept in the time zone the browser had when the schedule was made, so 07:30 stays 07:30 across daylight saving time. +- **What.** A temperature (16 to 28 °C), or the fan only, or the front defrost; seat heating for either front seat, and the rear defroster. +- **Only when it is needed.** A schedule can run only below or above an outside temperature: warming the car on cold mornings only, say. The car's own outside temperature from the last three hours decides; without a recent one the schedule runs. +- **When it does not run.** Not while the engine runs, and not when the GarageStack Worker finds it more than 10 minutes late (after a restart, say): a car warmed long after you left is no use, so that run is recorded as missed. A car that answers that it is still busy, as one woken from a long sleep can, is asked again, up to three times. +- **What you hear.** Each run sends a notification saying whether the car started climate, refused, or did not answer. The schedule's card shows the last run and when it runs next, and the optional dashboard card the next start. + +Commands sent by hand at the same moment as a scheduled run share the car's one command at a time; see [`ARCHITECTURE.md`](documentation/ARCHITECTURE.md#climate-schedules) for how a run is carried out. + +--- + +## Maintenance + +**Maintenance** in the sidebar keeps the car's service items, such as the oil, the tyres or the yearly inspection. + +- **Intervals.** Each item has a distance interval, a time interval, or both; with both, it is due at whichever comes first. +- **When it is due.** From the last service you logged and the car's odometer, an item is *OK*, *due soon* once 90 % of its interval has passed, or *overdue*. One without a service logged yet is *not tracked yet*. +- **Logging a service.** Record the date, the odometer reading (the current one is filled in) and notes. The item's interval starts again from there, and its history is kept. +- **Where it shows.** The dashboard's maintenance card shows the most urgent item, and so do the [Homepage widget](#homepage-dashboard-widget) and [Home Assistant](documentation/HOME_ASSISTANT.md#garagestacks-own-sensors). A notification comes when an item becomes due soon or overdue (checked every 6 hours, at most once a week per item). + +--- + ## Dutch registration (RDW) For a car with a Dutch licence plate, GarageStack shows what the [RDW](https://opendata.rdw.nl) has on record for it: when the APK runs out, the recalls on the car, and whether it is WAM insured. It is off until the deployment sets `RDW_ENABLED=true`; until then nothing is sent and none of the below appears. @@ -445,7 +475,13 @@ To read GarageStack's data from a script, a dashboard or a watch, make a token u curl -H "Authorization: Bearer gs_..." https://garagestack.example.com/api/vehicles ``` -A token reads as the account that made it, through the same endpoints the app uses (the API reference lists them, see [`DEMO.md`](documentation/DEMO.md#manual-start)), and it can only read: any request that would change something, send a command or make another token is refused with `403`. The full data export and the server logs are refused too: they hold the car's whole history and the services' diagnostics, so they need a signed-in session. The list under Settings shows when each token was last used; revoking one stops it at once. GarageStack keeps only a hash of each token, so a lost one cannot be shown again: revoke it and make a new one. +A token reads as the account that made it, through the same endpoints the app uses, and it can only read: any request that would change something, send a command or make another token is refused with `403`. The full data export and the server logs are refused too: they hold the car's whole history and the services' diagnostics, so they need a signed-in session. The list under Settings shows when each token was last used; revoking one stops it at once. GarageStack keeps only a hash of each token, so a lost one cannot be shown again: revoke it and make a new one. + +**The API reference** is at `/api/openapi/v1.json`, readable with the token like any other endpoint: every endpoint, what it takes and what it answers, as an OpenAPI document. Load it into any OpenAPI viewer, for example: + +```bash +curl -H "Authorization: Bearer gs_..." https://garagestack.example.com/api/openapi/v1.json -o garagestack-openapi.json +``` A few things to know before building on a token: @@ -557,7 +593,7 @@ A reading counts as over the limit only past 5 km/h above it: speedometers read - With an identity provider, restrict who may sign in -- at the provider itself or with `OIDC_ALLOWED_GROUPS` / `OIDC_ALLOWED_EMAILS`. Without a restriction, every account the provider accepts can sign in and control the car. - Login endpoints are rate-limited per IP address on top of the global limit. - MQTT now requires credentials and ACLs, and broker exposure defaults to localhost-only in Docker Compose. -- The optional Home Assistant broker login can only use the car's `saic/#` topics and read Home Assistant discovery, so it cannot publish fake discovery configs or reach anything else on the broker. +- The optional Home Assistant broker login can only use the car's `saic/#` topics and read Home Assistant discovery and GarageStack's own sensor values (`garagestack/#`), so it cannot publish fake discovery configs or reach anything else on the broker. - The frontend sends a strict Content-Security-Policy with every response, which allows scripts only from GarageStack itself. See [Behind a reverse proxy or CDN](#behind-a-reverse-proxy-or-cdn) for keeping it intact. ### What leaves your network diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index 5730e907..2f04259c 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -181,6 +181,46 @@ docker start garagestack To restore, stop the container, replace `garagestack-data` with the backup, and start it again. Only the database (`garagestack-data/db/postgres`) and DataProtection keys (`garagestack-data/dataprotection`) actually matter for disaster recovery -- losing the keys just logs everyone out, it doesn't lose any vehicle data. +A dump of the database alone, taken while the container runs, is a smaller backup that also moves between PostgreSQL versions: + +```bash +docker exec -u postgres garagestack pg_dump --clean --if-exists garagestack > garagestack-backup.sql +``` + +### Moving to a new PostgreSQL major version + +The image bundles PostgreSQL 18, and a PostgreSQL major version cannot read the data directory of another. An update never changes it on its own: when a release moves the image to the next major version, its release notes say so, and the database moves over by dump and restore. Replace `garagestack` with your `POSTGRES_DB` if you changed it. + +1. **Dump the database with the image you run now**, as above, and keep a copy of the whole folder too: + + ```bash + docker exec -u postgres garagestack pg_dump --clean --if-exists garagestack > garagestack-backup.sql + docker stop garagestack + cp -r garagestack-data garagestack-data-backup + ``` + +2. **Put the old database aside**, so the new image starts a fresh one. `.db_initialized` is what tells the container its database is set up already: + + ```bash + docker rm garagestack + mv garagestack-data/db/postgres garagestack-data/db/postgres-old + rm garagestack-data/.db_initialized + ``` + +3. **Start the new image** with your usual `docker run` command, and wait for the web interface to answer. It creates an empty database, keeping the password in `.postgres_password`. + +4. **Load the dump into it**, with the API and the Worker stopped so nothing writes meanwhile. The dump drops the empty tables before it loads its own: + + ```bash + docker exec garagestack supervisorctl stop api worker + docker exec -i -u postgres garagestack psql -v ON_ERROR_STOP=1 garagestack < garagestack-backup.sql + docker exec garagestack supervisorctl start api worker + ``` + +5. **Check the app**, then remove `garagestack-data/db/postgres-old` and the backup copy once you are sure. To go back instead, stop the container, put `garagestack-data-backup` back in place of `garagestack-data`, and run the previous image. + +Docker Compose with the bundled database works the same way: dump with `docker compose exec postgres pg_dump ...`, start the new `postgres` image on a fresh volume, and restore with `psql` (see [Backup and restore](../../README.md#backup-and-restore)). + ## Troubleshooting ### 28P01: password authentication failed for user "garagestack" diff --git a/docker/mosquitto-auth.sh b/docker/mosquitto-auth.sh index 251437c9..932ca560 100644 --- a/docker/mosquitto-auth.sh +++ b/docker/mosquitto-auth.sh @@ -38,9 +38,11 @@ if [ -n "$HA_MQTT_USERNAME" ]; then # holds on each connect, so write access to all of saic/# would let a restarted Home # Assistant broker overwrite fresh state here with its stale copies (the gateway's LWT # among them, which leaves every entity Unavailable). - # homeassistant/#: the gateway's retained discovery configs. homeassistant/status: Home - # Assistant's own online/offline message, which makes the gateway re-publish discovery. - printf '\nuser %s\ntopic read saic/#\ntopic write saic/+/vehicles/+/+/+/set\ntopic write saic/+/vehicles/+/+/+/+/set\ntopic read homeassistant/#\ntopic write homeassistant/status\n' \ + # homeassistant/#: the gateway's retained discovery configs, and GarageStack's own (APK, + # recalls, maintenance, last trip). garagestack/#: the state those read their values from. + # homeassistant/status: Home Assistant's own online/offline message, which makes the gateway + # re-publish discovery. + printf '\nuser %s\ntopic read saic/#\ntopic write saic/+/vehicles/+/+/+/set\ntopic write saic/+/vehicles/+/+/+/+/set\ntopic read homeassistant/#\ntopic write homeassistant/status\ntopic read garagestack/#\n' \ "$HA_MQTT_USERNAME" >> "$acl_file" fi diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index e1fd9728..4b16f586 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -33,7 +33,7 @@ without a real car, see [DEMO.md](DEMO.md). - **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.). +- **Api** (`GarageStack.Api`) serves the REST API and SignalR hub the frontend talks to, and separately publishes outbound MQTT messages: remote commands (lock, climate, etc.), and, every five minutes, Home Assistant discovery for what only GarageStack knows (`HomeAssistantPublisher`: the APK and recalls, the maintenance due first, the last trip, from `VehicleFactsService`, which the Homepage widget reads too). - **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. @@ -88,6 +88,7 @@ The trip log (`/trip-log`, `TripEndpoints`) shows only saved trips, because only - **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. +- **Hiding and joining.** `Trip.IsHidden` keeps a trip out of `GetTripsAsync`, `GetSummariesAsync` and `GetLatestAsync` (the map, the statistics and the dashboard); the log still lists it so it can be shown again, and the browser leaves it out of the totals and the export. A hidden trip is kept rather than deleted, so a migration that records trips again cannot quietly bring back what someone removed. `MergeWithNextAsync` joins a trip with the next saved one in one transaction: the first keeps its id and takes the second's end, fixes, odometer reading and end address, and the second is removed. A purpose someone chose survives the join; an automatic one is worked out again from the new ends. A join is not undone: the fixes of both are in the joined trip, but where one ended is not kept. - **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. diff --git a/documentation/DEMO.md b/documentation/DEMO.md index 4010bf51..5608d432 100644 --- a/documentation/DEMO.md +++ b/documentation/DEMO.md @@ -18,7 +18,7 @@ docker compose --env-file .env.demo -f docker-compose.demo.yml up --build Open [http://localhost:8080](http://localhost:8080) and log in with **demo / demo**. -The API is available at `http://localhost:5001`. Scalar API docs are not served in production mode, so start the API locally (see below) if you need them. +The API is available at `http://localhost:5001`. Its OpenAPI document is at `/api/openapi/v1.json` for anyone signed in, as in every deployment; the Scalar viewer for it is served only when the API runs locally (see below). This stack is also what the Playwright smoke tests run against, in CI and locally, because it is the production frontend image with the real nginx config. See [CONTRIBUTING.md](../.github/CONTRIBUTING.md). @@ -45,7 +45,7 @@ dotnet run --launch-profile Demo This starts the API at `http://localhost:5000` with `DEMO_MODE=true`, `Auth:Username=demo`, and `Auth:Password=demo` pre-configured. No database or MQTT connection is made. -The API reference (Scalar) is at [http://localhost:5000/scalar/v1](http://localhost:5000/scalar/v1), and the OpenAPI document at `/openapi/v1.json`. Both are served only in the `Development` and `Demo` environments (`ASPNETCORE_ENVIRONMENT`), never by the Docker images. +The API reference (Scalar) is at [http://localhost:5000/scalar/v1](http://localhost:5000/scalar/v1), and the OpenAPI document at `/openapi/v1.json`. Both are served only in the `Development` and `Demo` environments (`ASPNETCORE_ENVIRONMENT`). Every deployment, the Docker images included, also serves the document at `/api/openapi/v1.json` to whoever is signed in or holds an API token. **Frontend:** diff --git a/documentation/HOME_ASSISTANT.md b/documentation/HOME_ASSISTANT.md index 070a59cf..fa781a39 100644 --- a/documentation/HOME_ASSISTANT.md +++ b/documentation/HOME_ASSISTANT.md @@ -2,6 +2,8 @@ GarageStack and [Home Assistant](https://www.home-assistant.io) can share the same car data. The SAIC MQTT gateway inside GarageStack already publishes [Home Assistant MQTT discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) messages, so all Home Assistant needs is a login on GarageStack's Mosquitto broker. Your car then shows up as a device with sensors (battery, range, tyre pressures, doors, location and more) and controls (lock, climate, charging), with no custom component or YAML. +GarageStack adds sensors of its own to the same device, for what only it knows: the APK date and the days it has left, the open recalls and the insurance from the RDW, the maintenance item due first, and the last trip. See [GarageStack's own sensors](#garagestacks-own-sensors). + **Don't install a separate MG / SAIC integration in Home Assistant as well.** The MG iSmart API allows one active session per account, so a second client logging in with the same account keeps kicking GarageStack out (see [MG iSmart account and session limits](../README.md#mg-ismart-account-and-session-limits)). Sharing the broker means one login and one set of polls against the MG cloud, used by both. --- @@ -15,6 +17,8 @@ GarageStack and [Home Assistant](https://www.home-assistant.io) can share the sa │ homeassistant/.../config (discovery, retained) ▼ Mosquitto ◄──── GarageStack Worker and Api (internal login, full access) + │ the Api publishes ──► garagestack//state (its own sensors' values, retained) + │ homeassistant/.../garagestack_/.../config ▲ └──────── Home Assistant (HA_MQTT_USERNAME, restricted) ``` @@ -92,6 +96,7 @@ topic saic/+/vehicles/+/+/+/set out 0 topic saic/+/vehicles/+/+/+/+/set out 0 topic homeassistant/# in 0 topic homeassistant/status out 0 +topic garagestack/# in 0 ``` The car's data only flows in. Only the command topics and `homeassistant/status` go out to GarageStack, so your existing broker's own discovery messages stay local. @@ -123,9 +128,28 @@ For any other Mosquitto broker, add the same lines to its configuration file and --- +## GarageStack's own sensors + +The Api publishes these every five minutes, and as soon as one changes, on the car's device in Home Assistant. They need nothing beyond the broker login above (and, through a bridge, its `garagestack/#` line). A sensor appears once it has something to show and disappears again when it no longer does: the RDW ones need `RDW_ENABLED` and a plate, the maintenance ones a maintenance item, the trip ones a trip. + +| Entity | Shows | +| ------ | ----- | +| APK expires | The APK's last valid day (a date) | +| APK days left | Days until then, negative once it has run out | +| Open recalls | Recalls on the plate the RDW has not seen repaired | +| WAM insured | On while the RDW has WAM insurance on record | +| Next maintenance | The name of the item due first: the most urgent, then the soonest | +| Next maintenance status | `ok`, `due_soon`, `overdue` or `unknown` | +| Next maintenance due | The date it is due by its time interval | +| Next maintenance days left / distance left | How far off it is, in days and in kilometres | +| Last trip distance | The newest trip's distance (km), the one being driven included | +| Last trip started / ended | When it started and ended | + +Each reads its value from one retained JSON message, `garagestack//state`, so an automation can also use the whole object. The Homepage widget serves the same values (see [`WIDGET.md`](WIDGET.md)). + ## Reading GarageStack with an API token -The bridge above gives Home Assistant the car's own entities. What GarageStack works out itself (maintenance due, the latest trip, the merged status it shows on the dashboard) can be read with a [read-only API token](../README.md#read-only-api-tokens) and a [REST sensor](https://www.home-assistant.io/integrations/sensor.rest/). Make the token under **Settings > API tokens**, keep it in `secrets.yaml`, and use the car's VIN in the URL: +The broker gives Home Assistant the car's entities and the sensors above. Anything else GarageStack works out (the full maintenance list, the merged status it shows on the dashboard) can be read with a [read-only API token](../README.md#read-only-api-tokens) and a [REST sensor](https://www.home-assistant.io/integrations/sensor.rest/). Make the token under **Settings > API tokens**, keep it in `secrets.yaml`, and use the car's VIN in the URL: ```yaml # configuration.yaml diff --git a/documentation/WIDGET.md b/documentation/WIDGET.md index dcc10713..6074eb37 100644 --- a/documentation/WIDGET.md +++ b/documentation/WIDGET.md @@ -114,3 +114,15 @@ The endpoint returns a flat JSON object. Numeric fields are `null` when the vehi | `chargingScheduleEndTime` | string | Scheduled charge end time (HH:MM) | | `onboardChargerPlugStatus` | number | Onboard charger plug presence status (raw integer) | | `offboardChargerPlugStatus` | number | Offboard (DC) charger plug presence status (raw integer) | +| `apkExpiresOn` | string (date) | The APK's last valid day, as the RDW has it (`RDW_ENABLED` and a plate set; `null` otherwise) | +| `apkDaysLeft` | number | Days until then on the Dutch calendar, negative once it has run out | +| `openRecalls` | number | Recalls on the plate the RDW has not seen repaired | +| `wamInsured` | string | `"Insured"` or `"Not insured"`: whether the RDW has WAM insurance on record | +| `nextMaintenance` | string | The maintenance item due first: the most urgent, then the soonest (`null` without items) | +| `nextMaintenanceStatus` | string | `"OK"`, `"Due soon"`, `"Overdue"` or `"Not tracked yet"`, as the Maintenance page shows it | +| `nextMaintenanceDueDate` | string (ISO 8601) | When it is due by its time interval, if it has one | +| `nextMaintenanceDaysLeft` | number | Days until then, negative once passed | +| `nextMaintenanceKmLeft` | number | Kilometres until it is due by its distance interval, negative once passed | +| `lastTripStartedAt` | string (ISO 8601) | When the newest trip started, the one being driven included | +| `lastTripEndedAt` | string (ISO 8601) | When it ended, or its latest fix while it is still being driven | +| `lastTripDistanceKm` | number | Its distance along the GPS fixes (km) | diff --git a/documentation/screenshots/README.md b/documentation/screenshots/README.md index e2952a00..494f159b 100644 --- a/documentation/screenshots/README.md +++ b/documentation/screenshots/README.md @@ -1,6 +1,6 @@ # Screenshots -Every view of GarageStack, on a desktop and on a phone. They were taken in [demo mode](../DEMO.md), so the car, its trips and the addresses are sample data. +The main pages of GarageStack, on a desktop and on a phone. They were taken in [demo mode](../DEMO.md), so the car, its trips and the addresses are sample data. ## Desktop @@ -22,12 +22,16 @@ Every view of GarageStack, on a desktop and on a phone. They were taken in [demo ### Trip log -![The trip log: totals per purpose for the month, and each trip with its addresses, odometer readings, a business, commute or private choice and a notes field](desktop-trip-log.webp) +![The trip log: totals per purpose for the month, and each trip with its addresses, odometer readings, a business, commute or private choice, a notes field, and buttons to hide it or join it with the next trip](desktop-trip-log.webp) ### Maintenance ![The maintenance list: service items marked overdue, due soon, OK or not tracked yet](desktop-maintenance.webp) +### Climate schedules + +![The climate schedules: a weekday schedule at 07:30 with seat heating, and a one-off front defrost at 08:15, each with when it runs next](desktop-climate-schedules.webp) + ### Light theme ![The dashboard in the light theme](desktop-dashboard-light.webp) @@ -39,11 +43,19 @@ Every view of GarageStack, on a desktop and on a phone. They were taken in [demo | Dashboard | Map | A single trip | Light theme | | --------- | --- | ------------- | ----------- | | The dashboard on a phone | The map on a phone | One trip against the speed limits on a phone | The dashboard on a phone in the light theme | -| **Statistics** | **Trip log** | **Maintenance** | | -| The statistics on a phone | The trip log on a phone | The maintenance list on a phone | | +| **Statistics** | **Trip log** | **Maintenance** | **Climate schedules** | +| The statistics on a phone | The trip log on a phone | The maintenance list on a phone | The climate schedules on a phone | ## Taking new ones -The desktop screenshots are 1280 x 800 and the phone screenshots 390 x 844 at twice the pixel density, in the dark theme with the date filter on 30 days. The dashboard, map and statistics screenshots are also the install screenshots in the app's manifest, as `frontend/public/screenshot-*.webp`: replace those too, and keep their `sizes` in `frontend/vite.config.ts` in step. +`pnpm screenshots` in `frontend/` takes all of them again from a running demo stack, and copies the dashboard, map and statistics ones to the install screenshots in the app's manifest (`frontend/public/screenshot-*.webp`): + +```bash +cp .env.demo.example .env.demo +docker compose --env-file .env.demo -f docker-compose.demo.yml up -d --build # repository root +cd frontend && pnpm screenshots # or: pnpm screenshots http://localhost:5173 +``` + +It signs in as `demo`, and takes the desktop screenshots at 1280 x 800 and the phone screenshots at 390 x 844 at twice the pixel density, in the dark theme with the date filter on 30 days and the region set to the Netherlands, plus the dashboard in the light theme. The maps need the internet for their tiles; behind a proxy, `HTTPS_PROXY` is used for them. `CHROMIUM_PATH` points it at a Chromium other than Playwright's own. Keep the manifest's `sizes` in `frontend/vite.config.ts` in step if the sizes change. diff --git a/documentation/screenshots/desktop-climate-schedules.webp b/documentation/screenshots/desktop-climate-schedules.webp new file mode 100644 index 00000000..fe5be539 Binary files /dev/null and b/documentation/screenshots/desktop-climate-schedules.webp differ diff --git a/documentation/screenshots/desktop-dashboard-light.webp b/documentation/screenshots/desktop-dashboard-light.webp index 14bcc6aa..bb277c12 100644 Binary files a/documentation/screenshots/desktop-dashboard-light.webp and b/documentation/screenshots/desktop-dashboard-light.webp differ diff --git a/documentation/screenshots/desktop-dashboard.webp b/documentation/screenshots/desktop-dashboard.webp index 25049ab7..b07c8a5a 100644 Binary files a/documentation/screenshots/desktop-dashboard.webp and b/documentation/screenshots/desktop-dashboard.webp differ diff --git a/documentation/screenshots/desktop-maintenance.webp b/documentation/screenshots/desktop-maintenance.webp index dec09934..79cb42d3 100644 Binary files a/documentation/screenshots/desktop-maintenance.webp and b/documentation/screenshots/desktop-maintenance.webp differ diff --git a/documentation/screenshots/desktop-map-trip.webp b/documentation/screenshots/desktop-map-trip.webp index 6091a7c0..400bf847 100644 Binary files a/documentation/screenshots/desktop-map-trip.webp and b/documentation/screenshots/desktop-map-trip.webp differ diff --git a/documentation/screenshots/desktop-map.webp b/documentation/screenshots/desktop-map.webp index 16940d0d..7c6b0134 100644 Binary files a/documentation/screenshots/desktop-map.webp and b/documentation/screenshots/desktop-map.webp differ diff --git a/documentation/screenshots/desktop-statistics.webp b/documentation/screenshots/desktop-statistics.webp index d2b0df6f..fbbb1485 100644 Binary files a/documentation/screenshots/desktop-statistics.webp and b/documentation/screenshots/desktop-statistics.webp differ diff --git a/documentation/screenshots/desktop-trip-log.webp b/documentation/screenshots/desktop-trip-log.webp index 06ec6ff7..64b2b15e 100644 Binary files a/documentation/screenshots/desktop-trip-log.webp and b/documentation/screenshots/desktop-trip-log.webp differ diff --git a/documentation/screenshots/mobile-climate-schedules.webp b/documentation/screenshots/mobile-climate-schedules.webp new file mode 100644 index 00000000..dfd4942b Binary files /dev/null and b/documentation/screenshots/mobile-climate-schedules.webp differ diff --git a/documentation/screenshots/mobile-dashboard-light.webp b/documentation/screenshots/mobile-dashboard-light.webp index 6870295f..bd54f785 100644 Binary files a/documentation/screenshots/mobile-dashboard-light.webp and b/documentation/screenshots/mobile-dashboard-light.webp differ diff --git a/documentation/screenshots/mobile-dashboard.webp b/documentation/screenshots/mobile-dashboard.webp index d3506c0d..700319c3 100644 Binary files a/documentation/screenshots/mobile-dashboard.webp and b/documentation/screenshots/mobile-dashboard.webp differ diff --git a/documentation/screenshots/mobile-maintenance.webp b/documentation/screenshots/mobile-maintenance.webp index 0d502aed..eb29a7c9 100644 Binary files a/documentation/screenshots/mobile-maintenance.webp and b/documentation/screenshots/mobile-maintenance.webp differ diff --git a/documentation/screenshots/mobile-map-trip.webp b/documentation/screenshots/mobile-map-trip.webp index 542b31b2..df9d20ef 100644 Binary files a/documentation/screenshots/mobile-map-trip.webp and b/documentation/screenshots/mobile-map-trip.webp differ diff --git a/documentation/screenshots/mobile-map.webp b/documentation/screenshots/mobile-map.webp index 7e08971e..1b60fd44 100644 Binary files a/documentation/screenshots/mobile-map.webp and b/documentation/screenshots/mobile-map.webp differ diff --git a/documentation/screenshots/mobile-statistics.webp b/documentation/screenshots/mobile-statistics.webp index 03179772..6b18fcfc 100644 Binary files a/documentation/screenshots/mobile-statistics.webp and b/documentation/screenshots/mobile-statistics.webp differ diff --git a/documentation/screenshots/mobile-trip-log.webp b/documentation/screenshots/mobile-trip-log.webp index aa088b55..f8f51eb0 100644 Binary files a/documentation/screenshots/mobile-trip-log.webp and b/documentation/screenshots/mobile-trip-log.webp differ diff --git a/frontend/package.json b/frontend/package.json index e2533eac..3c7cd3d7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "test:unit": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", + "screenshots": "node scripts/screenshots.mjs", "build-only": "vite build && node scripts/precompress.mjs dist", "type-check": "vue-tsc --build", "lint": "pnpm run lint:oxlint && pnpm run lint:eslint && pnpm run lint:css", diff --git a/frontend/public/screenshot-desktop-home.webp b/frontend/public/screenshot-desktop-home.webp index 25049ab7..b07c8a5a 100644 Binary files a/frontend/public/screenshot-desktop-home.webp and b/frontend/public/screenshot-desktop-home.webp differ diff --git a/frontend/public/screenshot-desktop-map.webp b/frontend/public/screenshot-desktop-map.webp index 16940d0d..7c6b0134 100644 Binary files a/frontend/public/screenshot-desktop-map.webp and b/frontend/public/screenshot-desktop-map.webp differ diff --git a/frontend/public/screenshot-desktop-statistics.webp b/frontend/public/screenshot-desktop-statistics.webp index d2b0df6f..fbbb1485 100644 Binary files a/frontend/public/screenshot-desktop-statistics.webp and b/frontend/public/screenshot-desktop-statistics.webp differ diff --git a/frontend/public/screenshot-mobile-home.webp b/frontend/public/screenshot-mobile-home.webp index d3506c0d..700319c3 100644 Binary files a/frontend/public/screenshot-mobile-home.webp and b/frontend/public/screenshot-mobile-home.webp differ diff --git a/frontend/public/screenshot-mobile-map.webp b/frontend/public/screenshot-mobile-map.webp index 7e08971e..1b60fd44 100644 Binary files a/frontend/public/screenshot-mobile-map.webp and b/frontend/public/screenshot-mobile-map.webp differ diff --git a/frontend/public/screenshot-mobile-statistics.webp b/frontend/public/screenshot-mobile-statistics.webp index 03179772..6b18fcfc 100644 Binary files a/frontend/public/screenshot-mobile-statistics.webp and b/frontend/public/screenshot-mobile-statistics.webp differ diff --git a/frontend/scripts/screenshots.mjs b/frontend/scripts/screenshots.mjs new file mode 100644 index 00000000..81b734fe --- /dev/null +++ b/frontend/scripts/screenshots.mjs @@ -0,0 +1,155 @@ +// Takes the screenshot gallery (documentation/screenshots) and the install screenshots in the +// manifest (public/screenshot-*.webp) from a running demo stack, so they show the app as it is. +// +// docker compose --env-file .env.demo -f docker-compose.demo.yml up -d --build (repository root) +// cd frontend && pnpm screenshots [base URL, http://localhost:8080 by default] +// +// Desktop shots are 1280 x 800, phone shots 390 x 844 at twice the density, in the dark theme with +// the date filter on 30 days, in the Netherlands, and one of each in the light theme. CHROMIUM_PATH points at a +// browser other than Playwright's own; HTTPS_PROXY, when set, is where the map's tiles are fetched. +import { copyFile, writeFile } from 'node:fs/promises' +import { chromium } from '@playwright/test' + +const base = (process.argv[2] ?? 'http://localhost:8080').replace(/\/$/, '') +const docs = new URL('../../documentation/screenshots/', import.meta.url) +const publicDir = new URL('../public/', import.meta.url) + +const DESKTOP = { name: 'desktop', viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 } +const PHONE = { + name: 'mobile', + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, +} + +// The pages of the gallery, each with what has to have happened before it is worth a picture. +const PAGES = [ + { file: 'dashboard', path: '/' }, + { file: 'map', path: '/map' }, + { file: 'map-trip', path: '/map', prepare: selectTrip }, + { file: 'statistics', path: '/statistics' }, + { file: 'trip-log', path: '/trip-log' }, + { file: 'maintenance', path: '/maintenance' }, + { file: 'climate-schedules', path: '/climate-schedules' }, +] + +// The install screenshots in the manifest (vite.config.ts), copied from the gallery's. +const MANIFEST = { dashboard: 'home', map: 'map', statistics: 'statistics' } + +const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH || undefined, + // Playwright sends even localhost through a proxy unless told otherwise. + proxy: process.env.HTTPS_PROXY + ? { server: process.env.HTTPS_PROXY, bypass: 'localhost,127.0.0.1' } + : undefined, +}) + +try { + for (const device of [DESKTOP, PHONE]) { + await shoot(device, 'dark', PAGES) + await shoot(device, 'light', [{ file: 'dashboard-light', path: '/' }]) + } + for (const [page, name] of Object.entries(MANIFEST)) { + for (const device of ['desktop', 'mobile']) { + await copyFile( + new URL(`${device}-${page}.webp`, docs), + new URL(`screenshot-${device}-${name}.webp`, publicDir), + ) + } + } +} finally { + await browser.close() +} + +async function shoot(device, theme, pages) { + const { name, ...options } = device + // The region follows the browser's time zone: Amsterdam gives the gallery's kilometres, bar and + // 24-hour clock whatever machine runs this. + const context = await browser.newContext({ + ...options, + colorScheme: theme, + locale: 'en-GB', + timezoneId: 'Europe/Amsterdam', + }) + // The install offer would cover the phone screenshots. + await context.addInitScript(() => { + localStorage.setItem('garagestack-pwa-install-dismissed', 'never') + }) + const page = await context.newPage() + await signIn(page) + // Saved to the demo account, which every page then takes: the theme, English, the gallery's + // 30 days, and a selected trip coloured against the speed limits. + await saveSettings(page, 'ui', { theme, locale: 'en', filterDays: 30 }) + await saveSettings(page, 'map', { speedLimitOverlayEnabled: true }) + + for (const shot of pages) { + await page.goto(base + shot.path) + await settled(page) + if (shot.prepare) await shot.prepare(page) + // A focused control scrolls the page a little on its way in; the gallery shows it from the top. + await page.evaluate(() => document.querySelector('.main-content')?.scrollTo({ top: 0 })) + const file = `${name}-${shot.file}.webp` + await writeFile(new URL(file, docs), await webp(page, await page.screenshot())) + console.log(`screenshots: ${file}`) + } + await context.close() +} + +async function signIn(page) { + await page.goto(`${base}/login`) + await page.fill('#username', process.env.DEMO_USERNAME ?? 'demo') + await page.fill('#password', process.env.DEMO_PASSWORD ?? 'demo') + await Promise.all([page.waitForURL(`${base}/`), page.click('button[type="submit"]')]) +} + +async function saveSettings(page, section, changes) { + const status = await page.evaluate( + async ({ section, changes }) => { + const response = await fetch(`/api/settings/${section}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(changes), + }) + return response.status + }, + { section, changes }, + ) + if (status >= 300) throw new Error(`Saving the screenshot settings answered ${status}`) +} + +// Loaded, skeletons gone, and a moment for the map's tiles and the charts' animations. +async function settled(page) { + await page.waitForLoadState('networkidle').catch(() => {}) + await page + .waitForFunction(() => !document.querySelector('.skeleton'), null, { timeout: 20_000 }) + .catch(() => {}) + await page.waitForTimeout(3000) +} + +// One trip, snapped to the roads and coloured against the speed limits, as the gallery shows it. +async function selectTrip(page) { + const trips = page.locator('.trip-list__button') + const toUtrecht = trips.filter({ hasText: 'Utrecht' }).first() + await ((await toUtrecht.count()) > 0 ? toUtrecht : trips.first()).click() + await settled(page) +} + +// The browser encodes WebP itself, so the script needs no image library. +async function webp(page, png) { + const dataUrl = await page.evaluate( + async (source) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement('canvas') + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + canvas.getContext('2d').drawImage(image, 0, 0) + return canvas.toDataURL('image/webp', 0.85) + }, + `data:image/png;base64,${png.toString('base64')}`, + ) + return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64') +} diff --git a/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index acb893f6..6efd63d3 100644 --- a/frontend/src/assets/tripLog.css +++ b/frontend/src/assets/tripLog.css @@ -74,6 +74,12 @@ } } +/* After the radius, at the row's far end: this place sends arrival and departure notifications. */ +.commute-places__notify { + margin-left: auto; + color: var(--color-text-muted); +} + /* ── Trip list ────────────────────────────────────────────── */ .trip-log-list { @@ -105,6 +111,12 @@ opacity: 0.7; } +/* Listed only so it can be shown again: it counts nowhere. */ +.trip-log-item--hidden { + border-style: dashed; + opacity: 0.6; +} + .trip-log-item__when { grid-area: when; display: flex; @@ -191,6 +203,20 @@ color: var(--color-text); } +/* Hide and join, after the notes: rarer than choosing a purpose, so they come last. */ +.trip-log-item__manage { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.trip-log-hidden-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + align-self: flex-end; +} + /* ── Purpose chips (ChipGroup, main.css) ─────────────────── */ /* Two classes, so the colour wins over .toggle-chip whichever stylesheet loads last. */ diff --git a/frontend/src/components/CommutePlacesModal.vue b/frontend/src/components/CommutePlacesModal.vue index 3c955db1..1d65cee7 100644 --- a/frontend/src/components/CommutePlacesModal.vue +++ b/frontend/src/components/CommutePlacesModal.vue @@ -7,6 +7,7 @@ import DetailModal from './DetailModal.vue' import ConfirmFooter from './ConfirmFooter.vue' import ChipGroup from './ChipGroup.vue' import RangeControl from './RangeControl.vue' +import SettingsToggle from './SettingsToggle.vue' import { useCommutePlacesStore } from '@/stores/commutePlaces' import { COMMUTE_PLACE_KINDS, @@ -51,6 +52,7 @@ interface Draft { latitude: number longitude: number radiusMeters: number + notify: boolean } // Null shows the list; a draft shows the editor for a new or an existing place. @@ -86,6 +88,7 @@ function newDraft(latitude: number, longitude: number, kind?: CommutePlaceKind): latitude, longitude, radiusMeters: COMMUTE_RADIUS.default, + notify: false, } } @@ -167,12 +170,12 @@ function done(change: CommutePlaceChange | null) { async function save() { if (!props.vin || !draft.value) return - const { id, kind, label, latitude, longitude, radiusMeters } = draft.value + const { id, kind, label, latitude, longitude, radiusMeters, notify } = draft.value const trimmed = label.trim() done( await store.savePlace( props.vin, - { kind, label: trimmed === '' ? null : trimmed, latitude, longitude, radiusMeters }, + { kind, label: trimmed === '' ? null : trimmed, latitude, longitude, radiusMeters, notify }, id, ), ) @@ -221,6 +224,12 @@ function close() { {{ t('tripLog.places.within', { distance: radiusText(place.radiusMeters) }) }} + @@ -265,6 +274,14 @@ function close() { :step="COMMUTE_RADIUS.step" />

{{ t('tripLog.places.radiusHint') }}

+ +