Sessions UI: make odometer editable (#30437)

This commit is contained in:
Michael Geers 2026-06-02 14:41:01 +02:00 • committed by GitHub
parent 7b5df178ce
commit 5920e56ff8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 218 additions and 26 deletions

View file

@ -121,12 +121,48 @@
{{ socRange }}
</td>
</tr>
<tr v-if="session.odometer" data-testid="session-details-odometer">
<th>
<tr data-testid="session-details-odometer">
<th class="align-middle">
{{ $t("session.odometer") }}
</th>
<td>
{{ formatKm(session.odometer) }}
<div class="odometer-cell d-flex align-items-center">
<div
v-if="editingOdometer"
class="input-group input-group-sm odometer-input w-auto"
>
<input
ref="odometerInput"
v-model="odometerInput"
type="number"
inputmode="numeric"
min="0"
class="form-control grow-0 text-end"
:aria-label="$t('session.odometer')"
aria-describedby="sessionDetailsOdometerUnit"
@blur="saveOdometer"
@keydown.enter.prevent="saveOdometer"
@keydown.esc.stop="cancelOdometer"
/>
<span id="sessionDetailsOdometerUnit" class="input-group-text">
{{ distanceUnitLabel }}
</span>
</div>
<button
v-else
type="button"
class="btn-reset text-decoration-underline"
:class="{ 'text-muted': !session.odometer }"
:aria-label="$t('session.odometer')"
@click="startEditOdometer"
>
{{
session.odometer
? formatKm(session.odometer)
: $t("session.addOdometer")
}}
</button>
</div>
</td>
</tr>
<tr v-if="session.meterStart" data-testid="session-details-meter">
@ -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<string[]>, 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<Session> | { vehicle: null }) {
async updateSession(
data: Partial<Session> | { 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;
}
</style>

View file

@ -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";
}

View file

@ -1403,6 +1403,7 @@
"updatePassword": "Passwort ändern"
},
"session": {
"addOdometer": "Wert eintragen",
"cancel": "Abbrechen",
"co2": "CO₂",
"date": "Zeitraum",

View file

@ -1403,6 +1403,7 @@
"updatePassword": "Update password"
},
"session": {
"addOdometer": "Add value",
"cancel": "Cancel",
"co2": "CO₂",
"date": "Period",

View file

@ -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
}

View file

@ -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"
]

View file

@ -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

View file

@ -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

View file

@ -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");
});
});