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:
2026-10-17 14:39:59 +02:00
co-authored by Claude Opus 5.5
parent 818442db08
commit 538d03361d
14 changed files with 119 additions and 119 deletions
+4 -4
View File
@@ -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());
} }
+1 -1
View File
@@ -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"/>
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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",
+21 -21
View File
@@ -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>
+31 -31
View File
@@ -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>
+2 -2
View File
@@ -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>
); );
+4 -4
View File
@@ -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&apos; MG4</h1> <h1 className="title">Nils&apos; 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
View File
@@ -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>
+12 -12
View File
@@ -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>
</> </>
+4 -4
View File
@@ -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>
+9 -9
View File
@@ -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. */