diff --git a/README.md b/README.md index fab91fa4..b3a4701a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ GarageStack is a free, open-source web app for **modern MG cars**, the ones buil - **Relive every drive.** Trips are saved automatically, named by where they went and drawn on the map along the roads you took, with heatmaps and the speed limits along the way. - **Keep a trip log for the taxman.** Mark trips as business, commute or private, add notes, and export a month or a year as a spreadsheet. - **Control it from anywhere.** Pre-condition the cabin, lock the car or flash its lights to find it, and see whether the car actually carried the command out. +- **Have it warm when you leave.** Schedule climate for weekday mornings or a one-off trip, with seat heating, the defrosters, and a skip for mild days. - **Hear about it when something is off.** Push notifications for low tyre pressure, a car left unlocked, a window left open, charging complete and more. - **Fit it into your homelab.** Home Assistant through MQTT discovery, a gethomepage.dev widget, and single sign-on through Authentik, Authelia, Keycloak or any other OpenID Connect provider. - **Keep it yours.** Everything runs in Docker on your own hardware, as one all-in-one container or a Compose stack. No subscription, and no account needed beyond your MG login. @@ -46,8 +47,9 @@ The statistics, trip log, maintenance list and light theme are in the [screensho - **Trip history** -- Browse past journeys on an interactive map with route playback and heatmap visualisation to identify frequently driven roads. 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, 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). - **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 over a configurable time window. -- **Remote commands** -- Trigger climate pre-conditioning, lock or unlock the car, and activate the horn and lights remotely from the dashboard. Each command reports whether the car carried it out, and if it refused, the reason the MG servers gave. -- **Push notifications** -- Browser and in-app alerts for key events: engine started, low tyre pressure, low EV battery, car left unlocked, doors or windows left open, and the messages the official MG app receives. +- **Remote commands** -- Trigger climate pre-conditioning (normal climate, fan only or front defrost), lock or unlock the car, and activate the horn and lights remotely from the dashboard. 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 tyre pressure, low EV battery, car left unlocked, doors or windows left open, a climate schedule that ran, and the messages the official MG app receives. - **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance. - **Home Assistant** -- Your car appears in Home Assistant automatically through MQTT discovery, sharing GarageStack's broker and MG session instead of needing a second integration. See [`HOME_ASSISTANT.md`](documentation/HOME_ASSISTANT.md). - **Progressive Web App (PWA)** -- Installable on mobile or desktop for a native app-like experience, complete with a home screen icon and push notification support. @@ -321,6 +323,7 @@ GarageStack checks your vehicle's state every 5 minutes and sends both a browser | Charging complete | Charging stops while the cable is still connected (plug-in vehicles) | | Maintenance due | A maintenance item reaches 90 % of its interval, or passes it (checked every 6 hours, 7-day cooldown per item) | | MG app message | The official MG app receives a message, such as an alarm or a reminder (sent as it arrives, once per message) | +| Scheduled climate | A climate schedule ran: climate started, the car refused, or it did not answer (nothing is sent for a run skipped because the car was in use or the day was mild) | Push notifications require VAPID keys to be configured (`VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`). Without them, alerts still appear in the in-app notification panel. While GarageStack is open in a browser that allowed notifications but is not subscribed to push, the same alerts show up as system notifications too; a subscribed browser gets them through push instead, so nothing arrives twice. diff --git a/docker/all-in-one/Dockerfile b/docker/all-in-one/Dockerfile index 84e6d282..ffd96737 100644 --- a/docker/all-in-one/Dockerfile +++ b/docker/all-in-one/Dockerfile @@ -6,6 +6,8 @@ WORKDIR /app # OS_PATCH_DATE busts the layer cache so apk upgrade actually re-runs instead of # reusing a stale cached layer from before an OS package fix was published. ARG OS_PATCH_DATE=0 +# tzdata -- IANA time zones, which climate schedules keep their start time in. Named +# explicitly: trixie no longer counts it as a required package. RUN echo "os patch date: ${OS_PATCH_DATE}" && apk upgrade --no-cache COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ # No version here: npm installs the one in package.json's packageManager field, so the pnpm @@ -87,7 +89,7 @@ RUN echo "os patch date: ${OS_PATCH_DATE}" \ && apt-get install -y --no-install-recommends \ bash curl ca-certificates gosu supervisor \ nginx mosquitto mosquitto-clients \ - libgssapi-krb5-2 libicu76 \ + libgssapi-krb5-2 libicu76 tzdata \ && rm -rf /var/lib/apt/lists/* # Dedicated non-root user for the .NET API/Worker and the SAIC gateway. Postgres and diff --git a/documentation/ARCHITECTURE.md b/documentation/ARCHITECTURE.md index bd92bae1..263f03c4 100644 --- a/documentation/ARCHITECTURE.md +++ b/documentation/ARCHITECTURE.md @@ -55,12 +55,22 @@ Messages from the official MG app are the one notification the Worker does not w ## Sending a command to the car (the reverse path) -The frontend calls `POST /api/vehicles/{vin}/commands/{command}` on the Api, which publishes an MQTT message via `MqttPublisher` ([src/GarageStack.Api/Services/MqttPublisher.cs](../src/GarageStack.Api/Services/MqttPublisher.cs)) - a separate MQTT client from the Worker's, since the Api only ever publishes and the Worker only ever consumes. `saic-mqtt-gateway` picks the message up and relays it to the SAIC cloud API. `VehicleCommandGate` ([src/GarageStack.Api/VehicleCommandGate.cs](../src/GarageStack.Api/VehicleCommandGate.cs)) serializes commands per VIN server-side, since the real gateway only processes one command at a time. The command names and their gateway topics live in `VehicleCommands`, one table for both directions. +The frontend calls `POST /api/vehicles/{vin}/commands/{command}` on the Api, which publishes an MQTT message via `MqttPublisher` ([src/GarageStack.Api/Services/MqttPublisher.cs](../src/GarageStack.Api/Services/MqttPublisher.cs)) - a separate MQTT client from the Worker's, since the Api only ever publishes and the Worker only ever consumes. `saic-mqtt-gateway` picks the message up and relays it to the SAIC cloud API. `VehicleCommandGate` ([src/GarageStack.Api/VehicleCommandGate.cs](../src/GarageStack.Api/VehicleCommandGate.cs)) serializes commands per VIN server-side, since the real gateway only processes one command at a time. The command names and their gateway topics live in `VehicleCommands` ([src/GarageStack.Core/Helpers/VehicleCommands.cs](../src/GarageStack.Core/Helpers/VehicleCommands.cs)), one table for both directions, and `VehicleCommandSender` is the one place a command is checked and published. The gateway answers every command on the same topic with `/set` swapped for `/result`: `Success`, or `Failed: ` once the SAIC API gives up (it polls for up to 30 seconds). The Worker parses that (`GatewayCommandResult`) and raises `command_result`. The Api releases the command gate for that VIN and pushes a `commandResult` event over SignalR, named by the command the browser sent. `useVehicleCommand` then settles the matching pending command: a refusal clears it and shows the reason. A success clears a command that no telemetry reports (the horn, the charge current limit). A command that telemetry does report stays pending until the telemetry shows it, but a batch can already move on to its next command. Without an answer, the 45-second hold on both sides still applies, which is also what happens in demo mode. Two details matter here. The gateway publishes results retained, so the broker replays the last one per command on every subscribe; the Worker drops those replays, or a restart would show an old failure as new. And an answer names a command but not a request, so a browser only takes an answer for a command it is still waiting on. +### Climate schedules + +The gateway has charging and battery-heating schedules of its own, but none for climate, so GarageStack runs climate schedules itself. The Api keeps them (`ClimateScheduleEndpoints`) and works out each one's next run on every save (`ClimateScheduleCalendar`): a schedule is a time on the clock in the IANA time zone the browser had when it was made, so 07:30 stays 07:30 across daylight saving time. A time skipped when the clocks go forward runs just after the jump, and a time that happens twice when they go back runs the first time only. + +The Worker runs them. `ClimateScheduleService` looks every 15 seconds for schedules that are due and claims each one before it runs (a repeating schedule moves on to its next run, a one-off switches itself off), so a restart in the middle never runs a schedule twice. A run found more than 10 minutes late is recorded as missed instead: a car warmed long after its driver left is no use. `ClimateScheduleRunner` skips a run while the engine runs, or when the schedule only wants cold or hot days and an outside temperature from the last three hours says otherwise. Otherwise it sends the commands one at a time (temperature first, since the gateway only stores it while climate is off, then the climate mode, then the extras), stops at the first one refused or unanswered, records the outcome on the schedule, and sends one push in the `climate-schedule` category. + +Only the Api publishes commands, through the same `VehicleCommandSender` and gate as the browser. So the Worker asks for each command on the `vehicle_command_requested` channel, the one pg channel that is a request rather than news, and waits for the gateway's answer itself (`CommandAnswerWaiter`, completed by `CommandResultHandler`). The Api handles each request apart from its LISTEN loop, because a request may wait up to 90 seconds for the gate, and the `command_result` that frees the gate must not queue up behind it. A request the Api never sees (it is down, or reconnecting to Postgres) ends as no answer after 150 seconds rather than as a late start. Because answers carry no request id, a command sent by hand at the same moment as a scheduled run can count as that run's answer; the command still reaches the car, only the recorded outcome can be off. + +The Api (edits) and the Worker (claims and outcomes) both write a schedule, so `ClimateSchedule.Version` is a concurrency token: an edit is applied again on top of a claim it raced with, and a claim on a schedule edited since it was read is left for the next look. + ## Trips The gateway reports positions, not trips. `TripSegmenter` in Core cuts GPS fixes into trips. A trip ends when the car stands still for five minutes or no fix arrives for half an hour. A trip containing a jump faster than 250 km/h is thrown out as a positioning glitch, and one under 0.1 km never went anywhere. The Worker and the Api both use this one class, so they cannot disagree about where a trip starts and ends. @@ -231,8 +241,8 @@ The API speaks metric only (km, km/h, bar, °C, litres, L/100 km), and so does t chosen units live in the UI settings store and are applied at the last moment by `UnitFormatter` in `utils/units.ts`, reached through the `useUnits` composable: a value on screen goes through `units.format(quantity, metricValue)` (or `measure` when the number and its unit are -styled apart), never through a hand-written `km` or `bar`. A distance typed into a form goes back -through `useDistanceField`, which returns the untouched kilometres it loaded rather than a +styled apart), never through a hand-written `km` or `bar`. A distance or temperature typed into a form goes back +through `useMeasureField`, which returns the untouched metric value it loaded rather than a round-tripped approximation. Dashboard cards are described once, in `frontend/src/cards/registry.ts`: each entry carries the card's icon, whether it is visible by default for a given drivetrain, and whether the current telemetry has anything to show. The card ids, the default layout and the "does this card have data" checks are all derived from that list, so a new card is one entry plus its markup in `DashboardCardContent.vue`. The map's point-of-interest layers work the same way: `composables/poiTileLayer.ts` holds the fetch-by-tile, cache and cluster logic, and charging stations, fuel stations, service areas and speed cameras are four configurations of it. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 80a1527c..245050bb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -236,6 +236,12 @@ watch( {{ t('nav.tripLog') }} +
  • + + + {{ t('nav.climateSchedules') }} + +
  • diff --git a/frontend/src/assets/climateSchedules.css b/frontend/src/assets/climateSchedules.css new file mode 100644 index 00000000..d56a4c0a --- /dev/null +++ b/frontend/src/assets/climateSchedules.css @@ -0,0 +1,14 @@ +/* ── Last run, in the schedule form ───────────────────────── */ + +.climate-schedule-last-run { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.6rem; +} + +/* The car's reason takes its own line under the badge. */ +.climate-schedule-last-run__detail { + flex-basis: 100%; +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 5038c7b0..3e036700 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,6 +1,10 @@ @import url('@vueform/multiselect/themes/default.css'); :root { + /* Tells the browser which palette its own widgets use: the time and date pickers, select + dropdowns and the clock and calendar icons inside inputs. */ + color-scheme: dark; + --color-bg: #0f1117; --color-surface: #1a1d27; --color-surface-2: #252836; @@ -23,6 +27,8 @@ } [data-theme='light'] { + color-scheme: light; + --color-bg: #f0f4f8; --color-surface: #fff; --color-surface-2: #e8edf3; @@ -323,6 +329,13 @@ body, /* ── Status card grid ─────────────────────────────────────── */ +/* Status cards as a single-column list of items (maintenance, climate schedules). */ +.status-card-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + .status-grid { display: grid; grid-template-columns: repeat(4, 1fr); @@ -391,6 +404,14 @@ body, overflow: hidden; } +.status-card__note { + font-size: 0.78rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .status-card__unit { font-size: 0.78rem; color: var(--color-text-muted); @@ -523,6 +544,21 @@ body, border-color: var(--color-warning); } +/* A row that leads somewhere else: the whole row is the button. */ +.detail-list__item--link { + width: 100%; + font-family: inherit; + color: var(--color-text); + text-align: left; + cursor: pointer; +} + +.detail-list__item--link:hover, +.detail-list__item--link:focus-visible { + border-color: var(--color-primary); + outline: none; +} + .detail-list__item-icon { width: 1rem; color: var(--color-text-muted); @@ -1104,6 +1140,122 @@ body, left: 23px; } +/* Labelled fields stacked in a modal form: a group is a label above its field, a row puts + two groups side by side (one above the other on phones, see the mobile breakpoint). */ +.form-stack { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.9rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.form-group label, +.form-group__label { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.form-control { + width: 100%; + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + border-radius: 6px; + border: 1px solid var(--color-border); + background-color: var(--color-surface-2); + color: var(--color-text); +} + +textarea.form-control { + resize: vertical; + font-family: inherit; +} + +/* A time of day as selects (TimeOfDaySelect): hour and minute on a 24-hour clock. */ +.time-select { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.time-select .form-control { + flex: 1; + min-width: 0; +} + +.time-select__separator { + color: var(--color-text-muted); +} + +/* ── Toggle chips (one-of-N or several-of-N choices) ──────── */ + +.toggle-chip-group { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.toggle-chip { + --chip-color: var(--color-primary); + + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface-2); + color: var(--color-text-muted); + cursor: pointer; +} + +.toggle-chip--active { + border-color: var(--chip-color); + background: color-mix(in srgb, var(--chip-color) 18%, var(--color-surface-2)); + color: var(--color-text); + font-weight: 600; +} + +.toggle-chip:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +/* Seven day chips share the row evenly, so they fit a phone without wrapping. */ +.weekday-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.weekday-picker .toggle-chip-group { + flex-wrap: nowrap; +} + +.weekday-picker .toggle-chip { + flex: 1; + min-width: 0; + padding-inline: 0.2rem; +} + +.weekday-picker__presets { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + /* ── Settings ─────────────────────────────────────────────── */ .settings-section { @@ -1433,6 +1585,11 @@ body, border: 2px solid var(--color-surface); } +.range-control input[type='range']:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .range-control__labels { display: flex; justify-content: space-between; @@ -1451,6 +1608,10 @@ body, gap: 0.7rem; } +.range-control--grow { + flex: 1; +} + /* ── Card info overlay ────────────────────────────────────── */ .card-info-wrap { @@ -1670,6 +1831,10 @@ body, /* ── Mobile breakpoint ────────────────────────────────────── */ @media (width <= 767px) { + .form-row { + grid-template-columns: 1fr; + } + /* Show topbar, hide desktop sidebar brand */ .mobile-topbar { display: flex; diff --git a/frontend/src/assets/maintenance.css b/frontend/src/assets/maintenance.css index 5e6aeb82..3bd37aec 100644 --- a/frontend/src/assets/maintenance.css +++ b/frontend/src/assets/maintenance.css @@ -1,51 +1,3 @@ -/* ── List ─────────────────────────────────────────────────── */ - -.maintenance-list { - display: flex; - flex-direction: column; - gap: 0.6rem; -} - -/* ── Form fields ──────────────────────────────────────────── */ - -.maintenance-form { - display: flex; - flex-direction: column; - gap: 0.9rem; -} - -.maintenance-field-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.9rem; -} - -.maintenance-field-group { - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.maintenance-field-group label { - font-size: 0.78rem; - color: var(--color-text-muted); -} - -.maintenance-field { - width: 100%; - padding: 0.4rem 0.6rem; - font-size: 0.85rem; - border-radius: 6px; - border: 1px solid var(--color-border); - background-color: var(--color-surface-2); - color: var(--color-text); -} - -.maintenance-field--textarea { - resize: vertical; - font-family: inherit; -} - /* ── Detail modal ─────────────────────────────────────────── */ .maintenance-detail { @@ -95,9 +47,3 @@ .maintenance-history__delete { margin-left: auto; } - -@media (width <= 767px) { - .maintenance-field-row { - grid-template-columns: 1fr; - } -} diff --git a/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index f0568af3..aed45f53 100644 --- a/frontend/src/assets/tripLog.css +++ b/frontend/src/assets/tripLog.css @@ -122,11 +122,6 @@ align-items: center; } -.trip-log-item__purpose { - display: flex; - gap: 0.35rem; -} - .trip-log-item__notes { flex: 1 1 12rem; min-width: 0; @@ -138,42 +133,15 @@ color: var(--color-text); } -/* ── Purpose buttons ──────────────────────────────────────── */ +/* ── Purpose chips (ChipGroup, main.css) ─────────────────── */ -.trip-log-purpose { - --purpose-color: var(--color-primary); - - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.35rem; - padding: 0.3rem 0.6rem; - font-size: 0.78rem; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-surface-2); - color: var(--color-text-muted); - cursor: pointer; -} - -.trip-log-purpose--commute { - --purpose-color: var(--color-info); -} - -.trip-log-purpose--private { - --purpose-color: var(--color-success); -} - -.trip-log-purpose--active { - border-color: var(--purpose-color); - background: color-mix(in srgb, var(--purpose-color) 18%, var(--color-surface-2)); - color: var(--color-text); - font-weight: 600; +/* Two classes, so the colour wins over .toggle-chip whichever stylesheet loads last. */ +.toggle-chip.trip-log-purpose--commute { + --chip-color: var(--color-info); } -.trip-log-purpose:disabled { - cursor: not-allowed; - opacity: 0.6; +.toggle-chip.trip-log-purpose--private { + --chip-color: var(--color-success); } /* ── Phones ───────────────────────────────────────────────── */ diff --git a/frontend/src/cards/registry.ts b/frontend/src/cards/registry.ts index fc2a7270..789c56d1 100644 --- a/frontend/src/cards/registry.ts +++ b/frontend/src/cards/registry.ts @@ -129,6 +129,8 @@ export const CARD_DEFINITIONS = [ hasData: ({ latestTrip }) => latestTrip !== null && latestTrip.maxSpeedKmh !== null, }, { id: 'maintenance', icon: 'screwdriver-wrench' }, + // Off until asked for: most cars have no schedule, and the climate card links to them anyway. + { id: 'climateSchedule', icon: 'calendar-days', defaultVisible: () => false }, ] as const satisfies readonly CardDefinition[] export type CardId = (typeof CARD_DEFINITIONS)[number]['id'] diff --git a/frontend/src/components/ChipGroup.vue b/frontend/src/components/ChipGroup.vue new file mode 100644 index 00000000..9538c191 --- /dev/null +++ b/frontend/src/components/ChipGroup.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/ClimateDetailCard.vue b/frontend/src/components/ClimateDetailCard.vue index bde9e4ed..9e0d3246 100644 --- a/frontend/src/components/ClimateDetailCard.vue +++ b/frontend/src/components/ClimateDetailCard.vue @@ -1,19 +1,36 @@ - - diff --git a/frontend/src/components/ClimateScheduleFormModal.vue b/frontend/src/components/ClimateScheduleFormModal.vue new file mode 100644 index 00000000..2b361cdc --- /dev/null +++ b/frontend/src/components/ClimateScheduleFormModal.vue @@ -0,0 +1,408 @@ + + + diff --git a/frontend/src/components/ClimateScheduleSummaryCard.vue b/frontend/src/components/ClimateScheduleSummaryCard.vue new file mode 100644 index 00000000..6255adc7 --- /dev/null +++ b/frontend/src/components/ClimateScheduleSummaryCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/components/DashboardCardContent.vue b/frontend/src/components/DashboardCardContent.vue index 466dd452..d8f85c33 100644 --- a/frontend/src/components/DashboardCardContent.vue +++ b/frontend/src/components/DashboardCardContent.vue @@ -22,6 +22,7 @@ import LightsCard from './LightsCard.vue' import ChargingSessionCard from './ChargingSessionCard.vue' import BatteryHeatingCard from './BatteryHeatingCard.vue' import MaintenanceSummaryCard from './MaintenanceSummaryCard.vue' +import ClimateScheduleSummaryCard from './ClimateScheduleSummaryCard.vue' import { formatNumber } from '@/utils/format' import { energyUnit, hvBatteryReading, litres, litresPer100Km, whPerKm } from '@/utils/energy' import { evLevelVariant, fuelLevelVariant } from '@/utils/levels' @@ -278,6 +279,7 @@ const activeSimpleCard = computed(() => { v-else-if="cardId === 'climate'" :vin="vin" :climate-on="status.climateOn" + :climate-mode="status.climateMode" :remote-temperature="status.remoteTemperature" :interior-temperature="status.interiorTemperature" :exterior-temperature="status.exteriorTemperature" @@ -353,5 +355,6 @@ const activeSimpleCard = computed(() => { + diff --git a/frontend/src/components/DetailListSwitch.vue b/frontend/src/components/DetailListSwitch.vue new file mode 100644 index 00000000..59873d29 --- /dev/null +++ b/frontend/src/components/DetailListSwitch.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/MaintenanceItemDetailModal.vue b/frontend/src/components/MaintenanceItemDetailModal.vue index 51fbf6a5..c3120980 100644 --- a/frontend/src/components/MaintenanceItemDetailModal.vue +++ b/frontend/src/components/MaintenanceItemDetailModal.vue @@ -9,7 +9,7 @@ import { formatIntervalSummary } from '@/utils/maintenance' import { formatDate } from '@/utils/format' import { ODOMETER_FORMAT } from '@/utils/units' import { useUnits } from '@/composables/useUnits' -import { useDistanceField } from '@/composables/useDistanceField' +import { useMeasureField } from '@/composables/useMeasureField' import { useErrorMessage } from '@/composables/useErrorMessage' const props = defineProps<{ @@ -27,7 +27,7 @@ const units = useUnits() const errorMessage = useErrorMessage() const performedAt = ref('') -const odometer = useDistanceField(units) +const odometer = useMeasureField(units, 'distance') const logNotes = ref('') const logSaving = ref(false) const logError = ref(null) @@ -56,7 +56,7 @@ async function submitLog() { try { await store.logService(props.vin, props.item.id, { performedAt: performedAt.value, - odometerKm: odometer.km(), + odometerKm: odometer.metric(), notes: logNotes.value.trim() || null, }) if (store.actionError) { @@ -118,18 +118,13 @@ async function confirmDelete() {

    {{ currentItem.notes }}

    {{ t('maintenance.logService') }}

    -
    -
    -
    + +
    +
    - +
    -
    +
    @@ -138,17 +133,17 @@ async function confirmDelete() { v-model.number="odometer.shown" type="number" min="0" - class="maintenance-field" + class="form-control" />
    -
    +
    diff --git a/frontend/src/components/MaintenanceItemFormModal.vue b/frontend/src/components/MaintenanceItemFormModal.vue index 92def271..2aef73bf 100644 --- a/frontend/src/components/MaintenanceItemFormModal.vue +++ b/frontend/src/components/MaintenanceItemFormModal.vue @@ -6,7 +6,7 @@ import { useMaintenanceStore } from '@/stores/maintenance' import type { MaintenanceItem } from '@/services/maintenanceApi' import { useUnits } from '@/composables/useUnits' import { useErrorMessage } from '@/composables/useErrorMessage' -import { useDistanceField } from '@/composables/useDistanceField' +import { useMeasureField } from '@/composables/useMeasureField' const props = defineProps<{ open: boolean @@ -23,10 +23,10 @@ const errorMessage = useErrorMessage() const name = ref('') const notes = ref('') -const interval = useDistanceField(units) +const interval = useMeasureField(units, 'distance') const intervalMonths = ref(null) const lastServiceDate = ref('') -const lastServiceOdometer = useDistanceField(units) +const lastServiceOdometer = useMeasureField(units, 'distance') const validationError = ref(null) const distanceUnit = computed(() => ({ unit: units.value.symbol('distance') })) const saving = ref(false) @@ -60,7 +60,7 @@ async function submit() { validationError.value = t('maintenance.form.validationNameRequired') return } - const intervalKm = interval.km() + const intervalKm = interval.metric() if (intervalKm == null && intervalMonths.value == null) { validationError.value = t('maintenance.form.validationIntervalRequired') return @@ -82,7 +82,7 @@ async function submit() { intervalKm, intervalMonths: intervalMonths.value, lastServiceDate: lastServiceDate.value || null, - lastServiceOdometerKm: lastServiceOdometer.km(), + lastServiceOdometerKm: lastServiceOdometer.metric(), }) } if (store.actionError) { @@ -98,32 +98,32 @@ async function submit() {