From b12337ce2c1e62529398f7b5da33aecf28e67fce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:39:25 +0000 Subject: [PATCH 1/4] feat(notifications): tell when the car arrives at or leaves home or work A home or work place with Notify switched on (off until asked for) sends place-arrival when the car parks there, judged by the position of the poll it parked in, and place-departure on the first position outside its radius after the engine started there: warming the car up, or a drive that never leaves, sends nothing. Both are types each device can switch off, in English and Dutch. The AddCommutePlaceNotify migration adds the column. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- README.md | 5 +- frontend/src/assets/tripLog.css | 6 + .../src/components/CommutePlacesModal.vue | 21 +- frontend/src/components/NotificationPanel.vue | 2 + .../__tests__/CommutePlacesModal.spec.ts | 37 +- .../components/__tests__/TripLogItem.spec.ts | 1 + .../settings/NotificationTypeSettings.vue | 2 + frontend/src/locales/en.json | 7 +- frontend/src/locales/nl.json | 7 +- frontend/src/services/commutePlacesApi.ts | 2 + .../stores/__tests__/commutePlaces.spec.ts | 1 + frontend/src/utils/notificationCategories.ts | 2 + .../Endpoints/CommutePlaceEndpoints.cs | 11 +- src/GarageStack.Core/Models/CommutePlace.cs | 6 + .../Models/NotificationCategories.cs | 5 +- ...19193219_AddCommutePlaceNotify.Designer.cs | 1180 +++++++++++++++++ .../20261019193219_AddCommutePlaceNotify.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../AuthenticationFlowTests.cs | 12 +- .../TelemetryHandlerTests.cs | 155 +++ src/GarageStack.Worker/Mqtt/PlaceAlerts.cs | 106 ++ .../Mqtt/TelemetryHandler.cs | 36 +- .../Resources/NotificationStrings.nl.resx | 24 + .../Resources/NotificationStrings.resx | 24 + 24 files changed, 1670 insertions(+), 14 deletions(-) create mode 100644 src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.Designer.cs create mode 100644 src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.cs create mode 100644 src/GarageStack.Worker/Mqtt/PlaceAlerts.cs diff --git a/README.md b/README.md index 181ab468..51a9fdf1 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **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. +- **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. - **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. @@ -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,6 +410,7 @@ 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. - **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. diff --git a/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index acb893f6..e1073dfe 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 { 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') }}

+ + Notify: whether reaching or leaving the place sends a notification; left out, it stays as it was. +public record CommutePlaceRequest( + CommutePlaceKind? Kind, string? Label, double? Latitude, double? Longitude, int? RadiusMeters, bool? Notify = null); -public record CommutePlaceDto(int Id, CommutePlaceKind Kind, string? Label, double Latitude, double Longitude, int RadiusMeters); +public record CommutePlaceDto( + int Id, CommutePlaceKind Kind, string? Label, double Latitude, double Longitude, int RadiusMeters, bool Notify); /// The place as saved (null once removed), and how many trips the change marked and unmarked as commutes. public record CommutePlaceChange(CommutePlaceDto? Place, int Marked, int Cleared); diff --git a/src/GarageStack.Core/Models/CommutePlace.cs b/src/GarageStack.Core/Models/CommutePlace.cs index 730135f4..5bb11a09 100644 --- a/src/GarageStack.Core/Models/CommutePlace.cs +++ b/src/GarageStack.Core/Models/CommutePlace.cs @@ -27,6 +27,12 @@ public class CommutePlace /// public int RadiusMeters { get; set; } = CommutePlaceLimits.DefaultRadiusMeters; + /// + /// Whether the car reaching or leaving this place sends a notification. Off until asked for: + /// home and work are set up for the trip log, and a push on every commute is not what that asks. + /// + public bool Notify { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/GarageStack.Core/Models/NotificationCategories.cs b/src/GarageStack.Core/Models/NotificationCategories.cs index eac0da79..b4038bf7 100644 --- a/src/GarageStack.Core/Models/NotificationCategories.cs +++ b/src/GarageStack.Core/Models/NotificationCategories.cs @@ -33,6 +33,9 @@ public static class NotificationCategories public const string GatewayLogin = "gateway-login"; // The car has not reported for longer than it should. public const string DataStale = "data-stale"; + // The car parked at, or drove away from, a home or work place that asks for it (CommutePlace.Notify). + public const string PlaceArrival = "place-arrival"; + public const string PlaceDeparture = "place-departure"; // What the maintenance categories below are grouped as wherever the user picks types. public const string Maintenance = "maintenance"; @@ -66,7 +69,7 @@ public static string RdwApkExpired(DateOnly expiresOn) => { LowTyre, HighTyre, SlowPuncture, LowEv, ChargingComplete, ChargingInterrupted, EngineStart, UnlockedParked, DoorsOpenParked, WindowsOpenParked, MovedParked, VehicleMessage, ClimateSchedule, Low12V, LowFuel, GatewayLogin, DataStale, - Maintenance, Rdw, + PlaceArrival, PlaceDeparture, Maintenance, Rdw, }; /// diff --git a/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.Designer.cs b/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.Designer.cs new file mode 100644 index 00000000..8c1f563e --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.Designer.cs @@ -0,0 +1,1180 @@ +// +using System; +using System.Collections.Generic; +using GarageStack.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261019193219_AddCommutePlaceNotify")] + partial class AddCommutePlaceNotify + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GarageStack.Core.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.AppNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted", "CreatedAt"); + + b.ToTable("AppNotifications"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.ClimateSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Days") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastRunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastRunDetail") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("LastRunFailedCommand") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("LastRunOutcome") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NextRunUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OnlyAboveC") + .HasColumnType("double precision"); + + b.Property("OnlyBelowC") + .HasColumnType("double precision"); + + b.Property("RearDefroster") + .HasColumnType("boolean"); + + b.Property("SeatLeftLevel") + .HasColumnType("integer"); + + b.Property("SeatRightLevel") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("TemperatureC") + .HasColumnType("integer"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("ClimateSchedules"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.CommutePlace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Label") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Notify") + .HasColumnType("boolean"); + + b.Property("RadiusMeters") + .HasColumnType("integer"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("CommutePlaces"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.GeocodeCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CountryCode") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DisplayName") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HouseNumber") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Postcode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Precision") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Road") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_GeocodeCacheEntries_ExpiresAt"); + + b.HasIndex("Precision", "Language", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_GeocodeCacheEntries_Precision_Language_CellLatLng"); + + b.ToTable("GeocodeCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntervalKm") + .HasColumnType("double precision"); + + b.Property("IntervalMonths") + .HasColumnType("integer"); + + b.Property("LastServiceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastServiceOdometerKm") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("MaintenanceItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaintenanceItemId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("PerformedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MaintenanceItemId", "PerformedAt"); + + b.ToTable("MaintenanceLogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MapMatchCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MatchedKm") + .HasColumnType("double precision"); + + b.Property("PointIndexesJson") + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Shape") + .HasColumnType("text"); + + b.Property("SpeedLimitRunsJson") + .HasColumnType("text"); + + b.Property("TraceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_MapMatchCacheEntries_ExpiresAt"); + + b.HasIndex("Provider", "TraceHash") + .IsUnique() + .HasDatabaseName("IX_MapMatchCacheEntries_Provider_TraceHash"); + + b.ToTable("MapMatchCacheEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiCacheTile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CachedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_PoiCacheTiles_ExpiresAt"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .IsUnique() + .HasDatabaseName("IX_PoiCacheTiles_Source_PoiType_CellLatLng"); + + b.ToTable("PoiCacheTiles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PoiItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CellLat") + .HasColumnType("integer"); + + b.Property("CellLng") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MetaJson") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PoiType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Source", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_PoiItems_Source_ExternalId"); + + b.HasIndex("Source", "PoiType", "Brand") + .HasDatabaseName("IX_PoiItems_Source_PoiType_Brand"); + + b.HasIndex("Source", "PoiType", "CellLat", "CellLng") + .HasDatabaseName("IX_PoiItems_Source_PoiType_CellLatLng"); + + b.HasIndex("Source", "PoiType", "Latitude", "Longitude") + .HasDatabaseName("IX_PoiItems_Source_PoiType_LatLon"); + + b.ToTable("PoiItems"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("ExcludedCategories") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("P256DhKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApkExpiresOn") + .HasColumnType("date"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Found") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Make") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("OpenRecallIndicator") + .HasColumnType("boolean"); + + b.Property("RecallsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.Property("WamInsured") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId") + .IsUnique(); + + b.ToTable("RdwVehicleInfos"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RevokedToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Jti") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Jti") + .IsUnique(); + + b.ToTable("RevokedTokens"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatteryHeating") + .HasColumnType("boolean"); + + b.Property("BatteryHeatingScheduleMode") + .HasColumnType("text"); + + b.Property("BatteryHeatingScheduleStartTime") + .HasColumnType("text"); + + b.Property("BatteryVoltage") + .HasColumnType("double precision"); + + b.Property("BmsChargeStatus") + .HasColumnType("text"); + + b.Property("BonnetOpen") + .HasColumnType("boolean"); + + b.Property("ChargeCurrentLimit") + .HasColumnType("text"); + + b.Property("ChargerConnected") + .HasColumnType("boolean"); + + b.Property("ChargingCableLock") + .HasColumnType("boolean"); + + b.Property("ChargingLastEndAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChargingLastStartAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChargingScheduleEndTime") + .HasColumnType("text"); + + b.Property("ChargingScheduleMode") + .HasColumnType("text"); + + b.Property("ChargingScheduleStartTime") + .HasColumnType("text"); + + b.Property("ChargingStopReason") + .HasColumnType("text"); + + b.Property("ChargingType") + .HasColumnType("text"); + + b.Property("ClimateMode") + .HasColumnType("text"); + + b.Property("ClimateOn") + .HasColumnType("boolean"); + + b.Property("CurrentJourneyDistance") + .HasColumnType("double precision"); + + b.Property("DriverDoorOpen") + .HasColumnType("boolean"); + + b.Property("DriverWindowOpen") + .HasColumnType("boolean"); + + b.Property("ElectricRangeKm") + .HasColumnType("double precision"); + + b.Property("Elevation") + .HasColumnType("double precision"); + + b.Property("EngineRunning") + .HasColumnType("boolean"); + + b.Property("EvSocPercent") + .HasColumnType("double precision"); + + b.Property("ExteriorTemperature") + .HasColumnType("double precision"); + + b.Property("FuelLevelPercent") + .HasColumnType("double precision"); + + b.Property("FuelRangeKm") + .HasColumnType("double precision"); + + b.Property("Heading") + .HasColumnType("double precision"); + + b.Property("HeatedSeatFrontLeft") + .HasColumnType("integer"); + + b.Property("HeatedSeatFrontRight") + .HasColumnType("integer"); + + b.Property("HvBatteryActive") + .HasColumnType("boolean"); + + b.Property("HvCurrent") + .HasColumnType("double precision"); + + b.Property("HvPower") + .HasColumnType("double precision"); + + b.Property("HvSocKwh") + .HasColumnType("double precision"); + + b.Property("HvTotalCapacityKwh") + .HasColumnType("double precision"); + + b.Property("HvVoltage") + .HasColumnType("double precision"); + + b.Property("InteriorTemperature") + .HasColumnType("double precision"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsLocked") + .HasColumnType("boolean"); + + b.Property("LastChargeEndingPower") + .HasColumnType("double precision"); + + b.Property("LastChargeStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVehicleStateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("LightsDippedBeam") + .HasColumnType("boolean"); + + b.Property("LightsMainBeam") + .HasColumnType("boolean"); + + b.Property("LightsSide") + .HasColumnType("boolean"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MileageOfTheDay") + .HasColumnType("double precision"); + + b.Property("MileageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("ObcCurrent") + .HasColumnType("double precision"); + + b.Property("ObcPowerSinglePhase") + .HasColumnType("double precision"); + + b.Property("ObcPowerThreePhase") + .HasColumnType("double precision"); + + b.Property("ObcVoltage") + .HasColumnType("double precision"); + + b.Property("OdometerKm") + .HasColumnType("double precision"); + + b.Property("OffboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("OnboardChargerPlugStatus") + .HasColumnType("integer"); + + b.Property("PassengerDoorOpen") + .HasColumnType("boolean"); + + b.Property("PassengerWindowOpen") + .HasColumnType("boolean"); + + b.Property("PowerUsageOfDay") + .HasColumnType("double precision"); + + b.Property("PowerUsageSinceLastCharge") + .HasColumnType("double precision"); + + b.Property("RearLeftDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearLeftWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearRightDoorOpen") + .HasColumnType("boolean"); + + b.Property("RearRightWindowOpen") + .HasColumnType("boolean"); + + b.Property("RearWindowDefroster") + .HasColumnType("boolean"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemainingChargingTime") + .HasColumnType("integer"); + + b.Property("RemoteTemperature") + .HasColumnType("double precision"); + + b.Property("SocTargetPercent") + .HasColumnType("integer"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("SunRoofOpen") + .HasColumnType("boolean"); + + b.Property("TrunkOpen") + .HasColumnType("boolean"); + + b.Property("TyrePressureFrontLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureFrontRight") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearLeft") + .HasColumnType("double precision"); + + b.Property("TyrePressureRearRight") + .HasColumnType("double precision"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "RecordedAt"); + + b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Gps") + .HasFilter("\"Latitude\" IS NOT NULL AND \"Longitude\" IS NOT NULL"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Gps"), new[] { "Latitude", "Longitude", "Speed", "Heading" }); + + b.HasIndex(new[] { "VehicleId", "RecordedAt" }, "IX_TelemetrySnapshots_VehicleId_RecordedAt_Schedule") + .HasFilter("\"ChargingScheduleMode\" IS NOT NULL OR \"ChargingScheduleStartTime\" IS NOT NULL OR \"ChargingScheduleEndTime\" IS NOT NULL OR \"BatteryHeatingScheduleMode\" IS NOT NULL OR \"BatteryHeatingScheduleStartTime\" IS NOT NULL"); + + b.ToTable("TelemetrySnapshots"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvgMovingSpeedKmh") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistanceKm") + .HasColumnType("double precision"); + + b.Property("EndLatitude") + .HasColumnType("double precision"); + + b.Property("EndLongitude") + .HasColumnType("double precision"); + + b.Property("EndPlaceJson") + .HasColumnType("text"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxSpeedKmh") + .HasColumnType("double precision"); + + b.Property("MovingSpeedSamples") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OdometerEndKm") + .HasColumnType("double precision"); + + b.Property("OdometerStartKm") + .HasColumnType("double precision"); + + b.Property("PointCount") + .HasColumnType("integer"); + + b.Property("PointsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("Purpose") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("PurposeIsManual") + .HasColumnType("boolean"); + + b.Property("StartLatitude") + .HasColumnType("double precision"); + + b.Property("StartLongitude") + .HasColumnType("double precision"); + + b.Property("StartPlaceJson") + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId", "StartedAt") + .IsUnique(); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountKey", "Section") + .IsUnique(); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigJson") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastMessageId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastParkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LicensePlate") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("ParkedLatitude") + .HasColumnType("double precision"); + + b.Property("ParkedLongitude") + .HasColumnType("double precision"); + + b.Property("SaicUser") + .HasColumnType("text"); + + b.Property("Series") + .HasColumnType("text"); + + b.Property("TelemetryCompactedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("TripsRecordedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Vin") + .IsRequired() + .HasMaxLength(17) + .HasColumnType("character varying(17)"); + + b.HasKey("Id"); + + b.HasIndex("Vin") + .IsUnique(); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.ClimateSchedule", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.CommutePlace", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceLogEntry", b => + { + b.HasOne("GarageStack.Core.Models.MaintenanceItem", "MaintenanceItem") + .WithMany("LogEntries") + .HasForeignKey("MaintenanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MaintenanceItem"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.RdwVehicleInfo", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.TelemetrySnapshot", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany("TelemetrySnapshots") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Trip", b => + { + b.HasOne("GarageStack.Core.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.MaintenanceItem", b => + { + b.Navigation("LogEntries"); + }); + + modelBuilder.Entity("GarageStack.Core.Models.Vehicle", b => + { + b.Navigation("TelemetrySnapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.cs b/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.cs new file mode 100644 index 00000000..c2b7e4a7 --- /dev/null +++ b/src/GarageStack.Data/Migrations/20261019193219_AddCommutePlaceNotify.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GarageStack.Data.Migrations +{ + /// + public partial class AddCommutePlaceNotify : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notify", + table: "CommutePlaces", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notify", + table: "CommutePlaces"); + } + } +} diff --git a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs index 3995994e..0aff1108 100644 --- a/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/GarageStack.Data/Migrations/AppDbContextModelSnapshot.cs @@ -217,6 +217,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Longitude") .HasColumnType("double precision"); + b.Property("Notify") + .HasColumnType("boolean"); + b.Property("RadiusMeters") .HasColumnType("integer"); diff --git a/src/GarageStack.Tests/AuthenticationFlowTests.cs b/src/GarageStack.Tests/AuthenticationFlowTests.cs index 5b4b0ea3..0cc26562 100644 --- a/src/GarageStack.Tests/AuthenticationFlowTests.cs +++ b/src/GarageStack.Tests/AuthenticationFlowTests.cs @@ -361,7 +361,17 @@ async Task EntryAsync(long id) => Assert.True(work!.Marked >= 1); Assert.Equal(HttpStatusCode.BadRequest, secondHome.StatusCode); Assert.Equal("commutePlace.homeExists", await ProblemCodeAsync(secondHome, ct)); - Assert.Equal(new CommutePlaceDto(work.Place!.Id, CommutePlaceKind.Work, "Office", open.EndLatitude, open.EndLongitude, 300), work.Place); + Assert.Equal(new CommutePlaceDto(work.Place!.Id, CommutePlaceKind.Work, "Office", open.EndLatitude, open.EndLongitude, 300, false), work.Place); + + // A place notifies only once asked to, and a change that does not mention it keeps it. + var notifying = await (await client.PutAsJsonAsync($"{placesUrl}/{work.Place.Id}", + new { kind = "work", label = "Office", latitude = open.EndLatitude, longitude = open.EndLongitude, radiusMeters = 300, notify = true }, ct)) + .Content.ReadFromJsonAsync(ct); + var moved = await (await client.PutAsJsonAsync($"{placesUrl}/{work.Place.Id}", + new { kind = "work", label = "Office", latitude = open.EndLatitude, longitude = open.EndLongitude, radiusMeters = 350 }, ct)) + .Content.ReadFromJsonAsync(ct); + Assert.True(notifying!.Place!.Notify); + Assert.True(moved!.Place!.Notify); var labelled = await EntryAsync(open.Id); Assert.Equal(TripPurpose.Commute, labelled.Purpose); Assert.True(labelled.PurposeIsAutomatic); diff --git a/src/GarageStack.Tests/TelemetryHandlerTests.cs b/src/GarageStack.Tests/TelemetryHandlerTests.cs index 0b0c3467..e005c7a9 100644 --- a/src/GarageStack.Tests/TelemetryHandlerTests.cs +++ b/src/GarageStack.Tests/TelemetryHandlerTests.cs @@ -240,6 +240,7 @@ public async Task EngineStop_OverMqtt_RecordsWhenAndWhereTheCarParked() .AddScoped() .AddScoped(sp => new TelemetryRepository(sp.GetRequiredService())) .AddScoped() + .AddScoped() .BuildServiceProvider(); var handler = new TelemetryHandler( NullLogger.Instance, @@ -275,6 +276,7 @@ public async Task EngineStop_ThenThatPollsFix_ParksTheCarAtTheFix() .AddScoped() .AddScoped(sp => new TelemetryRepository(sp.GetRequiredService())) .AddScoped() + .AddScoped() .BuildServiceProvider(); var handler = new TelemetryHandler( NullLogger.Instance, @@ -353,3 +355,156 @@ await handler.TryHandleAsync( Assert.StartsWith("Unreadable payload", warning.Message); } } + +// Reaching and leaving home or work, told for the places that ask for it. +public class PlaceAlertsOverMqttTests +{ + private const string VehicleTopic = "saic/user@example.com/vehicles/FAKEVN00000000001"; + + // Home, and a spot two kilometres away from it. + private const string AtHome = """{"latitude":52.3700,"longitude":4.8900}"""; + private const string NearHome = """{"latitude":52.3710,"longitude":4.8910}"""; + private const string AwayFromHome = """{"latitude":52.3880,"longitude":4.8900}"""; + + private sealed class Rig(ServiceProvider services, TelemetryHandler handler, FakePushSender push) : IAsyncDisposable + { + public FakePushSender Push => push; + + public IEnumerable<(string Title, string? Category)> PlacePushes => + push.Sent.Where(p => p.Category?.StartsWith("place-", StringComparison.Ordinal) == true) + .Select(p => (p.Title, p.Category)); + + public Task SendAsync(string subtopic, string payload, CancellationToken ct) => + handler.TryHandleAsync(new MqttMessage($"{VehicleTopic}/{subtopic}", payload, Retain: false), ct); + + public Task EngineAsync(bool running, CancellationToken ct) => SendAsync("drivetrain/running", running ? "true" : "false", ct); + + public Task PositionAsync(string position, CancellationToken ct) => SendAsync("location/position", position, ct); + + // Parked at home, as the last drive left the car, beside a place set up as asked. + public async Task ParkedAtHomeAsync(CommutePlace place, CancellationToken ct) + { + await EngineAsync(false, ct); + await using var scope = services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var vehicle = await db.Vehicles.SingleAsync(ct); + vehicle.ParkedLatitude = 52.37; + vehicle.ParkedLongitude = 4.89; + place.VehicleId = vehicle.Id; + db.CommutePlaces.Add(place); + await db.SaveChangesAsync(ct); + } + + public ValueTask DisposeAsync() => services.DisposeAsync(); + } + + private static Rig Build() + { + var databaseName = Guid.NewGuid().ToString(); + var services = new ServiceCollection() + .AddDbContext(o => o.UseInMemoryDatabase(databaseName)) + .AddScoped() + .AddScoped(sp => new TelemetryRepository(sp.GetRequiredService())) + .AddScoped() + .AddScoped() + .BuildServiceProvider(); + var push = new FakePushSender(); + var handler = new TelemetryHandler( + NullLogger.Instance, + new VehicleResolver(services.GetRequiredService()), + push, + WorkerLocalizer.Notifications()); + return new Rig(services, handler, push); + } + + private static CommutePlace Home(bool notify = true, string? label = null) => new() + { + Kind = CommutePlaceKind.Home, + Label = label, + Latitude = 52.37, + Longitude = 4.89, + RadiusMeters = 250, + Notify = notify, + }; + + [Fact] + public async Task ADriveFromHomeAndBack_TellsTheLeavingOnceItIsOutside_AndTheArrival() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = Build(); + await rig.ParkedAtHomeAsync(Home(), ct); + + await rig.EngineAsync(true, ct); + await rig.PositionAsync(NearHome, ct); + Assert.Empty(rig.PlacePushes); + + await rig.PositionAsync(AwayFromHome, ct); + await rig.PositionAsync(AwayFromHome, ct); + await rig.EngineAsync(false, ct); + await rig.PositionAsync(AtHome, ct); + + Assert.Equal( + [("Left home", NotificationCategories.PlaceDeparture), ("Arrived home", NotificationCategories.PlaceArrival)], + rig.PlacePushes); + } + + [Fact] + public async Task AnEngineRunThatNeverLeaves_TellsNothing() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = Build(); + await rig.ParkedAtHomeAsync(Home(), ct); + + await rig.EngineAsync(true, ct); + await rig.PositionAsync(NearHome, ct); + await rig.EngineAsync(false, ct); + await rig.PositionAsync(AtHome, ct); + + Assert.Empty(rig.PlacePushes); + } + + [Fact] + public async Task APlaceThatDoesNotAskForIt_TellsNothing() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = Build(); + await rig.ParkedAtHomeAsync(Home(notify: false), ct); + + await rig.EngineAsync(true, ct); + await rig.PositionAsync(AwayFromHome, ct); + await rig.EngineAsync(false, ct); + await rig.PositionAsync(AtHome, ct); + + Assert.Empty(rig.PlacePushes); + } + + [Fact] + public async Task ANamedPlace_IsToldByItsName() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = Build(); + await rig.ParkedAtHomeAsync(Home(label: "Zwolle"), ct); + + await rig.EngineAsync(true, ct); + await rig.PositionAsync(AwayFromHome, ct); + await rig.EngineAsync(false, ct); + await rig.PositionAsync(AtHome, ct); + + Assert.Equal(["Left Zwolle", "Arrived at Zwolle"], rig.PlacePushes.Select(p => p.Title)); + } + + [Fact] + public async Task ParkingElsewhere_TellsNoArrival() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = Build(); + await rig.ParkedAtHomeAsync(Home(), ct); + + await rig.EngineAsync(true, ct); + await rig.PositionAsync(AwayFromHome, ct); + await rig.EngineAsync(false, ct); + await rig.PositionAsync(AwayFromHome, ct); + + Assert.Equal(["Left home"], rig.PlacePushes.Select(p => p.Title)); + } +} diff --git a/src/GarageStack.Worker/Mqtt/PlaceAlerts.cs b/src/GarageStack.Worker/Mqtt/PlaceAlerts.cs new file mode 100644 index 00000000..71a666d9 --- /dev/null +++ b/src/GarageStack.Worker/Mqtt/PlaceAlerts.cs @@ -0,0 +1,106 @@ +using System.Collections.Concurrent; +using GarageStack.Core.Helpers; +using GarageStack.Core.Interfaces; +using GarageStack.Core.Models; +using Microsoft.Extensions.Localization; + +namespace GarageStack.Worker.Mqtt; + +/// +/// Tells when the car leaves or reaches a home or work place that asks for it +/// (). feeds it the engine and the +/// car's positions; this class keeps what one drive has shown so far. +/// +/// Leaving is told on the first position outside the place's radius after the engine started +/// there, not when it started: an engine run to warm the car, or a car moved down the drive, has +/// not left. Reaching is told on the position of the poll the car parked in, which the gateway +/// sends just after the engine stopping (see ), and not for a drive +/// that never left the place it started in. +/// +/// +/// The state is this process's own: a Worker restarted mid-drive tells neither end of that drive, +/// which beats telling one twice. +/// +/// +public sealed class PlaceAlerts( + ILogger logger, + IPushSender pushSender, + IStringLocalizer strings) +{ + // The place the car started in and has not left yet, per vehicle. + private readonly ConcurrentDictionary _leaving = new(); + + // A parking whose own position is still to come, and the place the drive never left, if any. + private readonly ConcurrentDictionary _arriving = new(); + + private sealed record Arrival(DateTime ParkedAt, int? NeverLeftPlaceId); + + /// The engine started: a new drive, from where the car was parked. + public async Task EngineStartedAsync( + int vehicleId, Vehicle? vehicle, ICommutePlaceRepository places, CancellationToken ct) + { + _arriving.TryRemove(vehicleId, out _); + _leaving.TryRemove(vehicleId, out _); + if (vehicle is not { ParkedLatitude: { } latitude, ParkedLongitude: { } longitude }) return; + + if (PlaceAt(await places.ListAsync(vehicleId, ct), latitude, longitude) is { } place) + _leaving[vehicleId] = place; + } + + /// Whether the car started in a place and has not left it yet. + public bool IsLeaving(int vehicleId) => _leaving.ContainsKey(vehicleId); + + /// Where the car is now, while a drive may be leaving a place. + public async Task PositionAsync(int vehicleId, double latitude, double longitude, CancellationToken ct) + { + if (!_leaving.TryGetValue(vehicleId, out var place) || CommuteRule.IsWithin(place, latitude, longitude)) return; + // Two messages of one poll may both see the car outside; only one tells it. + if (!_leaving.TryRemove(vehicleId, out _)) return; + + logger.LogInformation("Vehicle {VehicleId} left commute place {PlaceId}", vehicleId, place.Id); + await pushSender.SendToAllAsync( + Title(place, "PlaceLeftHomeTitle", "PlaceLeftWorkTitle", "PlaceLeftTitle"), + strings["PlaceLeftBody"], ct, NotificationCategories.PlaceDeparture, vehicleId); + } + + /// The engine stopped: the drive is over once its parking position is known. + public void Parked(int vehicleId, DateTime parkedAt) + { + var neverLeft = _leaving.TryRemove(vehicleId, out var place) ? place.Id : (int?)null; + _arriving[vehicleId] = new Arrival(parkedAt, neverLeft); + } + + /// Whether a parking still waits for its position, and when the car parked. + public bool IsAwaitingArrival(int vehicleId, out DateTime parkedAt) + { + var awaiting = _arriving.TryGetValue(vehicleId, out var arrival); + parkedAt = arrival?.ParkedAt ?? default; + return awaiting; + } + + /// Where the car parked: tells the arrival, if it is at a place that asks for it. + public async Task ParkedAtAsync( + int vehicleId, double? latitude, double? longitude, ICommutePlaceRepository places, CancellationToken ct) + { + if (!_arriving.TryRemove(vehicleId, out var arrival)) return; + if (latitude is not { } lat || longitude is not { } lon) return; + if (PlaceAt(await places.ListAsync(vehicleId, ct), lat, lon) is not { } place + || place.Id == arrival.NeverLeftPlaceId) + return; + + logger.LogInformation("Vehicle {VehicleId} arrived at commute place {PlaceId}", vehicleId, place.Id); + await pushSender.SendToAllAsync( + Title(place, "PlaceArrivedHomeTitle", "PlaceArrivedWorkTitle", "PlaceArrivedTitle"), + strings["PlaceArrivedBody"], ct, NotificationCategories.PlaceArrival, vehicleId); + } + + // Home comes first in the list, so where a work place overlaps home, home is where the car is. + private static CommutePlace? PlaceAt(IReadOnlyList places, double latitude, double longitude) => + places.FirstOrDefault(p => p.Notify && CommuteRule.IsWithin(p, latitude, longitude)); + + // A place the user named is told by its name; otherwise by its kind, in the language's own words. + private string Title(CommutePlace place, string homeKey, string workKey, string namedKey) => + place.Label is { } label + ? strings[namedKey, label] + : strings[place.Kind == CommutePlaceKind.Home ? homeKey : workKey]; +} diff --git a/src/GarageStack.Worker/Mqtt/TelemetryHandler.cs b/src/GarageStack.Worker/Mqtt/TelemetryHandler.cs index a5a7bd70..cd864d8a 100644 --- a/src/GarageStack.Worker/Mqtt/TelemetryHandler.cs +++ b/src/GarageStack.Worker/Mqtt/TelemetryHandler.cs @@ -53,6 +53,9 @@ public sealed class TelemetryHandler( // of metres back, which the moved-while-parked check then took for a move. private readonly ConcurrentDictionary _awaitingParkedFix = new(); + // Arriving at and leaving home or work, for the places that ask for it. + private readonly PlaceAlerts _placeAlerts = new(logger, pushSender, strings); + // Topics the gateway publishes that are left out on purpose, so they are no news either: // hybrid_electrical_range repeats drivetrain/range (see TelemetryMapper), nothing reads why // battery heating stopped, and chargingDataAvailable only goes offline after charge status @@ -108,14 +111,24 @@ public async Task TryHandleAsync(MqttMessage message, CancellationToken ct var telemetryRepo = resolved.Services.GetRequiredService(); var db = resolved.Services.GetRequiredService(); var notifications = resolved.Services.GetRequiredService(); + var places = resolved.Services.GetRequiredService(); patch.VehicleId = vehicleId; patch.RecordedAt = DateTime.UtcNow; await MergeOrAddTelemetryAsync(vehicleId, patch, telemetryRepo, ct); - await ParkAtThisPollsFixAsync(vehicleId, patch, telemetryRepo, resolved.Vehicles, ct); + await ParkAtThisPollsFixAsync(vehicleId, patch, telemetryRepo, resolved.Vehicles, places, ct); + await ArriveWithoutAFixAsync(vin, vehicleId, patch.RecordedAt, resolved.Vehicles, places, ct); + + var transition = await ObserveEngineAsync(vin, patch, notifications, ct); + if (transition == StateTransition.TurnedOn && vehicleId > 0) + await _placeAlerts.EngineStartedAsync(vehicleId, await resolved.Vehicles.GetByVinAsync(vin, ct), places, ct); + + if (patch is not { Latitude: null, Longitude: null } && _placeAlerts.IsLeaving(vehicleId) + && await telemetryRepo.GetMergedLatestAsync(vehicleId, ct) is { Latitude: { } lat, Longitude: { } lon }) + await _placeAlerts.PositionAsync(vehicleId, lat, lon, ct); - if (await ObserveEngineAsync(vin, patch, notifications, ct) == StateTransition.TurnedOff && vehicleId > 0) + if (transition == StateTransition.TurnedOff && vehicleId > 0) { // The engine stopping ends the trip and parks the car: the Api refreshes the trip // list, and the push checks give the driver a grace period before warning about an @@ -123,6 +136,7 @@ public async Task TryHandleAsync(MqttMessage message, CancellationToken ct var parkedAt = await telemetryRepo.GetMergedLatestAsync(vehicleId, ct); await resolved.Vehicles.SetParkedAsync(vehicleId, patch.RecordedAt, parkedAt?.Latitude, parkedAt?.Longitude, ct); _awaitingParkedFix[vehicleId] = patch.RecordedAt; + _placeAlerts.Parked(vehicleId, patch.RecordedAt); await db.Database.NotifyAsync(PgChannels.TripCompleted, vehicleId.ToString(), ct); logger.LogInformation("Trip completed for vehicleId={VehicleId} - notifying SignalR clients", vehicleId); } @@ -142,7 +156,8 @@ public async Task TryHandleAsync(MqttMessage message, CancellationToken ct /// fixes are the car reporting from its spot, which the moved-while-parked check compares. /// internal async Task ParkAtThisPollsFixAsync( - int vehicleId, TelemetrySnapshot patch, ITelemetryRepository telemetryRepo, IVehicleRepository vehicleRepo, CancellationToken ct) + int vehicleId, TelemetrySnapshot patch, ITelemetryRepository telemetryRepo, IVehicleRepository vehicleRepo, + ICommutePlaceRepository places, CancellationToken ct) { if (patch is { Latitude: null, Longitude: null }) return; if (!_awaitingParkedFix.TryGetValue(vehicleId, out var parkedAt)) return; @@ -154,6 +169,21 @@ internal async Task ParkAtThisPollsFixAsync( var merged = await telemetryRepo.GetMergedLatestAsync(vehicleId, ct); await vehicleRepo.SetParkedAsync(vehicleId, parkedAt, merged?.Latitude, merged?.Longitude, ct); + await _placeAlerts.ParkedAtAsync(vehicleId, merged?.Latitude, merged?.Longitude, places, ct); + } + + /// + /// A parking whose poll sent no position within (a garage without GPS, + /// a gateway that skipped it): the arrival is told from where the car was taken to have parked, + /// the position the engine stopping found. + /// + private async Task ArriveWithoutAFixAsync( + string vin, int vehicleId, DateTime now, IVehicleRepository vehicleRepo, ICommutePlaceRepository places, CancellationToken ct) + { + if (!_placeAlerts.IsAwaitingArrival(vehicleId, out var parkedAt) || now - parkedAt <= MergeWindow) return; + + var vehicle = await vehicleRepo.GetByVinAsync(vin, ct); + await _placeAlerts.ParkedAtAsync(vehicleId, vehicle?.ParkedLatitude, vehicle?.ParkedLongitude, places, ct); } // Serializes read-await-write access to _mergeState per vehicle so two concurrently diff --git a/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx b/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx index 722e2aff..30c85d09 100644 --- a/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx +++ b/src/GarageStack.Worker/Resources/NotificationStrings.nl.resx @@ -66,6 +66,30 @@ Je auto staat {0} km van waar hij geparkeerd werd, met de motor uit. + + Thuis aangekomen + + + Aangekomen op het werk + + + Aangekomen bij {0} + + + Je auto staat daar geparkeerd. + + + Van huis vertrokken + + + Van het werk vertrokken + + + Vertrokken van {0} + + + Je auto is weggereden. + Linksvoor diff --git a/src/GarageStack.Worker/Resources/NotificationStrings.resx b/src/GarageStack.Worker/Resources/NotificationStrings.resx index ea363956..3f20bc45 100644 --- a/src/GarageStack.Worker/Resources/NotificationStrings.resx +++ b/src/GarageStack.Worker/Resources/NotificationStrings.resx @@ -66,6 +66,30 @@ Your car is {0} km from where it was parked, with the engine off. + + Arrived home + + + Arrived at work + + + Arrived at {0} + + + Your car has parked there. + + + Left home + + + Left work + + + Left {0} + + + Your car has driven away. + Front left From 4eb9c2afb33ce0a7cebbaea6f6c9180f79fe6f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:49:44 +0000 Subject: [PATCH 2/4] feat(trip-log): hide a trip, and join two trips a short stop split A hidden trip (Trip.IsHidden, AddTripIsHidden migration) leaves the map, the statistics, the dashboard and the trip log's totals and export, and stays listed under Show hidden trips to show again; nothing is deleted. Join with next trip (two taps) makes a trip and the saved trip after it one, from the first one's start to the second one's end, with the second removed in the same transaction. A purpose someone chose is kept; otherwise it is labelled again from the new ends, so home to a fuel station to work becomes a commute. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6DtW2eHhipteRopmy3cif --- README.md | 6 +- documentation/ARCHITECTURE.md | 1 + frontend/src/assets/tripLog.css | 20 + frontend/src/components/TripLogItem.vue | 44 +- .../components/__tests__/TripLogItem.spec.ts | 41 + frontend/src/locales/en.json | 15 +- frontend/src/locales/nl.json | 15 +- frontend/src/plugins/icons.ts | 3 + .../src/services/__tests__/tripLogFixtures.ts | 1 + frontend/src/services/tripLogApi.ts | 7 + frontend/src/stores/__tests__/tripLog.spec.ts | 61 + frontend/src/stores/tripLog.ts | 75 +- frontend/src/views/TripLogView.vue | 33 +- .../Endpoints/TripEndpoints.cs | 40 + .../Interfaces/ITripRepository.cs | 15 + src/GarageStack.Core/Models/Trip.cs | 7 + src/GarageStack.Core/Models/TripLog.cs | 22 +- .../Demo/DemoTripRepository.cs | 56 +- ...20261019194144_AddTripIsHidden.Designer.cs | 1183 +++++++++++++++++ .../20261019194144_AddTripIsHidden.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../Repositories/TripRepository.cs | 83 +- .../AuthenticationFlowTests.cs | 39 + src/GarageStack.Tests/TripRepositoryTests.cs | 144 ++ 24 files changed, 1904 insertions(+), 39 deletions(-) create mode 100644 src/GarageStack.Data/Migrations/20261019194144_AddTripIsHidden.Designer.cs create mode 100644 src/GarageStack.Data/Migrations/20261019194144_AddTripIsHidden.cs diff --git a/README.md b/README.md index 51a9fdf1..dda9f956 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ 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. @@ -412,10 +412,12 @@ Note: "keys left in the car" is not currently supported because the SAIC MQTT ga - **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. diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index e1fd9728..6862d08c 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -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/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index e1073dfe..6efd63d3 100644 --- a/frontend/src/assets/tripLog.css +++ b/frontend/src/assets/tripLog.css @@ -111,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; @@ -197,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/TripLogItem.vue b/frontend/src/components/TripLogItem.vue index 95df5b5a..59c2186d 100644 --- a/frontend/src/components/TripLogItem.vue +++ b/frontend/src/components/TripLogItem.vue @@ -13,6 +13,7 @@ import { endLabel, loggedDistanceKm, odometerDistanceKm, PURPOSE_ICONS } from '@ import { COMMUTE_PLACE_ICONS, commutePlaceAt } from '@/utils/commutePlaces' import type { CommutePlace } from '@/services/commutePlacesApi' import { useUnits } from '@/composables/useUnits' +import { useTwoTapConfirm } from '@/composables/useTwoTapConfirm' import ChipGroup from './ChipGroup.vue' import type { ChipOption } from '@/utils/chips' @@ -25,12 +26,17 @@ const props = defineProps<{ saving: boolean /** Home and work, to mark the ends that are at one of them. */ commutePlaces: readonly CommutePlace[] + /** A saved trip follows this one, so it can be joined with it. */ + canJoinNext: boolean }>() const emit = defineEmits<{ (e: 'save', purpose: TripPurpose | null, notes: string | null): void /** One of the trip's ends, to set as home or work. */ (e: 'pickPlace', latitude: number, longitude: number): void + (e: 'setHidden', hidden: boolean): void + /** Join with the trip after it; confirmed with a second tap, since it cannot be undone. */ + (e: 'joinNext'): void }>() const { t } = useI18n() @@ -101,6 +107,12 @@ function choosePurpose(purpose: TripPurpose | null) { emit('save', purpose, props.entry.notes) } +const joinConfirm = useTwoTapConfirm() + +function joinNext() { + if (joinConfirm.tap(true)) emit('joinNext') +} + function saveNotes() { const notes = normalizedNotes() if (notes === props.entry.notes) return @@ -109,7 +121,10 @@ function saveNotes() { diff --git a/frontend/src/components/__tests__/TripLogItem.spec.ts b/frontend/src/components/__tests__/TripLogItem.spec.ts index 47f066da..0602a3d2 100644 --- a/frontend/src/components/__tests__/TripLogItem.spec.ts +++ b/frontend/src/components/__tests__/TripLogItem.spec.ts @@ -23,6 +23,7 @@ function mountItem( placesResolving: boolean saving: boolean commutePlaces: CommutePlace[] + canJoinNext: boolean }> = {}, ) { return mount(TripLogItem, { @@ -32,6 +33,7 @@ function mountItem( placesResolving: false, saving: false, commutePlaces: [], + canJoinNext: true, ...props, }, global: { plugins: [i18n], stubs: { FontAwesomeIcon: true } }, @@ -165,6 +167,45 @@ describe('TripLogItem', () => { expect(wrapper.emitted('pickPlace')).toEqual([[52.2554, 6.1639]]) }) + it('hides a trip, and shows a hidden one again', async () => { + const shown = mountItem(logEntry()) + const hidden = mountItem(logEntry({ isHidden: true })) + + await shown + .findAll('button') + .find((b) => b.text() === 'Hide')! + .trigger('click') + await hidden + .findAll('button') + .find((b) => b.text() === 'Show again')! + .trigger('click') + + expect(shown.emitted('setHidden')).toEqual([[true]]) + expect(hidden.emitted('setHidden')).toEqual([[false]]) + expect(hidden.classes()).toContain('trip-log-item--hidden') + }) + + it('joins with the next trip only on a second tap', async () => { + const wrapper = mountItem(logEntry()) + const join = () => + wrapper + .findAll('button') + .find((b) => b.text().startsWith('Join') || b.text().startsWith('Tap'))! + + await join().trigger('click') + expect(wrapper.emitted('joinNext')).toBeUndefined() + expect(join().text()).toBe('Tap again to join') + + await join().trigger('click') + expect(wrapper.emitted('joinNext')).toHaveLength(1) + }) + + it('offers no join for the newest trip', () => { + const wrapper = mountItem(logEntry(), { canJoinNext: false }) + + expect(wrapper.findAll('button').some((b) => b.text() === 'Join with next trip')).toBe(false) + }) + it('says when a commute was marked automatically', () => { const automatic = mountItem(logEntry({ purpose: 'commute', purposeIsAutomatic: true })) const chosen = mountItem(logEntry({ purpose: 'commute' })) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 5266c439..6fa918ae 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -723,7 +723,6 @@ "placesOff": "Place names are switched off, so addresses show as coordinates. Switch them on under Settings > Map.", "export": "Export CSV", "exportWhileResolving": "Some addresses are still being looked up; those export as coordinates", - "saveFailed": "Your change could not be saved. Try again.", "empty": "No saved trips in this period.", "csv": { "date": "Date", @@ -774,7 +773,14 @@ "automatic": "Automatic", "automaticHint": "Marked as commute because the trip runs between home and work. Choose another purpose to override it.", "useEnd": "Set {place} as home or work" - } + }, + "hide": "Hide", + "show": "Show again", + "hideHint": "Leave this trip out of the map, the statistics, the dashboard and these totals, for a positioning glitch. It stays listed under the hidden trips.", + "joinNext": "Join with next trip", + "joinNextConfirm": "Tap again to join", + "joinNextHint": "Joins this trip with the one after it, for a drive a short stop such as a fuel station split in two. Cannot be undone.", + "showHidden": "Show hidden trip | Show {n} hidden trips" }, "maintenance": { "title": "Maintenance", @@ -932,7 +938,10 @@ }, "trip": { "notesTooLong": "The notes can be at most 500 characters.", - "tooManyIds": "Too many trips at once. Choose a shorter period." + "tooManyIds": "Too many trips at once. Choose a shorter period.", + "hiddenRequired": "Say whether the trip is hidden.", + "noNextTrip": "There is no saved trip after this one to join it with yet.", + "unreadable": "One of the two trips cannot be read, so they cannot be joined." }, "push": { "notConfigured": "Push notifications are not set up on this server.", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 8777530c..e4a220a1 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -723,7 +723,6 @@ "placesOff": "Plaatsnamen staan uit, dus adressen worden als coördinaten getoond. Zet ze aan onder Instellingen > Kaart.", "export": "CSV exporteren", "exportWhileResolving": "Sommige adressen worden nog opgezocht; die worden als coördinaten geëxporteerd", - "saveFailed": "Je wijziging kon niet worden opgeslagen. Probeer het opnieuw.", "empty": "Geen opgeslagen ritten in deze periode.", "csv": { "date": "Datum", @@ -774,7 +773,14 @@ "automatic": "Automatisch", "automaticHint": "Als woon-werk gemarkeerd omdat de rit tussen thuis en werk loopt. Kies een ander doel om dit te wijzigen.", "useEnd": "{place} instellen als thuis of werk" - } + }, + "hide": "Verbergen", + "show": "Weer tonen", + "hideHint": "Laat deze rit weg uit de kaart, de statistieken, het dashboard en deze totalen, bij een fout in de positie. Hij blijft staan bij de verborgen ritten.", + "joinNext": "Samenvoegen met volgende rit", + "joinNextConfirm": "Tik nogmaals om samen te voegen", + "joinNextHint": "Voegt deze rit samen met de rit erna, voor een rit die door een korte stop, zoals bij een tankstation, in tweeën is gesplitst. Kan niet ongedaan worden gemaakt.", + "showHidden": "Verborgen rit tonen | {n} verborgen ritten tonen" }, "maintenance": { "title": "Onderhoud", @@ -932,7 +938,10 @@ }, "trip": { "notesTooLong": "De notities mogen hoogstens 500 tekens lang zijn.", - "tooManyIds": "Te veel ritten tegelijk. Kies een kortere periode." + "tooManyIds": "Te veel ritten tegelijk. Kies een kortere periode.", + "hiddenRequired": "Geef aan of de rit verborgen is.", + "noNextTrip": "Er is nog geen opgeslagen rit na deze om hem mee samen te voegen.", + "unreadable": "Een van de twee ritten kan niet worden gelezen, dus ze kunnen niet worden samengevoegd." }, "push": { "notConfigured": "Pushmeldingen zijn op deze server niet ingesteld.", diff --git a/frontend/src/plugins/icons.ts b/frontend/src/plugins/icons.ts index ef304a4e..e0ed4958 100644 --- a/frontend/src/plugins/icons.ts +++ b/frontend/src/plugins/icons.ts @@ -44,6 +44,8 @@ export { faDiamondTurnRight, faDoorOpen, faDownload, + faEye, + faEyeSlash, faFan, faFileCsv, faFileLines, @@ -59,6 +61,7 @@ export { faLayerGroup, faLeaf, faLightbulb, + faLink, faLocationArrow, faLocationDot, faLock, diff --git a/frontend/src/services/__tests__/tripLogFixtures.ts b/frontend/src/services/__tests__/tripLogFixtures.ts index bb5c2f5d..c3a78ce6 100644 --- a/frontend/src/services/__tests__/tripLogFixtures.ts +++ b/frontend/src/services/__tests__/tripLogFixtures.ts @@ -24,6 +24,7 @@ export function logEntry(overrides: Partial = {}): TripLogEntry { purpose: null, notes: null, purposeIsAutomatic: false, + isHidden: false, ...overrides, } } diff --git a/frontend/src/services/tripLogApi.ts b/frontend/src/services/tripLogApi.ts index 575c5f3d..a5131cf9 100644 --- a/frontend/src/services/tripLogApi.ts +++ b/frontend/src/services/tripLogApi.ts @@ -34,6 +34,8 @@ export interface TripLogEntry { notes: string | null /** Marked a commute from home and work, rather than chosen by someone. */ purposeIsAutomatic: boolean + /** Left out of the map, the statistics, the dashboard and this log's totals and export. */ + isHidden: boolean } export interface TripPlaces { @@ -55,6 +57,11 @@ export const tripLogApi = { request(`/api/vehicles/${vin}/trips/log${buildQuery({ from, to })}`), update: (vin: string, id: number, purpose: TripPurpose | null, notes: string | null) => requestJson(`/api/vehicles/${vin}/trips/${id}`, 'PUT', { purpose, notes }), + setHidden: (vin: string, id: number, hidden: boolean) => + requestJson(`/api/vehicles/${vin}/trips/${id}/hidden`, 'PUT', { hidden }), + // Joins the trip with the saved trip after it; the joined trip keeps this one's id. + mergeWithNext: (vin: string, id: number) => + requestJson(`/api/vehicles/${vin}/trips/${id}/merge-next`, 'POST'), setPurpose: (vin: string, ids: number[], purpose: TripPurpose | null) => requestJson<{ changed: number }>(`/api/vehicles/${vin}/trips/purpose`, 'POST', { ids, diff --git a/frontend/src/stores/__tests__/tripLog.spec.ts b/frontend/src/stores/__tests__/tripLog.spec.ts index 92fc57b0..0eca7ad7 100644 --- a/frontend/src/stores/__tests__/tripLog.spec.ts +++ b/frontend/src/stores/__tests__/tripLog.spec.ts @@ -13,6 +13,8 @@ vi.mock('@/services/tripLogApi', async (importOriginal) => { log: vi.fn(), update: vi.fn(), setPurpose: vi.fn(), + setHidden: vi.fn(), + mergeWithNext: vi.fn(), resolvePlaces: vi.fn(), }, } @@ -118,6 +120,65 @@ describe('useTripLogStore', () => { expect(store.entries.map((e) => e.purpose)).toEqual(['commute', 'business', 'commute']) }) + it('leaves hidden trips out of the totals and out of classifying them all', async () => { + api.log.mockResolvedValue([ + logEntry({ id: 1 }), + logEntry({ id: 2, isHidden: true }), + logEntry({ id: 3, purpose: 'business' }), + ]) + api.setPurpose.mockResolvedValue({ changed: 1 }) + const store = useTripLogStore() + await store.fetchLog('VIN1', { year: 2026, month: 8 }) + + await store.classifyUnclassified('VIN1', 'private') + + expect(store.hiddenCount).toBe(1) + expect(store.visibleEntries.map((e) => e.id)).toEqual([1, 3]) + expect(store.totals.business.trips).toBe(1) + expect(api.setPurpose).toHaveBeenCalledWith('VIN1', [1], 'private') + }) + + it('hides a trip and shows it again, keeping the places it has', async () => { + api.log.mockResolvedValue([logEntry({ id: 1, startPlace: zwolle })]) + api.setHidden.mockResolvedValue(logEntry({ id: 1, isHidden: true })) + const store = useTripLogStore() + await store.fetchLog('VIN1', { year: 2026, month: 8 }) + + await store.setHidden('VIN1', 1, true) + + expect(api.setHidden).toHaveBeenCalledWith('VIN1', 1, true) + expect(store.entries[0]).toMatchObject({ isHidden: true, startPlace: zwolle }) + expect(store.totals.unclassified.trips).toBe(0) + }) + + it('joins a trip with the next one: the joined trip stays, the next one goes', async () => { + api.log.mockResolvedValue([logEntry({ id: 1 }), logEntry({ id: 2 }), logEntry({ id: 3 })]) + api.mergeWithNext.mockResolvedValue(logEntry({ id: 1, distanceKm: 80 })) + const store = useTripLogStore() + await store.fetchLog('VIN1', { year: 2026, month: 8 }) + + const ok = await store.mergeWithNext('VIN1', 1) + + expect(ok).toBe(true) + expect(store.entries.map((e) => [e.id, e.distanceKm])).toEqual([ + [1, 80], + [3, 40], + ]) + }) + + it('keeps both trips when the join is refused', async () => { + api.log.mockResolvedValue([logEntry({ id: 1 }), logEntry({ id: 2 })]) + api.mergeWithNext.mockRejectedValue(new Error('409')) + const store = useTripLogStore() + await store.fetchLog('VIN1', { year: 2026, month: 8 }) + + const ok = await store.mergeWithNext('VIN1', 1) + + expect(ok).toBe(false) + expect(store.entries).toHaveLength(2) + expect(store.actionError).not.toBeNull() + }) + describe('resolvePlaces', () => { it('asks for the trips still missing a place, and fills them in', async () => { api.log.mockResolvedValue([ diff --git a/frontend/src/stores/tripLog.ts b/frontend/src/stores/tripLog.ts index 94018935..bca68098 100644 --- a/frontend/src/stores/tripLog.ts +++ b/frontend/src/stores/tripLog.ts @@ -31,7 +31,11 @@ export const useTripLogStore = defineStore('tripLog', () => { let latestLoad = 0 let placesStopped = false - const totals = computed(() => purposeTotals(entries.value)) + /** The trips that count: everything but the hidden ones, which stay listable to show again. */ + const visibleEntries = computed(() => entries.value.filter((entry) => !entry.isHidden)) + const hiddenCount = computed(() => entries.value.length - visibleEntries.value.length) + + const totals = computed(() => purposeTotals(visibleEntries.value)) async function fetchLog(vin: string, period: TripLogPeriod) { const load = ++latestLoad @@ -66,17 +70,7 @@ export const useTripLogStore = defineStore('tripLog', () => { actionError.value = null try { const updated = await tripLogApi.update(vin, id, purpose, notes) - // Places this page looked up but the server does not keep ("nothing mapped here") stay - // as they are rather than being looked up all over again. - patchEntries((entry) => - entry.id === id - ? { - ...updated, - startPlace: updated.startPlace ?? entry.startPlace, - endPlace: updated.endPlace ?? entry.endPlace, - } - : entry, - ) + patchEntries((entry) => (entry.id === id ? keepPlaces(updated, entry) : entry)) return true } catch (e) { actionError.value = asError(e) @@ -88,7 +82,9 @@ export const useTripLogStore = defineStore('tripLog', () => { /** Gives every trip on the page that has no purpose yet this one. */ async function classifyUnclassified(vin: string, purpose: TripPurpose): Promise { - const ids = entries.value.filter((entry) => entry.purpose === null).map((entry) => entry.id) + const ids = visibleEntries.value + .filter((entry) => entry.purpose === null) + .map((entry) => entry.id) if (ids.length === 0) return true markSaving(ids, true) @@ -109,6 +105,55 @@ export const useTripLogStore = defineStore('tripLog', () => { } } + // Places this page looked up but the server does not keep ("nothing mapped here") stay as they + // are rather than being looked up all over again. + function keepPlaces(updated: TripLogEntry, entry: TripLogEntry): TripLogEntry { + return { + ...updated, + startPlace: updated.startPlace ?? entry.startPlace, + endPlace: updated.endPlace ?? entry.endPlace, + } + } + + /** Hides a trip from the map, the statistics and these totals, or shows it again. */ + async function setHidden(vin: string, id: number, hidden: boolean): Promise { + markSaving([id], true) + actionError.value = null + try { + const updated = await tripLogApi.setHidden(vin, id, hidden) + patchEntries((entry) => (entry.id === id ? keepPlaces(updated, entry) : entry)) + return true + } catch (e) { + actionError.value = asError(e) + return false + } finally { + markSaving([id], false) + } + } + + /** + * Joins a trip with the saved trip after it. The joined trip takes this one's place in the list + * and the next one goes; the server removes it the same way. + */ + async function mergeWithNext(vin: string, id: number): Promise { + const index = entries.value.findIndex((entry) => entry.id === id) + const next = entries.value[index + 1] + markSaving(next ? [id, next.id] : [id], true) + actionError.value = null + try { + const merged = await tripLogApi.mergeWithNext(vin, id) + entries.value = entries.value + .filter((entry) => entry.id !== next?.id) + .map((entry) => (entry.id === id ? keepPlaces(merged, entry) : entry)) + return true + } catch (e) { + actionError.value = asError(e) + return false + } finally { + markSaving(next ? [id, next.id] : [id], false) + } + } + function applyPlaces(found: readonly TripPlaces[]) { const byId = new Map(found.map((places) => [places.id, places])) patchEntries((entry) => { @@ -182,6 +227,8 @@ export const useTripLogStore = defineStore('tripLog', () => { return { entries, + visibleEntries, + hiddenCount, loading, loadError, actionError, @@ -192,6 +239,8 @@ export const useTripLogStore = defineStore('tripLog', () => { fetchLog, updateEntry, classifyUnclassified, + setHidden, + mergeWithNext, resolvePlaces, stopResolvingPlaces, } diff --git a/frontend/src/views/TripLogView.vue b/frontend/src/views/TripLogView.vue index 44aec455..970d1ebf 100644 --- a/frontend/src/views/TripLogView.vue +++ b/frontend/src/views/TripLogView.vue @@ -47,8 +47,16 @@ const months = computed(() => })), ) +// Hidden trips stay out of the list unless asked for, as they stay out of the totals and export. +const showHidden = ref(false) +const listed = computed(() => (showHidden.value ? store.entries : store.visibleEntries)) + // Newest first on screen, where the trips still to classify are; the export stays in date order. -const newestFirst = computed(() => [...store.entries].reverse()) +const newestFirst = computed(() => [...listed.value].reverse()) + +// Every trip but the newest has a saved trip after it to join with. The newest of a past month +// may too, in the month after; the server says so when there is none. +const newestId = computed(() => store.entries[store.entries.length - 1]?.id) const placesShown = computed(() => uiSettings.placeNamesEnabled && store.placesAvailable) const unclassified = computed(() => store.totals.unclassified.trips) @@ -109,12 +117,20 @@ function save(id: number, purpose: TripPurpose | null, notes: string | null) { if (vin.value) void store.updateEntry(vin.value, id, purpose, notes) } +function setHidden(id: number, hidden: boolean) { + if (vin.value) void store.setHidden(vin.value, id, hidden) +} + +function joinNext(id: number) { + if (vin.value) void store.mergeWithNext(vin.value, id) +} + function classifyAll(purpose: TripPurpose) { if (vin.value) void store.classifyUnclassified(vin.value, purpose) } function exportCsv() { - const csv = tripLogCsv(store.entries, { + const csv = tripLogCsv(store.visibleEntries, { t, units: units.value, placesShown: placesShown.value, @@ -133,7 +149,7 @@ function exportCsv() {