Translate the dashboard to English; euro instead of lira
Dashboard: every label, empty state, chart legend and the login page are English; numbers and dates use en-GB (24-hour clock, day-month dates), money is EUR, percentages read 80% instead of %80, durations "2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it. Car app: the charging tab's price suffix, validation message and total cost use € instead of ₺. Checked by rendering all three pages from the 1.1.2 image against InfluxDB 2.7.12 in podman. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
@@ -74,7 +74,7 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
|
|||||||
- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`).
|
- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`).
|
||||||
- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls.
|
- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls.
|
||||||
Unit tests and lint pass; **not yet tried on the car**.
|
Unit tests and lint pass; **not yet tried on the car**.
|
||||||
- [ ] Push the image (`mg4-server:1.1.1`) to git.stinnesbeck.com (needs a registry login on the build machine).
|
- [ ] Push the image (`mg4-server:1.1.2`) to git.stinnesbeck.com (needs a registry login on the build machine).
|
||||||
- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`).
|
- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`).
|
||||||
- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP.
|
- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP.
|
||||||
|
|
||||||
@@ -90,9 +90,6 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
|
|||||||
(`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the
|
(`ALLOWED_HOSTS`). For git.stinnesbeck.com it needs the host on the allowlist, and the
|
||||||
response parsing checked against that server's releases API. Release assets must be named
|
response parsing checked against that server's releases API. Release assets must be named
|
||||||
`NilsMG4-unstable-<version>.apk` with a `.sha256` sidecar (`build-car` writes both).
|
`NilsMG4-unstable-<version>.apk` with a `.sha256` sidecar (`build-car` writes both).
|
||||||
- [ ] **Charging currency.** Prices and costs are shown in Turkish lira (`₺/kWh`, `₺`) in
|
|
||||||
`pane_charging.xml` and `MainActivity.java`.
|
|
||||||
- [ ] **Dashboard language.** The `web/` pages are still Turkish and show prices in lira.
|
|
||||||
- [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only.
|
- [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only.
|
||||||
- [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s
|
- [ ] **Translate the rest.** The docs (`CHANGELOG.md`, `OTA.md`'s
|
||||||
message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French.
|
message table, comments in `mise.toml`/`app/build.gradle`) are still Turkish or French.
|
||||||
@@ -116,6 +113,9 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
|
|||||||
telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls
|
telemetry restored from before `ea8032a` and sent to the server, Upload-tab cadence controls
|
||||||
reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again
|
reconnected, dead switches removed. The removed ABRP classes' unit tests compile and pass again
|
||||||
(AbrpApi and HeadUnitWeather tests deleted with their classes).
|
(AbrpApi and HeadUnitWeather tests deleted with their classes).
|
||||||
|
- 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4.
|
||||||
|
- 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the
|
||||||
|
dashboard and the car app's charging tab.
|
||||||
- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
|
- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
|
||||||
removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
|
removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
|
||||||
|
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
|
chargePriceInput.setText(String.format(java.util.Locale.US, "%.2f", value));
|
||||||
Toast.makeText(this, "Charging price saved", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Charging price saved", Toast.LENGTH_SHORT).show();
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
chargePriceLayout.setError("Enter a valid ₺/kWh value");
|
chargePriceLayout.setError("Enter a valid €/kWh value");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +916,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—");
|
java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—");
|
||||||
long sec = session.durationSeconds;
|
long sec = session.durationSeconds;
|
||||||
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
|
chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—");
|
||||||
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—");
|
chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "€%.2f", session.totalCost) : "—");
|
||||||
chargeGraph.setPoints(session.points);
|
chargeGraph.setPoints(session.points);
|
||||||
chargeCurveTable.setText(chargeGraph.tableText());
|
chargeCurveTable.setText(chargeGraph.tableText());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:layout_marginTop="16dp" android:background="@drawable/bg_glass">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="CHARGING PRICE" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="CHARGING PRICE" android:textAppearance="@style/Text.EV.Eyebrow"/>
|
||||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Unit price" app:suffixText="₺/kWh">
|
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="Unit price" app:suffixText="€/kWh">
|
||||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
<com.google.android.material.button.MaterialButton android:id="@+id/charge_price_save" style="@style/Widget.EV.Button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="8dp" android:text="Save price"/>
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ resources:
|
|||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
images:
|
images:
|
||||||
- name: git.stinnesbeck.com/nils/mg4-server
|
- name: git.stinnesbeck.com/nils/mg4-server
|
||||||
newTag: "1.1.1"
|
newTag: "1.1.2"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nilsmg4-web",
|
"name": "nilsmg4-web",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nilsmg4-web",
|
"name": "nilsmg4-web",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.35.0",
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nilsmg4-web",
|
"name": "nilsmg4-web",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export default async function ChargingPage() {
|
|||||||
|
|
||||||
if (!installationId || sessions.length === 0) {
|
if (!installationId || sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Shell active="/charging" title="Şarj">
|
<Shell active="/charging" title="Charging">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="empty">
|
<p className="empty">
|
||||||
Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez.
|
No charging sessions yet. Only DC charges above 10 kW are metered; AC charging is not recorded.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
@@ -25,22 +25,22 @@ export default async function ChargingPage() {
|
|||||||
const total = sumCharging(sessions);
|
const total = sumCharging(sessions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell active="/charging" title="Şarj" subtitle={`${sessions.length} DC seans`}>
|
<Shell active="/charging" title="Charging" subtitle={`${sessions.length} DC session${sessions.length === 1 ? "" : "s"}`}>
|
||||||
<div className="tiles">
|
<div className="tiles">
|
||||||
<Tile label="Bataryaya giren net" value={precise(total.energyKwh)} unit="kWh" tone="gold" />
|
<Tile label="Net into battery" value={precise(total.energyKwh)} unit="kWh" tone="gold" />
|
||||||
<Tile label="Şebekeden alınan (+%10)" value={precise(total.gridEnergyKwh)} unit="kWh" tone="gold" />
|
<Tile label="Drawn from grid (+10%)" value={precise(total.gridEnergyKwh)} unit="kWh" tone="gold" />
|
||||||
<Tile label="Toplam ödenen" value={money(total.cost)} tone="gold" />
|
<Tile label="Total paid" value={money(total.cost)} tone="gold" />
|
||||||
<Tile
|
<Tile
|
||||||
label="Ortalama birim"
|
label="Average price"
|
||||||
value={total.gridEnergyKwh > 0 ? money(total.cost / total.gridEnergyKwh) : "—"}
|
value={total.gridEnergyKwh > 0 ? money(total.cost / total.gridEnergyKwh) : "—"}
|
||||||
note="kWh başına"
|
note="per kWh"
|
||||||
tone="teal"
|
tone="teal"
|
||||||
/>
|
/>
|
||||||
<Tile label="En yüksek güç" value={num(total.peakKw)} unit="kW" tone="teal" />
|
<Tile label="Peak power" value={num(total.peakKw)} unit="kW" tone="teal" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Seanslar</h2>
|
<h2 className="card-title">Sessions</h2>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const gained =
|
const gained =
|
||||||
@@ -59,7 +59,7 @@ export default async function ChargingPage() {
|
|||||||
<div className="session-meta">
|
<div className="session-meta">
|
||||||
{session.isDemo && "Demo · "}
|
{session.isDemo && "Demo · "}
|
||||||
{duration(session.durationSeconds)}
|
{duration(session.durationSeconds)}
|
||||||
{gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`}
|
{gained !== null && ` · ${num(session.startSocPercent as number)}% → ${num(session.endSocPercent as number)}%`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="session-energy">
|
<div className="session-energy">
|
||||||
@@ -77,16 +77,16 @@ export default async function ChargingPage() {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<div className="rows" style={{ marginTop: 14 }}>
|
<div className="rows" style={{ marginTop: 14 }}>
|
||||||
<Row label="Başlangıç" value={dateTime(session.startedAt)} />
|
<Row label="Start" value={dateTime(session.startedAt)} />
|
||||||
<Row label="Bitiş" value={dateTime(session.endedAt)} />
|
<Row label="End" value={dateTime(session.endedAt)} />
|
||||||
<Row label="Ölçülen süre" value={duration(session.durationSeconds)} />
|
<Row label="Metered duration" value={duration(session.durationSeconds)} />
|
||||||
<Row label="Bataryaya giren net" value={`${precise(session.energyKwh)} kWh`} />
|
<Row label="Net into battery" value={`${precise(session.energyKwh)} kWh`} />
|
||||||
<Row label="Şebekeden alınan (+%10)" value={`${precise(session.gridEnergyKwh ?? session.energyKwh * 1.1)} kWh`} />
|
<Row label="Drawn from grid (+10%)" value={`${precise(session.gridEnergyKwh ?? session.energyKwh * 1.1)} kWh`} />
|
||||||
<Row label="Kazanılan doluluk" value={gained === null ? "—" : `%${num(gained)}`} />
|
<Row label="Charge added" value={gained === null ? "—" : `${num(gained)}%`} />
|
||||||
<Row label="Tepe güç" value={peak > 0 ? `${num(peak)} kW` : "—"} />
|
<Row label="Peak power" value={peak > 0 ? `${num(peak)} kW` : "—"} />
|
||||||
<Row label="Ortalama güç" value={average > 0 ? `${num(average)} kW` : "—"} />
|
<Row label="Average power" value={average > 0 ? `${num(average)} kW` : "—"} />
|
||||||
<Row label="Birim fiyat" value={`${money(session.pricePerKwh)}/kWh`} />
|
<Row label="Unit price" value={`${money(session.pricePerKwh)}/kWh`} />
|
||||||
<Row label="Tutar" value={money(session.totalCost)} />
|
<Row label="Amount" value={money(session.totalCost)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export default async function ConsumptionPage({
|
|||||||
|
|
||||||
if (!installationId || days.length === 0) {
|
if (!installationId || days.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Shell active="/consumption" title="Tüketim">
|
<Shell active="/consumption" title="Consumption">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="empty">Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.</p>
|
<p className="empty">No daily consumption records yet. The car uploads them as it drives.</p>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
@@ -46,14 +46,14 @@ export default async function ConsumptionPage({
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
active="/consumption"
|
active="/consumption"
|
||||||
title="Tüketim"
|
title="Consumption"
|
||||||
subtitle={hasFilter ? (validFilter ? isoDate(selectedDate!) : "Seçili gün kaydı yok") : `${days.length} günlük kayıt`}
|
subtitle={hasFilter ? (validFilter ? isoDate(selectedDate!) : "No record for the selected day") : `${days.length} day${days.length === 1 ? "" : "s"} recorded`}
|
||||||
>
|
>
|
||||||
{/* Date filter */}
|
{/* Date filter */}
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<form className="filter-form" method="get" action="/consumption">
|
<form className="filter-form" method="get" action="/consumption">
|
||||||
<label className="filter-label" htmlFor="date-picker">
|
<label className="filter-label" htmlFor="date-picker">
|
||||||
Tarihe göre filtrele
|
Filter by date
|
||||||
</label>
|
</label>
|
||||||
<div className="filter-row">
|
<div className="filter-row">
|
||||||
<input
|
<input
|
||||||
@@ -65,9 +65,9 @@ export default async function ConsumptionPage({
|
|||||||
max={maxDate}
|
max={maxDate}
|
||||||
className="field date-input"
|
className="field date-input"
|
||||||
/>
|
/>
|
||||||
<button className="button filter-button" type="submit">Filtrele</button>
|
<button className="button filter-button" type="submit">Filter</button>
|
||||||
{hasFilter && (
|
{hasFilter && (
|
||||||
<Link className="filter-clear" href="/consumption">Temizle</Link>
|
<Link className="filter-clear" href="/consumption">Clear</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -80,60 +80,60 @@ export default async function ConsumptionPage({
|
|||||||
{validFilter && selected ? (
|
{validFilter && selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="tiles">
|
<div className="tiles">
|
||||||
<Tile label="Mesafe" value={num(selected.day.km)} unit="km" tone="teal" />
|
<Tile label="Distance" value={num(selected.day.km)} unit="km" tone="teal" />
|
||||||
<Tile label="Enerji" value={precise(selected.day.kwh)} unit="kWh" tone="gold" />
|
<Tile label="Energy" value={precise(selected.day.kwh)} unit="kWh" tone="gold" />
|
||||||
<Tile label="Ortalama" value={efficiency(selected.day.kwh, selected.day.km)} tone="teal" />
|
<Tile label="Average" value={efficiency(selected.day.kwh, selected.day.km)} tone="teal" />
|
||||||
<Tile
|
<Tile
|
||||||
label="Ortalama hız"
|
label="Average speed"
|
||||||
value={selected.day.hours > 0 ? num(selected.day.km / selected.day.hours) : "—"}
|
value={selected.day.hours > 0 ? num(selected.day.km / selected.day.hours) : "—"}
|
||||||
unit={selected.day.hours > 0 ? "km/h" : undefined}
|
unit={selected.day.hours > 0 ? "km/h" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
<Row label="Sürüş süresi" value={duration(selected.day.hours * 3600)} />
|
<Row label="Driving time" value={duration(selected.day.hours * 3600)} />
|
||||||
<Row label="Doluluk düşüşü" value={`%${num(selected.day.socDrop)}`} />
|
<Row label="SoC used" value={`${num(selected.day.socDrop)}%`} />
|
||||||
<Row label="O gün sonu ömür boyu" value={selected.lifetime ? `${num(selected.lifetime.km)} km` : "—"} />
|
<Row label="Lifetime at end of day" value={selected.lifetime ? `${num(selected.lifetime.km)} km` : "—"} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="empty">Bu tarih için tüketim kaydı yok.</p>
|
<p className="empty">No consumption recorded for this date.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="tiles">
|
<div className="tiles">
|
||||||
<Tile label="Toplam mesafe" value={num(total.km)} unit="km" tone="teal" />
|
<Tile label="Total distance" value={num(total.km)} unit="km" tone="teal" />
|
||||||
<Tile label="Toplam enerji" value={precise(total.kwh)} unit="kWh" tone="gold" />
|
<Tile label="Total energy" value={precise(total.kwh)} unit="kWh" tone="gold" />
|
||||||
<Tile label="Ortalama" value={efficiency(total.kwh, total.km)} tone="teal" />
|
<Tile label="Average" value={efficiency(total.kwh, total.km)} tone="teal" />
|
||||||
<Tile
|
<Tile
|
||||||
label="Ortalama hız"
|
label="Average speed"
|
||||||
value={total.hours > 0 ? num(total.km / total.hours) : "—"}
|
value={total.hours > 0 ? num(total.km / total.hours) : "—"}
|
||||||
unit={total.hours > 0 ? "km/h" : undefined}
|
unit={total.hours > 0 ? "km/h" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Günlük enerji</h2>
|
<h2 className="card-title">Daily energy</h2>
|
||||||
<DailyEnergyChart days={chartDays} />
|
<DailyEnergyChart days={chartDays} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Verimlilik eğilimi</h2>
|
<h2 className="card-title">Efficiency trend</h2>
|
||||||
<EfficiencyChart days={chartDays} />
|
<EfficiencyChart days={chartDays} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Ömür boyu</h2>
|
<h2 className="card-title">Lifetime</h2>
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
<Row label="Mesafe" value={`${num(lifetime.km)} km`} />
|
<Row label="Distance" value={`${num(lifetime.km)} km`} />
|
||||||
<Row label="Enerji" value={`${precise(lifetime.kwh)} kWh`} />
|
<Row label="Energy" value={`${precise(lifetime.kwh)} kWh`} />
|
||||||
<Row label="Ortalama" value={efficiency(lifetime.kwh, lifetime.km)} />
|
<Row label="Average" value={efficiency(lifetime.kwh, lifetime.km)} />
|
||||||
<Row label="Sürüş süresi" value={duration(lifetime.hours * 3600)} />
|
<Row label="Driving time" value={duration(lifetime.hours * 3600)} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Gün gün</h2>
|
<h2 className="card-title">Day by day</h2>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{table.map((day) => {
|
{table.map((day) => {
|
||||||
const isSelected = hasFilter && day.date === selectedDate;
|
const isSelected = hasFilter && day.date === selectedDate;
|
||||||
@@ -158,13 +158,13 @@ export default async function ConsumptionPage({
|
|||||||
</summary>
|
</summary>
|
||||||
<div className="session-body">
|
<div className="session-body">
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
<Row label="Sürüş süresi" value={duration(day.day.hours * 3600)} />
|
<Row label="Driving time" value={duration(day.day.hours * 3600)} />
|
||||||
<Row
|
<Row
|
||||||
label="Ortalama hız"
|
label="Average speed"
|
||||||
value={day.day.hours > 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"}
|
value={day.day.hours > 0 ? `${num(day.day.km / day.day.hours)} km/h` : "—"}
|
||||||
/>
|
/>
|
||||||
<Row label="Doluluk düşüşü" value={`%${num(day.day.socDrop)}`} />
|
<Row label="SoC used" value={`${num(day.day.socDrop)}%`} />
|
||||||
<Row label="O gün sonu ömür boyu" value={day.lifetime ? `${num(day.lifetime.km)} km` : "—"} />
|
<Row label="Lifetime at end of day" value={day.lifetime ? `${num(day.lifetime.km)} km` : "—"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import "./globals.css";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Nils' MG4",
|
title: "Nils' MG4",
|
||||||
description: "MG4 tüketim ve şarj geçmişi",
|
description: "MG4 consumption and charging history",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -16,7 +16,7 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="tr">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function LoginPage({
|
|||||||
<div className="login">
|
<div className="login">
|
||||||
<form className="login-card" action="/api/session" method="post">
|
<form className="login-card" action="/api/session" method="post">
|
||||||
<h1 className="title">Nils' MG4</h1>
|
<h1 className="title">Nils' MG4</h1>
|
||||||
<p className="subtitle">Tüketim ve şarj geçmişi</p>
|
<p className="subtitle">Consumption and charging history</p>
|
||||||
{/*
|
{/*
|
||||||
A hidden username so password managers will offer to save this.
|
A hidden username so password managers will offer to save this.
|
||||||
Most of them ignore a lone password field — they key a saved entry on
|
Most of them ignore a lone password field — they key a saved entry on
|
||||||
@@ -33,15 +33,15 @@ export default async function LoginPage({
|
|||||||
className="field"
|
className="field"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Parola"
|
placeholder="Password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button className="button" type="submit">
|
<button className="button" type="submit">
|
||||||
Giriş
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
{error && <p className="error">Parola hatalı.</p>}
|
{error && <p className="error">Wrong password.</p>}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-25
@@ -12,10 +12,10 @@ export default async function OverviewPage() {
|
|||||||
|
|
||||||
if (!installationId) {
|
if (!installationId) {
|
||||||
return (
|
return (
|
||||||
<Shell active="/" title="Özet">
|
<Shell active="/" title="Overview">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="empty">
|
<p className="empty">
|
||||||
Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek.
|
No data yet. It appears here once the car sends its first daily record.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
@@ -31,26 +31,26 @@ export default async function OverviewPage() {
|
|||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
active="/"
|
active="/"
|
||||||
title="Özet"
|
title="Overview"
|
||||||
subtitle={installationCount > 1 ? `${installationCount} araçtan en günceli` : "MG4"}
|
subtitle={installationCount > 1 ? `Most recent of ${installationCount} cars` : "MG4"}
|
||||||
>
|
>
|
||||||
<div className="tiles">
|
<div className="tiles">
|
||||||
<Tile
|
<Tile
|
||||||
label="Şebekeden alınan"
|
label="Drawn from grid"
|
||||||
value={precise(charge.gridEnergyKwh)}
|
value={precise(charge.gridEnergyKwh)}
|
||||||
unit="kWh"
|
unit="kWh"
|
||||||
note={`${charge.sessions} DC şarj`}
|
note={`${charge.sessions} DC charge${charge.sessions === 1 ? "" : "s"}`}
|
||||||
tone="gold"
|
tone="gold"
|
||||||
/>
|
/>
|
||||||
<Tile
|
<Tile
|
||||||
label="Ödenen"
|
label="Paid"
|
||||||
value={money(charge.cost)}
|
value={money(charge.cost)}
|
||||||
note={charge.gridEnergyKwh > 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh ort.` : undefined}
|
note={charge.gridEnergyKwh > 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh avg.` : undefined}
|
||||||
tone="gold"
|
tone="gold"
|
||||||
/>
|
/>
|
||||||
<Tile label="Son 30 gün" value={num(recent.km)} unit="km" note={efficiency(recent.kwh, recent.km)} tone="teal" />
|
<Tile label="Last 30 days" value={num(recent.km)} unit="km" note={efficiency(recent.kwh, recent.km)} tone="teal" />
|
||||||
<Tile
|
<Tile
|
||||||
label="Ömür boyu"
|
label="Lifetime"
|
||||||
value={lifetime ? num(lifetime.km) : "—"}
|
value={lifetime ? num(lifetime.km) : "—"}
|
||||||
unit="km"
|
unit="km"
|
||||||
note={lifetime ? efficiency(lifetime.kwh, lifetime.km) : undefined}
|
note={lifetime ? efficiency(lifetime.kwh, lifetime.km) : undefined}
|
||||||
@@ -59,39 +59,39 @@ export default async function OverviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Son 30 gün</h2>
|
<h2 className="card-title">Last 30 days</h2>
|
||||||
<DailyEnergyChart days={days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
|
<DailyEnergyChart days={days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{lastSession && (
|
{lastSession && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Son şarj</h2>
|
<h2 className="card-title">Last charge</h2>
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
<Row label="Tarih" value={dateTime(lastSession.startedAt)} />
|
<Row label="Date" value={dateTime(lastSession.startedAt)} />
|
||||||
<Row label="Bataryaya giren net" value={`${precise(lastSession.energyKwh)} kWh`} />
|
<Row label="Net into battery" value={`${precise(lastSession.energyKwh)} kWh`} />
|
||||||
<Row label="Şebekeden alınan (+%10)" value={`${precise(lastSession.gridEnergyKwh ?? lastSession.energyKwh * 1.1)} kWh`} />
|
<Row label="Drawn from grid (+10%)" value={`${precise(lastSession.gridEnergyKwh ?? lastSession.energyKwh * 1.1)} kWh`} />
|
||||||
<Row
|
<Row
|
||||||
label="Doluluk"
|
label="State of charge"
|
||||||
value={
|
value={
|
||||||
lastSession.startSocPercent !== undefined && lastSession.endSocPercent !== undefined
|
lastSession.startSocPercent !== undefined && lastSession.endSocPercent !== undefined
|
||||||
? `%${num(lastSession.startSocPercent)} → %${num(lastSession.endSocPercent)}`
|
? `${num(lastSession.startSocPercent)}% → ${num(lastSession.endSocPercent)}%`
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label="Süre" value={duration(lastSession.durationSeconds)} />
|
<Row label="Duration" value={duration(lastSession.durationSeconds)} />
|
||||||
<Row label="Ücret" value={money(lastSession.totalCost)} />
|
<Row label="Cost" value={money(lastSession.totalCost)} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Batarya ve toplamlar</h2>
|
<h2 className="card-title">Battery and totals</h2>
|
||||||
<div className="rows">
|
<div className="rows">
|
||||||
<Row label="Sağlık (SOH)" value={soh === undefined ? "—" : `%${num(soh)}`} />
|
<Row label="Health (SoH)" value={soh === undefined ? "—" : `${num(soh)}%`} />
|
||||||
<Row label="Toplam şarj süresi" value={duration(charge.seconds)} />
|
<Row label="Total charging time" value={duration(charge.seconds)} />
|
||||||
<Row label="Görülen en yüksek güç" value={charge.peakKw > 0 ? `${num(charge.peakKw)} kW` : "—"} />
|
<Row label="Highest power seen" value={charge.peakKw > 0 ? `${num(charge.peakKw)} kW` : "—"} />
|
||||||
<Row label="Şarjla kazanılan doluluk" value={charge.socGained > 0 ? `%${num(charge.socGained)}` : "—"} />
|
<Row label="Charge added" value={charge.socGained > 0 ? `${num(charge.socGained)}%` : "—"} />
|
||||||
<Row label="Kayıtlı gün" value={`${days.length}`} />
|
<Row label="Days recorded" value={`${days.length}`} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export interface DayDatum {
|
|||||||
* distinction the telemetry side makes between a failed read and a zero.
|
* distinction the telemetry side makes between a failed read and a zero.
|
||||||
*/
|
*/
|
||||||
export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
|
export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
|
||||||
if (days.length === 0) return <p className="empty">Henüz günlük kayıt yok</p>;
|
if (days.length === 0) return <p className="empty">No daily records yet</p>;
|
||||||
const plot = PLOT;
|
const plot = PLOT;
|
||||||
const max = niceMax(Math.max(...days.map((day) => day.kwh)));
|
const max = niceMax(Math.max(...days.map((day) => day.kwh)));
|
||||||
const slot = innerWidth(plot) / days.length;
|
const slot = innerWidth(plot) / days.length;
|
||||||
@@ -86,7 +86,7 @@ export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
||||||
aria-label="Gün gün tüketilen enerji">
|
aria-label="Energy used per day">
|
||||||
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
|
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
|
||||||
{days.map((day, index) => {
|
{days.map((day, index) => {
|
||||||
const height = (day.kwh / max) * innerHeight(plot);
|
const height = (day.kwh / max) * innerHeight(plot);
|
||||||
@@ -116,7 +116,7 @@ export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
|
|||||||
<div className="legend">
|
<div className="legend">
|
||||||
<span>
|
<span>
|
||||||
<span className="swatch" style={{ background: GOLD }} />
|
<span className="swatch" style={{ background: GOLD }} />
|
||||||
Günlük enerji (kWh)
|
Daily energy (kWh)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -128,7 +128,7 @@ export function EfficiencyChart({ days }: { days: DayDatum[] }) {
|
|||||||
const points = days
|
const points = days
|
||||||
.filter((day) => day.km >= 1 && day.kwh > 0)
|
.filter((day) => day.km >= 1 && day.kwh > 0)
|
||||||
.map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km }));
|
.map((day) => ({ date: day.date, value: (day.kwh * 100) / day.km }));
|
||||||
if (points.length < 2) return <p className="empty">Grafik için yeterli gün yok</p>;
|
if (points.length < 2) return <p className="empty">Not enough days for a chart</p>;
|
||||||
const plot = PLOT;
|
const plot = PLOT;
|
||||||
const max = niceMax(Math.max(...points.map((point) => point.value)));
|
const max = niceMax(Math.max(...points.map((point) => point.value)));
|
||||||
const step = innerWidth(plot) / Math.max(1, points.length - 1);
|
const step = innerWidth(plot) / Math.max(1, points.length - 1);
|
||||||
@@ -144,7 +144,7 @@ export function EfficiencyChart({ days }: { days: DayDatum[] }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
||||||
aria-label="Gün gün ortalama tüketim">
|
aria-label="Average consumption per day">
|
||||||
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
|
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
|
||||||
<line
|
<line
|
||||||
x1={plot.padLeft}
|
x1={plot.padLeft}
|
||||||
@@ -176,7 +176,7 @@ export function EfficiencyChart({ days }: { days: DayDatum[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="swatch" style={{ background: GOLD }} />
|
<span className="swatch" style={{ background: GOLD }} />
|
||||||
Ortalama {average.toFixed(1)}
|
Average {average.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -196,7 +196,7 @@ export interface CurveDatum {
|
|||||||
* them — the taper is only legible next to the percentage that caused it.
|
* them — the taper is only legible next to the percentage that caused it.
|
||||||
*/
|
*/
|
||||||
export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
|
export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
|
||||||
if (curve.length < 2) return <p className="empty">Bu seans için eğri kaydı yok</p>;
|
if (curve.length < 2) return <p className="empty">No curve recorded for this session</p>;
|
||||||
const plot = { ...PLOT, padRight: 26 };
|
const plot = { ...PLOT, padRight: 26 };
|
||||||
const times = curve.map((point) => new Date(point.timestamp).getTime());
|
const times = curve.map((point) => new Date(point.timestamp).getTime());
|
||||||
const start = times[0];
|
const start = times[0];
|
||||||
@@ -223,7 +223,7 @@ export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
|
||||||
aria-label="Şarj gücü ve doluluk eğrisi">
|
aria-label="Charging power and state-of-charge curve">
|
||||||
<Grid plot={plot} max={maxPower} format={(value) => value.toFixed(0)} />
|
<Grid plot={plot} max={maxPower} format={(value) => value.toFixed(0)} />
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="powerFill" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="powerFill" x1="0" x2="0" y1="0" y2="1">
|
||||||
@@ -241,20 +241,20 @@ export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
|
|||||||
0%
|
0%
|
||||||
</text>
|
</text>
|
||||||
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
|
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
|
||||||
0 dk
|
0 min
|
||||||
</text>
|
</text>
|
||||||
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
|
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
|
||||||
{Math.round(span / 60000)} dk
|
{Math.round(span / 60000)} min
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="legend">
|
<div className="legend">
|
||||||
<span>
|
<span>
|
||||||
<span className="swatch" style={{ background: GOLD }} />
|
<span className="swatch" style={{ background: GOLD }} />
|
||||||
Güç (kW)
|
Power (kW)
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="swatch" style={{ background: CYAN }} />
|
<span className="swatch" style={{ background: CYAN }} />
|
||||||
Doluluk (%)
|
State of charge (%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import Link from "next/link";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ href: "/", label: "Özet", icon: "◈" },
|
{ href: "/", label: "Overview", icon: "◈" },
|
||||||
{ href: "/consumption", label: "Tüketim", icon: "▤" },
|
{ href: "/consumption", label: "Consumption", icon: "▤" },
|
||||||
{ href: "/charging", label: "Şarj", icon: "⚡" },
|
{ href: "/charging", label: "Charging", icon: "⚡" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Tab = (typeof TABS)[number]["href"];
|
export type Tab = (typeof TABS)[number]["href"];
|
||||||
@@ -42,7 +42,7 @@ export function Shell({
|
|||||||
*/}
|
*/}
|
||||||
<form action="/logout" method="post">
|
<form action="/logout" method="post">
|
||||||
<button className="signout" type="submit">
|
<button className="signout" type="submit">
|
||||||
Çıkış
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/** Turkish formatting for every user-facing number and date. */
|
/** English formatting (en-GB: 24-hour clock, day-month dates) with euro for every number and date. */
|
||||||
|
|
||||||
const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
|
const NUMBER = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 1 });
|
||||||
const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const PRECISE = new Intl.NumberFormat("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 });
|
const CURRENCY = new Intl.NumberFormat("en-GB", { style: "currency", currency: "EUR", maximumFractionDigits: 2 });
|
||||||
const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" });
|
const DATE = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
||||||
const DATE_TIME = new Intl.DateTimeFormat("tr-TR", {
|
const DATE_TIME = new Intl.DateTimeFormat("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -32,13 +32,13 @@ export function dateTime(value: Date | string): string {
|
|||||||
return DATE_TIME.format(new Date(value));
|
return DATE_TIME.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `2 sa 05 dk` — the same shape the car's charging pane uses. */
|
/** `2 h 05 min` — the same shape the car's charging pane uses. */
|
||||||
export function duration(seconds: number): string {
|
export function duration(seconds: number): string {
|
||||||
const total = Math.max(0, Math.round(seconds));
|
const total = Math.max(0, Math.round(seconds));
|
||||||
const hours = Math.floor(total / 3600);
|
const hours = Math.floor(total / 3600);
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
if (hours === 0) return `${minutes} dk`;
|
if (hours === 0) return `${minutes} min`;
|
||||||
return `${hours} sa ${String(minutes).padStart(2, "0")} dk`;
|
return `${hours} h ${String(minutes).padStart(2, "0")} min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */
|
/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */
|
||||||
|
|||||||
Reference in New Issue
Block a user