diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue index c44a373ed..59a655922 100644 --- a/assets/js/components/Sessions/SessionDetailsModal.vue +++ b/assets/js/components/Sessions/SessionDetailsModal.vue @@ -121,12 +121,48 @@ {{ socRange }} - - + + {{ $t("session.odometer") }} - {{ formatKm(session.odometer) }} +
+
+ + + {{ distanceUnitLabel }} + +
+ +
@@ -181,7 +217,7 @@ import formatter from "@/mixins/formatter"; import Options from "../Vehicles/Options.vue"; import CustomSelect from "../Helper/CustomSelect.vue"; import GenericModal from "../Helper/GenericModal.vue"; -import { distanceUnit, distanceValue } from "@/units"; +import { distanceUnit, distanceValue, distanceValueReverse } from "@/units"; import api from "@/api"; import { defineComponent, type PropType } from "vue"; import type { Session } from "./types"; @@ -198,7 +234,17 @@ export default defineComponent({ loadpoints: { type: Array as PropType, default: () => [] }, }, emits: ["session-changed"], + data() { + return { + editingOdometer: false, + odometerInput: "" as number | string, + odometerOriginal: "" as number | string, + }; + }, computed: { + distanceUnitLabel(): string { + return distanceUnit(); + }, chargedEnergy() { return this.session.chargedEnergy * 1e3; }, @@ -246,6 +292,26 @@ export default defineComponent({ formatKm(value: number) { return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`; }, + startEditOdometer() { + this.odometerInput = this.session.odometer + ? Math.round(distanceValue(this.session.odometer)) + : ""; + this.odometerOriginal = this.odometerInput; + this.editingOdometer = true; + this.$nextTick(() => (this.$refs["odometerInput"] as HTMLInputElement)?.focus()); + }, + cancelOdometer() { + this.editingOdometer = false; + }, + async saveOdometer() { + if (!this.editingOdometer) return; + this.editingOdometer = false; + const value = this.odometerInput; + if (value === this.odometerOriginal) return; + // empty input clears the stored value + const odometer = value === "" ? null : distanceValueReverse(Number(value)); + await this.updateSession({ odometer }); + }, async changeVehicle(title: string) { await this.updateSession({ vehicle: title }); }, @@ -255,7 +321,9 @@ export default defineComponent({ async changeLoadpoint(title: string) { await this.updateSession({ loadpoint: title }); }, - async updateSession(data: Partial | { vehicle: null }) { + async updateSession( + data: Partial | { vehicle: null } | { odometer: number | null } + ) { try { await api.put("session/" + this.session.id, data); this.$emit("session-changed"); @@ -284,4 +352,23 @@ export default defineComponent({ .options .loadpoint-name { text-decoration: underline; } + +.odometer-cell { + /* reserve edit-input height so the row doesn't jump between view/edit */ + min-height: 2rem; +} + +.odometer-input .form-control { + /* fit ~7 digits (e.g. 128222) */ + width: calc(7ch + 1rem); + -moz-appearance: textfield; + appearance: textfield; +} + +.odometer-input .form-control::-webkit-inner-spin-button, +.odometer-input .form-control::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; +} diff --git a/assets/js/units.ts b/assets/js/units.ts index cf50379be..f57f236b0 100644 --- a/assets/js/units.ts +++ b/assets/js/units.ts @@ -11,6 +11,10 @@ export function distanceValue(value: number) { return isMiles() ? value * MILES_FACTOR : value; } +export function distanceValueReverse(value: number) { + return isMiles() ? value / MILES_FACTOR : value; +} + export function distanceUnit() { return isMiles() ? "mi" : "km"; } diff --git a/i18n/de.json b/i18n/de.json index e0ebf2a22..24b083f13 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1403,6 +1403,7 @@ "updatePassword": "Passwort ändern" }, "session": { + "addOdometer": "Wert eintragen", "cancel": "Abbrechen", "co2": "CO₂", "date": "Zeitraum", diff --git a/i18n/en.json b/i18n/en.json index 73f528edd..5fff29c9b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1403,6 +1403,7 @@ "updatePassword": "Update password" }, "session": { + "addOdometer": "Add value", "cancel": "Cancel", "co2": "CO₂", "date": "Period", diff --git a/server/http_session_handler.go b/server/http_session_handler.go index 6eda64752..242dca7c3 100644 --- a/server/http_session_handler.go +++ b/server/http_session_handler.go @@ -119,22 +119,30 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) { id := mux.Vars(r)["id"] - var data struct { - Vehicle, Loadpoint *string - } - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, err) return } - if data.Vehicle == nil && data.Loadpoint == nil { + // only update fields present in the request; a null value clears the column + updates := map[string]any{} + if v, ok := body["vehicle"]; ok { + updates["vehicle"] = v + } + if v, ok := body["loadpoint"]; ok { + updates["loadpoint"] = v + } + if v, ok := body["odometer"]; ok { + updates["odometer"] = v + } + + if len(updates) == 0 { jsonError(w, http.StatusBadRequest, errors.New("nothing to update")) return } - // https://github.com/evcc-io/evcc/issues/13738#issuecomment-2094070362 - if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(data); txn.Error != nil { + if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(updates); txn.Error != nil { jsonError(w, http.StatusBadRequest, txn.Error) return } diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 2004cfd96..f08dfbfb5 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -405,9 +405,7 @@ "type": "number" }, "odometer": { - "description": "Vehicle odometer reading in kilometers", - "nullable": true, - "type": "number" + "$ref": "#/components/schemas/Odometer" }, "vehicle": { "$ref": "#/components/schemas/VehicleName" @@ -561,6 +559,11 @@ ], "type": "string" }, + "Odometer": { + "description": "Vehicle odometer reading in kilometers", + "nullable": true, + "type": "number" + }, "Password": { "description": "Admin password", "type": "string" @@ -1939,7 +1942,7 @@ } ], "put": { - "description": "Update vehicle of charging session.", + "description": "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value.", "externalDocs": { "url": "https://docs.evcc.io/en/docs/features/sessions" }, @@ -1952,6 +1955,9 @@ "loadpoint": { "$ref": "#/components/schemas/LoadpointName" }, + "odometer": { + "$ref": "#/components/schemas/Odometer" + }, "vehicle": { "$ref": "#/components/schemas/VehicleName" } @@ -1967,7 +1973,7 @@ "description": "Success" } }, - "summary": "Update vehicle of charging session", + "summary": "Update charging session", "tags": [ "sessions" ] diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index d657b8a6e..12bea73bb 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -946,7 +946,7 @@ call getSessions { ## updateSession -Update vehicle of charging session. +Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value. **Tags:** sessions diff --git a/server/openapi.yaml b/server/openapi.yaml index 26ca0be20..3c5e387c7 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -696,8 +696,8 @@ paths: - $ref: "#/components/parameters/id" put: operationId: updateSession - summary: Update vehicle of charging session - description: "Update vehicle of charging session." + summary: Update charging session + description: "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value." externalDocs: url: https://docs.evcc.io/en/docs/features/sessions tags: @@ -713,6 +713,8 @@ paths: $ref: "#/components/schemas/VehicleName" loadpoint: $ref: "#/components/schemas/LoadpointName" + odometer: + $ref: "#/components/schemas/Odometer" responses: "200": description: Success @@ -1182,9 +1184,7 @@ components: vehicle: $ref: "#/components/schemas/VehicleName" odometer: - nullable: true - type: "number" - description: Vehicle odometer reading in kilometers + $ref: "#/components/schemas/Odometer" meterStart: nullable: true type: "number" @@ -1305,6 +1305,10 @@ components: - "now" - "minpv" - "pv" + Odometer: + nullable: true + type: "number" + description: Vehicle odometer reading in kilometers Password: description: Admin password type: string diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index ee2010db0..dd812f7dc 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -1,6 +1,12 @@ import { test, expect, devices, type Page } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils"; +import { + expectModalVisible, + expectModalHidden, + enableAppContext, + expectAppEvent, + openMoreMenu, +} from "./utils"; test.use({ baseURL: baseUrl() }); @@ -347,7 +353,7 @@ test.describe("session details", async () => { await expect(modal.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)"); await expect(modal.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh"); await expect(modal.getByTestId("session-details-co2")).toHaveCount(0); - await expect(modal.getByTestId("session-details-odometer")).toHaveCount(0); + await expect(modal.getByTestId("session-details-odometer")).toContainText("Add value"); await expect(modal.getByTestId("session-details-meter")).toHaveCount(0); await expect(modal.getByTestId("session-details-delete")).toContainText("Delete"); }); @@ -431,3 +437,78 @@ test.describe("session details", async () => { await expect(page.getByTestId("sessions-entry")).toHaveCount(3); }); }); + +test.describe("edit odometer", async () => { + async function openFirstSession(page: Page) { + await page.getByTestId("sessions-entry").nth(0).click(); + await expectModalVisible(page.getByTestId("session-details")); + } + + async function closeSession(page: Page) { + const modal = page.getByTestId("session-details"); + await modal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(modal); + } + + async function setDistanceUnit(page: Page, unit: string) { + await openMoreMenu(page); + await page.getByRole("button", { name: "User Interface", exact: true }).click(); + const settings = page.getByTestId("global-settings-modal"); + await expectModalVisible(settings); + await settings.getByRole("radio", { name: `Units: ${unit}` }).click(); + await settings.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(settings); + } + + test("edit existing value and convert between km and miles (session 1)", async ({ page }) => { + const odometer = page.getByTestId("session-details").getByLabel("Mileage"); + + await page.goto("/#/sessions?year=2023&month=3"); + await openFirstSession(page); + await expect(odometer).toContainText("12,345 km"); + await odometer.click(); + await expect(odometer).toHaveValue("12345"); + await odometer.fill("100000"); + await odometer.press("Enter"); + // value reloads from the server after save + await expect(odometer).toContainText("100,000 km"); + + // switch to miles, value converts on display (100000 km -> 62137 mi) + await closeSession(page); + await setDistanceUnit(page, "miles"); + await openFirstSession(page); + await expect(odometer).toContainText("62,137 mi"); + + // edit in miles + await odometer.click(); + await expect(odometer).toHaveValue("62137"); + await odometer.fill("50000"); + await odometer.press("Enter"); + await expect(odometer).toContainText("50,000 mi"); + + // switch back to km, value round-trips (50000 mi -> ~80,467 km) + await closeSession(page); + await setDistanceUnit(page, "km"); + await openFirstSession(page); + await expect(odometer).toContainText("80,467 km"); + }); + + test("add missing value (session 4)", async ({ page }) => { + const odometer = page.getByTestId("session-details").getByLabel("Mileage"); + + await page.goto("/#/sessions?year=2023&month=5"); + await openFirstSession(page); + await expect(odometer).toContainText("Add value"); + await odometer.click(); + await expect(odometer).toHaveValue(""); + await odometer.fill("1000"); + await odometer.press("Enter"); + await expect(odometer).toContainText("1,000 km"); + + // clearing the field deletes the value + await odometer.click(); + await odometer.fill(""); + await odometer.press("Enter"); + await expect(odometer).toContainText("Add value"); + }); +});