Replace the Express service with a Next.js app on Vercel

The standalone service in server/ was never deployed anywhere, and keeping it
alongside a Vercel app would mean maintaining two implementations of the same
validation rules. It is deleted; web/ is the only ingest path now. Its routes
live at /v1/... rather than under /api, so the Android client's URL building is
unchanged and CONSUMPTION_API_URL stays a bare origin.

Credentials are split in two. INGEST_TOKEN is baked into the published APK and
must be assumed extractable — anyone can unzip an APK and read its strings — so
it opens the PUT endpoints and nothing else. Reading the history needs the
dashboard password, which is never compiled into anything and reaches the
browser as an HMAC-signed httpOnly cookie. The MongoDB URI stays a server-side
environment variable; the car has never held one and still does not.

The dashboard is a phone-shaped reader for the same data: totals for energy
taken and paid, a daily energy chart, an efficiency trend, and every DC session
with its power/SOC curve. It reuses the head unit's OLED palette so the two read
as one app. Every page is a server component and the charts are hand-rolled SVG,
so the whole thing renders without shipping client JavaScript.

MongoClient is cached on globalThis: a fresh client per request means a fresh
pool per request, and Atlas starts refusing connections long before the traffic
would justify it. Index creation moved to the first write of each warm lambda,
since a serverless deployment has no boot step to hang it on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-08 14:21:37 +03:00
co-authored by Claude Opus 5
parent e9bfa1217b
commit 8242797ece
40 changed files with 3120 additions and 1319 deletions
-4
View File
@@ -1,4 +0,0 @@
MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/
MONGODB_DATABASE=fatihsmg4
API_TOKEN=replace-with-a-long-random-secret
PORT=8080
-3
View File
@@ -1,3 +0,0 @@
node_modules/
.env
npm-debug.log*
-9
View File
@@ -1,9 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
USER node
EXPOSE 8080
CMD ["node", "src/server.js"]
-27
View File
@@ -1,27 +0,0 @@
# FatihsMG4 consumption API
The Android app never connects to MongoDB directly and never contains a MongoDB URI. This
service stores completed daily totals in `daily_consumption` and each completed charge,
including its SOC/power curve and price, in `charging_sessions`.
## Run
1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token.
2. Export the variables (Node does not load `.env` automatically), then run `npm install` and
`npm start`.
3. Build the Android app with the public HTTPS service address and the same token:
```sh
CONSUMPTION_API_URL=https://your-api.example.com \
CONSUMPTION_API_TOKEN=your-random-token \
./gradlew assembleUnstableRelease
```
If either Android build variable is missing, cloud sync is disabled and local consumption
tracking continues unchanged. The car retries locally queued charging sessions every five minutes.
Daily consumption is uploaded once after the day closes; missed days are retried in order.
The future mobile client can read authenticated history from:
- `GET /v1/consumption/:installationId`
- `GET /v1/charging-sessions/:installationId`
-1040
View File
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
{
"name": "fatihsmg4-consumption-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"express": "^5.1.0",
"mongodb": "^6.20.0"
}
}
-130
View File
@@ -1,130 +0,0 @@
import crypto from "node:crypto";
import express from "express";
export function createApp({ consumptionCollection, chargingCollection, apiToken }) {
if (!consumptionCollection || !chargingCollection) throw new Error("collections are required");
if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters");
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "256kb", strict: true }));
const auth = authenticate(apiToken);
app.get("/health", (_request, response) => response.json({ status: "ok" }));
app.put("/v1/consumption/daily", auth, route(async (request, response) => {
const record = validateConsumption(request.body);
const updatedAt = new Date();
await consumptionCollection.updateOne(
{ installationId: record.installationId, date: record.date },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
response.status(204).end();
}));
app.put("/v1/charging-sessions/:sessionId", auth, route(async (request, response) => {
const record = validateChargingSession(request.body, request.params.sessionId);
const updatedAt = new Date();
await chargingCollection.updateOne(
{ installationId: record.installationId, sessionId: record.sessionId },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } }, { upsert: true });
response.status(204).end();
}));
app.get("/v1/consumption/:installationId", auth, route(async (request, response) => {
const installationId = validateUuid(request.params.installationId, "installationId");
const records = await consumptionCollection.find({ installationId }).sort({ date: -1 }).limit(400).toArray();
response.json({ records });
}));
app.get("/v1/charging-sessions/:installationId", auth, route(async (request, response) => {
const installationId = validateUuid(request.params.installationId, "installationId");
const records = await chargingCollection.find({ installationId }).sort({ startedAt: -1 }).limit(200).toArray();
response.json({ records });
}));
app.use((error, _request, response, _next) => {
if (error instanceof ValidationError || error?.type === "entity.parse.failed" || error?.type === "entity.too.large") {
response.status(400).json({ error: error.message || "invalid request" });
return;
}
console.error("Request failed", error);
response.status(500).json({ error: "internal server error" });
});
return app;
}
function route(handler) { return (request, response, next) => Promise.resolve(handler(request, response)).catch(next); }
function authenticate(expected) {
const expectedBytes = Buffer.from(expected);
return (request, response, next) => {
const header = request.get("authorization") ?? "";
const supplied = header.startsWith("Bearer ") ? header.slice(7) : "";
const suppliedBytes = Buffer.from(supplied);
if (suppliedBytes.length !== expectedBytes.length || !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) {
response.status(401).json({ error: "unauthorized" }); return;
}
next();
};
}
class ValidationError extends Error {}
function validateConsumption(body) {
validateBody(body);
if (!/^\d{4}-\d{2}-\d{2}$/.test(body.date ?? "")) throw new ValidationError("date is invalid");
return {
installationId: validateUuid(body.installationId, "installationId"), date: body.date,
recordedAt: validateDate(body.recordedAt, "recordedAt"), day: validateTotals(body.day, "day"),
lifetime: validateTotals(body.lifetime, "lifetime"),
...(body.sohPercent === undefined ? {} : { sohPercent: bounded(body.sohPercent, 0, 100, "sohPercent") })
};
}
function validateChargingSession(body, pathSessionId) {
validateBody(body);
const sessionId = validateUuid(body.sessionId, "sessionId");
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
if (!Array.isArray(body.curve) || body.curve.length > 720) throw new ValidationError("curve is invalid");
const curve = body.curve.map((point) => ({
timestamp: validateDate(point.timestampMs, "curve.timestampMs"),
powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"),
...(point.socPercent === undefined ? {} : { socPercent: bounded(point.socPercent, 0, 100, "curve.socPercent") })
}));
return {
installationId: validateUuid(body.installationId, "installationId"), sessionId,
startedAt: validateDate(body.startedAt, "startedAt"), endedAt: validateDate(body.endedAt, "endedAt"),
durationSeconds: bounded(body.durationSeconds, 0, 604800, "durationSeconds"),
energyKwh: bounded(body.energyKwh, 0, 1000, "energyKwh"),
pricePerKwh: bounded(body.pricePerKwh, 0, 1_000_000, "pricePerKwh"),
totalCost: bounded(body.totalCost, 0, 1_000_000_000, "totalCost"),
...(body.startSocPercent === undefined ? {} : { startSocPercent: bounded(body.startSocPercent, 0, 100, "startSocPercent") }),
...(body.endSocPercent === undefined ? {} : { endSocPercent: bounded(body.endSocPercent, 0, 100, "endSocPercent") }), curve
};
}
function validateBody(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required");
}
function validateUuid(value, name) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value ?? ""))
throw new ValidationError(`${name} is invalid`);
return value;
}
function validateDate(value, name) {
if (!Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${name} is invalid`);
return new Date(value);
}
function bounded(value, min, max, name) {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max)
throw new ValidationError(`${name} is invalid`);
return value;
}
function validateTotals(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`);
const result = {};
for (const field of ["km", "kwh", "hours", "socDrop"]) {
const number = value[field];
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000)
throw new ValidationError(`${name}.${field} is invalid`);
result[field] = number;
}
return result;
}
-32
View File
@@ -1,32 +0,0 @@
import { MongoClient } from "mongodb";
import { createApp } from "./app.js";
const { MONGODB_URI, API_TOKEN } = process.env;
const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4";
const port = Number(process.env.PORT || 8080);
if (!MONGODB_URI) throw new Error("MONGODB_URI is required");
if (!API_TOKEN) throw new Error("API_TOKEN is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid");
const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 });
await client.connect();
const database = client.db(databaseName);
const consumptionCollection = database.collection("daily_consumption");
const chargingCollection = database.collection("charging_sessions");
await consumptionCollection.createIndex({ installationId: 1, date: 1 }, { unique: true });
await consumptionCollection.createIndex({ installationId: 1, date: -1 });
await chargingCollection.createIndex({ installationId: 1, sessionId: 1 }, { unique: true });
await chargingCollection.createIndex({ installationId: 1, startedAt: -1 });
const app = createApp({ consumptionCollection, chargingCollection, apiToken: API_TOKEN });
const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`));
async function shutdown() {
server.close(async () => {
await client.close();
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
-57
View File
@@ -1,57 +0,0 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createApp } from "../src/app.js";
const token = "a-secure-test-token-with-24-chars";
const consumptionWrites = [], chargingWrites = [];
const readable = (writes) => ({
async updateOne(filter, update, options) { writes.push({ filter, update, options }); },
find() { return { sort() { return this; }, limit() { return this; }, async toArray() { return []; } }; }
});
let server, baseUrl;
before(async () => {
const app = createApp({ consumptionCollection: readable(consumptionWrites),
chargingCollection: readable(chargingWrites), apiToken: token });
await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); });
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((resolve) => server.close(resolve)));
const auth = { "content-type": "application/json", authorization: `Bearer ${token}` };
const installationId = "123e4567-e89b-42d3-a456-426614174000";
const consumption = { installationId, recordedAt: 1791417600000, date: "2026-10-08",
day: { km: 12, kwh: 2.1, hours: .4, socDrop: 4 },
lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, sohPercent: 91.5 };
test("rejects requests without the API token", async () => {
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", body: JSON.stringify(consumption), headers: { "content-type": "application/json" } });
assert.equal(response.status, 401);
});
test("upserts one daily consumption record", async () => {
const response = await fetch(`${baseUrl}/v1/consumption/daily`, { method: "PUT", headers: auth, body: JSON.stringify(consumption) });
assert.equal(response.status, 204);
assert.deepEqual(consumptionWrites.at(-1).filter, { installationId, date: "2026-10-08" });
assert.equal(consumptionWrites.at(-1).update.$set.day.kwh, 2.1);
});
test("upserts a complete charging session with its curve and price", async () => {
const sessionId = "223e4567-e89b-42d3-a456-426614174001";
const session = { installationId, sessionId, startedAt: 1791417600000, endedAt: 1791421200000,
durationSeconds: 3600, energyKwh: 50, pricePerKwh: 8.5, totalCost: 425,
startSocPercent: 10, endSocPercent: 90,
curve: [{ timestampMs: 1791417600000, socPercent: 10, powerKw: 72 }] };
const response = await fetch(`${baseUrl}/v1/charging-sessions/${sessionId}`,
{ method: "PUT", headers: auth, body: JSON.stringify(session) });
assert.equal(response.status, 204);
assert.deepEqual(chargingWrites.at(-1).filter, { installationId, sessionId });
assert.equal(chargingWrites.at(-1).update.$set.totalCost, 425);
assert.equal(chargingWrites.at(-1).update.$set.curve[0].powerKw, 72);
});
test("read APIs are authenticated and mobile-client ready", async () => {
const response = await fetch(`${baseUrl}/v1/charging-sessions/${installationId}`, { headers: { authorization: `Bearer ${token}` } });
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { records: [] });
});
+14
View File
@@ -0,0 +1,14 @@
# MongoDB Atlas connection string. Never leaves the server.
MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/?retryWrites=true&w=majority
MONGODB_DATABASE=fatihsmg4
# Write-only token the car sends as `Authorization: Bearer ...` on the PUT endpoints.
# Generate with: openssl rand -hex 32
INGEST_TOKEN=
# Password for the web dashboard. Anyone who knows it can read the history.
DASHBOARD_PASSWORD=
# Signs the session cookie. Rotating it logs every browser out.
# Generate with: openssl rand -hex 32
SESSION_SECRET=
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
next-env.d.ts
*.tsbuildinfo
+68
View File
@@ -0,0 +1,68 @@
# FatihsMG4 web
Next.js app on Vercel: the ingest API the car writes to, and a phone-shaped dashboard for
reading the history back. Replaces the standalone Express service that used to live in
`server/`.
**The car never connects to MongoDB and never holds a MongoDB URI.** The URI is a Vercel
environment variable and is only ever read by a server-side route.
## Two credentials, on purpose
| | Who holds it | What it opens |
|---|---|---|
| `INGEST_TOKEN` | the car, in the APK | the `PUT` endpoints — write only |
| `DASHBOARD_PASSWORD` | you, in a browser | the dashboard and the `GET` history |
The APK is published, so its token must be assumed extractable: anyone can `unzip` an APK
and read its strings. That is survivable here because the write token cannot read anything
back. The password is never compiled into anything.
## Environment variables
Set all five in Vercel → Settings → Environment Variables, for Production and Preview.
| Name | Notes |
|---|---|
| `MONGODB_URI` | Atlas connection string |
| `MONGODB_DATABASE` | defaults to `fatihsmg4` |
| `INGEST_TOKEN` | `openssl rand -hex 32` |
| `DASHBOARD_PASSWORD` | at least 8 characters, or login is refused outright |
| `SESSION_SECRET` | `openssl rand -hex 32`; rotating it signs every browser out |
Atlas network access must allow Vercel's egress, which is not a fixed address on the Hobby
plan — either allow `0.0.0.0/0` and rely on the database user's credentials, or use Atlas
private networking on a paid plan.
## Endpoints
Write, `Authorization: Bearer <INGEST_TOKEN>`:
- `PUT /v1/consumption/daily`
- `PUT /v1/charging-sessions/:sessionId`
Read, session cookie or `Authorization: Bearer <DASHBOARD_PASSWORD>`:
- `GET /v1/history/:installationId/consumption`
- `GET /v1/history/:installationId/charging`
`GET /v1/health` is unauthenticated and returns `{"status":"ok"}`.
The car builds its URLs as `CONSUMPTION_API_URL + /v1/...`, so the Android build variable
is the bare origin — `https://your-app.vercel.app`, no trailing path.
## Indexes
There is no boot step on Vercel, so `ensureIndexes()` runs on the first write of each warm
lambda and is skipped afterwards. The unique indexes on `(installationId, date)` and
`(installationId, sessionId)` are what make the upserts idempotent when the car retries a
record it could not confirm.
## Local development
```sh
cp .env.example .env.local # Next loads .env.local automatically
npm install
npm run dev
npm test # pure validation and session-signing tests, no database
```
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Every page reads live telemetry; a cached shell would show yesterday's drive.
experimental: { serverActions: { bodySizeLimit: "1mb" } },
};
export default nextConfig;
+1145
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "fatihsmg4-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "node --test --experimental-strip-types test/*.test.ts"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"mongodb": "^6.20.0",
"next": "^15.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"typescript": "^5.8.3"
},
"type": "module"
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE, checkDashboardPassword, issueSessionValue, sessionCookieOptions } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Deliberately slow to fail. The password is the only thing between the open internet and
* the history, and a quarter second is unnoticeable once but ruinous for anyone working
* through a list.
*/
export async function POST(request: Request) {
const form = await request.formData();
const password = String(form.get("password") ?? "");
if (!checkDashboardPassword(password)) {
await new Promise((resolve) => setTimeout(resolve, 250));
return NextResponse.redirect(new URL("/login?error=1", request.url), { status: 303 });
}
const store = await cookies();
store.set(SESSION_COOKIE, issueSessionValue(), sessionCookieOptions);
return NextResponse.redirect(new URL("/", request.url), { status: 303 });
}
+97
View File
@@ -0,0 +1,97 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumCharging } from "@/lib/data";
import { dateTime, duration, money, num, precise } from "@/lib/format";
import { ChargeCurveChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function ChargingPage() {
await requireSession();
const { installationId, sessions } = await loadOverview(1, 200);
if (!installationId || sessions.length === 0) {
return (
<Shell active="/charging" title="Şarj">
<div className="card">
<p className="empty">
Şarj kaydı yok. Yalnızca 10 kW üstü DC şarjlar ölçülür; AC şarj kaydedilmez.
</p>
</div>
</Shell>
);
}
const total = sumCharging(sessions);
return (
<Shell active="/charging" title="Şarj" subtitle={`${sessions.length} DC seans`}>
<div className="tiles">
<Tile label="Toplam alınan" value={precise(total.energyKwh)} unit="kWh" tone="gold" />
<Tile label="Toplam ödenen" value={money(total.cost)} tone="gold" />
<Tile
label="Ortalama birim"
value={total.energyKwh > 0 ? money(total.cost / total.energyKwh) : "—"}
note="kWh başına"
tone="teal"
/>
<Tile label="En yüksek güç" value={num(total.peakKw)} unit="kW" tone="teal" />
</div>
<section className="card">
<h2 className="card-title">Seanslar</h2>
<ul className="list">
{sessions.map((session) => {
const gained =
session.startSocPercent !== undefined && session.endSocPercent !== undefined
? session.endSocPercent - session.startSocPercent
: null;
const peak = session.curve.reduce((max, point) => Math.max(max, point.powerKw), 0);
const average =
session.durationSeconds > 0 ? (session.energyKwh * 3600) / session.durationSeconds : 0;
return (
<li key={session.sessionId}>
<details className="session">
<summary>
<div>
<div className="session-when">{dateTime(session.startedAt)}</div>
<div className="session-meta">
{duration(session.durationSeconds)}
{gained !== null && ` · %${num(session.startSocPercent as number)} → %${num(session.endSocPercent as number)}`}
</div>
</div>
<div className="session-energy">
{precise(session.energyKwh)}
<span className="session-cost"> kWh</span>
<div className="session-cost">{money(session.totalCost)}</div>
</div>
</summary>
<div className="session-body">
<ChargeCurveChart
curve={session.curve.map((point) => ({
timestamp: new Date(point.timestamp).toISOString(),
powerKw: point.powerKw,
socPercent: point.socPercent,
}))}
/>
<div className="rows" style={{ marginTop: 14 }}>
<Row label="Başlangıç" value={dateTime(session.startedAt)} />
<Row label="Bitiş" value={dateTime(session.endedAt)} />
<Row label="Ölçülen süre" value={duration(session.durationSeconds)} />
<Row label="Alınan enerji" value={`${precise(session.energyKwh)} kWh`} />
<Row label="Kazanılan doluluk" value={gained === null ? "—" : `%${num(gained)}`} />
<Row label="Tepe güç" value={peak > 0 ? `${num(peak)} kW` : "—"} />
<Row label="Ortalama güç" value={average > 0 ? `${num(average)} kW` : "—"} />
<Row label="Birim fiyat" value={`${money(session.pricePerKwh)}/kWh`} />
<Row label="Tutar" value={money(session.totalCost)} />
</div>
</div>
</details>
</li>
);
})}
</ul>
</section>
</Shell>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumDays } from "@/lib/data";
import { duration, efficiency, isoDate, num, precise } from "@/lib/format";
import { DailyEnergyChart, EfficiencyChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function ConsumptionPage() {
await requireSession();
const { installationId, days } = await loadOverview(90, 1);
if (!installationId || days.length === 0) {
return (
<Shell active="/consumption" title="Tüketim">
<div className="card">
<p className="empty">Günlük tüketim kaydı yok. Araç günü kapattığında yüklenir.</p>
</div>
</Shell>
);
}
const chartDays = days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }));
const total = sumDays(days);
const lifetime = days[days.length - 1].lifetime;
// Newest first for reading; the charts above keep chronological order.
const table = [...days].reverse();
return (
<Shell active="/consumption" title="Tüketim" subtitle={`${days.length} günlük kayıt`}>
<div className="tiles">
<Tile label="Toplam mesafe" value={num(total.km)} unit="km" tone="teal" />
<Tile label="Toplam enerji" value={precise(total.kwh)} unit="kWh" tone="gold" />
<Tile label="Ortalama" value={efficiency(total.kwh, total.km)} tone="teal" />
<Tile
label="Ortalama hız"
value={total.hours > 0 ? num(total.km / total.hours) : "—"}
unit={total.hours > 0 ? "km/h" : undefined}
/>
</div>
<section className="card">
<h2 className="card-title">Günlük enerji</h2>
<DailyEnergyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Verimlilik eğilimi</h2>
<EfficiencyChart days={chartDays} />
</section>
<section className="card">
<h2 className="card-title">Ömür boyu</h2>
<div className="rows">
<Row label="Mesafe" value={`${num(lifetime.km)} km`} />
<Row label="Enerji" value={`${precise(lifetime.kwh)} kWh`} />
<Row label="Ortalama" value={efficiency(lifetime.kwh, lifetime.km)} />
<Row label="Sürüş süresi" value={duration(lifetime.hours * 3600)} />
</div>
</section>
<section className="card">
<h2 className="card-title">Gün gün</h2>
<ul className="list">
{table.map((day) => (
<li key={day.date}>
<details className="session">
<summary>
<div>
<div className="session-when">{isoDate(day.date)}</div>
<div className="session-meta">
{num(day.day.km)} km · {efficiency(day.day.kwh, day.day.km)}
</div>
</div>
<div className="session-energy">
{precise(day.day.kwh)}
<span className="session-cost"> kWh</span>
</div>
</summary>
<div className="session-body">
<div className="rows">
<Row label="Sürüş süresi" value={duration(day.day.hours * 3600)} />
<Row
label="Ortalama hız"
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="O gün sonu ömür boyu" value={`${num(day.lifetime.km)} km`} />
</div>
</div>
</details>
</li>
))}
</ul>
</section>
</Shell>
);
}
+363
View File
@@ -0,0 +1,363 @@
/*
* The car's palette, from app/src/main/res/values/colors.xml.
*
* Permanent OLED black with charcoal cards and muted gold/cyan accents — the same visual
* direction AGENTS.md pins for the head unit, so the phone and the dash read as one app.
* No light mode: this is looked at in a dark car as often as anywhere else.
*/
:root {
--bg: #000000;
--surface: #121212;
--raised: #1b1d1e;
--outline: #363b3d;
--text: #f4f3ef;
--muted: #bdc4c6;
--accent: #d8c39a;
--on-accent: #17140e;
--cyan: #a0e2e0;
--cyan-deep: #103436;
--gold-surface: #28241b;
--ok: #a4dcc1;
--warn: #e9ca96;
--error: #ffb4ab;
--radius: 18px;
--gap: 12px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
}
a {
color: inherit;
text-decoration: none;
}
/* The phone frame. Wide windows get the same column rather than a stretched dashboard. */
.shell {
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
padding: 20px 16px calc(84px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: var(--gap);
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.title {
font-size: 26px;
font-weight: 650;
letter-spacing: -0.4px;
margin: 0;
}
.subtitle {
color: var(--muted);
font-size: 13px;
margin: 2px 0 0;
}
.card {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 16px;
}
.card-title {
font-size: 12px;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 12px;
font-weight: 600;
}
/* Headline numbers: two per row, gold for energy/cost, cyan for distance/efficiency. */
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gap);
}
.tile {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 14px 16px;
}
.tile.gold {
background: linear-gradient(160deg, var(--gold-surface), var(--surface) 70%);
}
.tile.teal {
background: linear-gradient(160deg, var(--cyan-deep), var(--surface) 70%);
}
.tile-label {
font-size: 11px;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--muted);
margin: 0;
}
.tile-value {
font-size: 24px;
font-weight: 650;
margin: 6px 0 0;
letter-spacing: -0.5px;
}
.tile-unit {
font-size: 13px;
font-weight: 500;
color: var(--muted);
margin-left: 3px;
}
.tile-note {
font-size: 11px;
color: var(--muted);
margin: 4px 0 0;
}
/* Key/value rows inside a card. */
.rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
font-size: 14px;
}
.row + .row {
border-top: 1px solid var(--outline);
padding-top: 10px;
}
.row-key {
color: var(--muted);
}
.row-value {
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.session {
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 14px 16px;
}
.session summary {
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.session summary::-webkit-details-marker {
display: none;
}
.session-when {
font-size: 14px;
font-weight: 600;
}
.session-meta {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
.session-energy {
text-align: right;
font-size: 18px;
font-weight: 650;
color: var(--accent);
white-space: nowrap;
}
.session-cost {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.session-body {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--outline);
}
.chart {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.legend {
display: flex;
gap: 14px;
font-size: 11px;
color: var(--muted);
margin-top: 8px;
}
.swatch {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
margin-right: 5px;
vertical-align: baseline;
}
.empty {
color: var(--muted);
font-size: 14px;
text-align: center;
padding: 28px 12px;
}
/* Bottom navigation — the car app falls back to the same pattern below 1000dp. */
.nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgba(10, 10, 10, 0.92);
backdrop-filter: blur(14px);
border-top: 1px solid var(--outline);
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
}
.nav-inner {
max-width: 480px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.nav a {
text-align: center;
padding: 8px 4px;
font-size: 11px;
color: var(--muted);
font-weight: 600;
letter-spacing: 0.3px;
}
.nav a[aria-current="page"] {
color: var(--accent);
}
.nav-icon {
display: block;
font-size: 17px;
margin-bottom: 2px;
}
/* Login */
.login {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--surface);
border: 1px solid var(--outline);
border-radius: var(--radius);
padding: 28px 24px;
}
.field {
width: 100%;
background: var(--raised);
border: 1px solid var(--outline);
border-radius: 12px;
color: var(--text);
padding: 13px 14px;
font-size: 16px;
margin-top: 16px;
}
.field:focus {
outline: none;
border-color: var(--accent);
}
.button {
width: 100%;
margin-top: 14px;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 12px;
padding: 13px;
font-size: 15px;
font-weight: 650;
cursor: pointer;
}
.error {
color: var(--error);
font-size: 13px;
margin: 14px 0 0;
}
.signout {
font-size: 12px;
color: var(--muted);
background: none;
border: 1px solid var(--outline);
border-radius: 999px;
padding: 5px 12px;
cursor: pointer;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "FatihsMG4",
description: "MG4 tüketim ve şarj geçmişi",
};
export const viewport: Viewport = {
themeColor: "#000000",
width: "device-width",
initialScale: 1,
// The dashboard is pinned to a phone-width column; zooming it is still the reader's call.
maximumScale: 5,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<body>{children}</body>
</html>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { hasSession } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
if (await hasSession()) redirect("/");
const { error } = await searchParams;
return (
<div className="login">
<form className="login-card" action="/api/session" method="post">
<h1 className="title">FatihsMG4</h1>
<p className="subtitle">Tüketim ve şarj geçmişi</p>
<input
className="field"
type="password"
name="password"
placeholder="Parola"
autoComplete="current-password"
autoFocus
required
/>
<button className="button" type="submit">
Giriş
</button>
{error && <p className="error">Parola hatalı.</p>}
</form>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SESSION_COOKIE } from "@/lib/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const store = await cookies();
store.delete(SESSION_COOKIE);
return NextResponse.redirect(new URL("/login", request.url));
}
+98
View File
@@ -0,0 +1,98 @@
import { requireSession } from "@/lib/auth";
import { loadOverview, sumCharging, sumDays } from "@/lib/data";
import { dateTime, duration, efficiency, money, num, precise } from "@/lib/format";
import { DailyEnergyChart } from "@/components/charts";
import { Row, Shell, Tile } from "@/components/shell";
export const dynamic = "force-dynamic";
export default async function OverviewPage() {
await requireSession();
const { installationId, installationCount, days, sessions } = await loadOverview(30, 200);
if (!installationId) {
return (
<Shell active="/" title="Özet">
<div className="card">
<p className="empty">
Henüz veri yok. Araç ilk günlük kaydı gönderdiğinde burada görünecek.
</p>
</div>
</Shell>
);
}
const charge = sumCharging(sessions);
const recent = sumDays(days);
const lifetime = days.length > 0 ? days[days.length - 1].lifetime : null;
const soh = [...days].reverse().find((day) => day.sohPercent !== undefined)?.sohPercent;
const lastSession = sessions[0];
return (
<Shell
active="/"
title="Özet"
subtitle={installationCount > 1 ? `${installationCount} araçtan en günceli` : "MG4"}
>
<div className="tiles">
<Tile
label="Toplam alınan"
value={precise(charge.energyKwh)}
unit="kWh"
note={`${charge.sessions} DC şarj`}
tone="gold"
/>
<Tile
label="Ödenen"
value={money(charge.cost)}
note={charge.energyKwh > 0 ? `${money(charge.cost / charge.energyKwh)}/kWh ort.` : undefined}
tone="gold"
/>
<Tile label="Son 30 gün" value={num(recent.km)} unit="km" note={efficiency(recent.kwh, recent.km)} tone="teal" />
<Tile
label="Ömür boyu"
value={lifetime ? num(lifetime.km) : "—"}
unit="km"
note={lifetime ? efficiency(lifetime.kwh, lifetime.km) : undefined}
tone="teal"
/>
</div>
<section className="card">
<h2 className="card-title">Son 30 gün</h2>
<DailyEnergyChart days={days.map((day) => ({ date: day.date, kwh: day.day.kwh, km: day.day.km }))} />
</section>
{lastSession && (
<section className="card">
<h2 className="card-title">Son şarj</h2>
<div className="rows">
<Row label="Tarih" value={dateTime(lastSession.startedAt)} />
<Row label="Alınan enerji" value={`${precise(lastSession.energyKwh)} kWh`} />
<Row
label="Doluluk"
value={
lastSession.startSocPercent !== undefined && lastSession.endSocPercent !== undefined
? `%${num(lastSession.startSocPercent)} → %${num(lastSession.endSocPercent)}`
: "—"
}
/>
<Row label="Süre" value={duration(lastSession.durationSeconds)} />
<Row label="Ücret" value={money(lastSession.totalCost)} />
</div>
</section>
)}
<section className="card">
<h2 className="card-title">Batarya ve toplamlar</h2>
<div className="rows">
<Row label="Sağlık (SOH)" value={soh === undefined ? "—" : `%${num(soh)}`} />
<Row label="Toplam şarj süresi" value={duration(charge.seconds)} />
<Row label="Görülen en yüksek güç" value={charge.peakKw > 0 ? `${num(charge.peakKw)} kW` : "—"} />
<Row label="Şarjla kazanılan doluluk" value={charge.socGained > 0 ? `%${num(charge.socGained)}` : "—"} />
<Row label="Kayıtlı gün" value={`${days.length}`} />
</div>
</section>
</Shell>
);
}
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { chargingCollection, ensureIndexes } from "@/lib/mongo";
import { validateChargingSession } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, context: { params: Promise<{ sessionId: string }> }) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const { sessionId } = await context.params;
const record = validateChargingSession(await parseJson(request), sessionId);
await ensureIndexes();
const collection = await chargingCollection();
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId, sessionId: record.sessionId },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } },
{ upsert: true },
);
return new NextResponse(null, { status: 204 });
});
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { handle, parseJson, requireIngest } from "@/lib/api";
import { consumptionCollection, ensureIndexes } from "@/lib/mongo";
import { validateConsumption } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Upsert on (installationId, date). The car retries a day it could not confirm, so the
* same day arrives more than once and must land on the same document rather than a
* duplicate — the unique index is what guarantees that under a concurrent retry.
*/
export async function PUT(request: Request) {
const denied = requireIngest(request);
if (denied) return denied;
return handle(async () => {
const record = validateConsumption(await parseJson(request));
await ensureIndexes();
const collection = await consumptionCollection();
const updatedAt = new Date();
await collection.updateOne(
{ installationId: record.installationId, date: record.date },
{ $set: { ...record, updatedAt }, $setOnInsert: { createdAt: updatedAt } },
{ upsert: true },
);
return new NextResponse(null, { status: 204 });
});
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Unauthenticated on purpose: it reveals nothing and the car has no other probe. */
export function GET() {
return NextResponse.json({ status: "ok" });
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { chargingCollection } from "@/lib/mongo";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) {
const denied = await requireReader(request);
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await chargingCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ startedAt: -1 })
.limit(200)
.toArray();
return NextResponse.json({ records });
});
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { handle, requireReader } from "@/lib/api";
import { consumptionCollection } from "@/lib/mongo";
import { validateUuid } from "@/lib/validate";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ installationId: string }> }) {
const denied = await requireReader(request);
if (denied) return denied;
return handle(async () => {
const { installationId } = await context.params;
const collection = await consumptionCollection();
const records = await collection
.find({ installationId: validateUuid(installationId, "installationId") }, { projection: { _id: 0 } })
.sort({ date: -1 })
.limit(400)
.toArray();
return NextResponse.json({ records });
});
}
+267
View File
@@ -0,0 +1,267 @@
/*
* Hand-rolled SVG charts.
*
* A charting library would be the larger part of the bundle for three small plots, and
* these are server-rendered: no hydration, no client JavaScript, and they draw correctly
* before anything loads. Each one states its own units in the legend rather than relying
* on a tooltip, because a tooltip needs a pointer and this is read on a phone.
*/
const GOLD = "#d8c39a";
const CYAN = "#a0e2e0";
const GRID = "#363b3d";
const MUTED = "#bdc4c6";
interface Plot {
width: number;
height: number;
padTop: number;
padRight: number;
padBottom: number;
padLeft: number;
}
const PLOT: Plot = { width: 320, height: 150, padTop: 10, padRight: 8, padBottom: 20, padLeft: 34 };
function innerWidth(plot: Plot) {
return plot.width - plot.padLeft - plot.padRight;
}
function innerHeight(plot: Plot) {
return plot.height - plot.padTop - plot.padBottom;
}
/** A rounded axis maximum, so the gridline labels are readable numbers. */
function niceMax(value: number): number {
if (!(value > 0)) return 1;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalised = value / magnitude;
const step = normalised <= 1 ? 1 : normalised <= 2 ? 2 : normalised <= 5 ? 5 : 10;
return step * magnitude;
}
function Grid({ plot, max, format }: { plot: Plot; max: number; format: (value: number) => string }) {
const lines = [0, 0.5, 1];
return (
<g>
{lines.map((fraction) => {
const y = plot.padTop + innerHeight(plot) * (1 - fraction);
return (
<g key={fraction}>
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={y}
y2={y}
stroke={GRID}
strokeWidth={1}
strokeDasharray={fraction === 0 ? undefined : "2 4"}
/>
<text x={plot.padLeft - 6} y={y + 3.5} fill={MUTED} fontSize={9} textAnchor="end">
{format(max * fraction)}
</text>
</g>
);
})}
</g>
);
}
export interface DayDatum {
date: string;
kwh: number;
km: number;
}
/**
* Daily energy as bars. Days with no record are absent rather than drawn as zero — the
* car was not driven, which is not the same as a day of zero consumption, and the same
* distinction the telemetry side makes between a failed read and a zero.
*/
export function DailyEnergyChart({ days }: { days: DayDatum[] }) {
if (days.length === 0) return <p className="empty">Henüz günlük kayıt yok</p>;
const plot = PLOT;
const max = niceMax(Math.max(...days.map((day) => day.kwh)));
const slot = innerWidth(plot) / days.length;
const barWidth = Math.max(1.5, Math.min(14, slot * 0.62));
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Gün gün tüketilen enerji">
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
{days.map((day, index) => {
const height = (day.kwh / max) * innerHeight(plot);
const x = plot.padLeft + slot * (index + 0.5) - barWidth / 2;
return (
<rect
key={day.date}
x={x}
y={plot.padTop + innerHeight(plot) - height}
width={barWidth}
height={Math.max(height, day.kwh > 0 ? 1 : 0)}
rx={2}
fill={GOLD}
opacity={0.92}
>
<title>{`${day.date} — ${day.kwh.toFixed(2)} kWh, ${day.km.toFixed(1)} km`}</title>
</rect>
);
})}
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
{shortDate(days[0].date)}
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{shortDate(days[days.length - 1].date)}
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Günlük enerji (kWh)
</span>
</div>
</>
);
}
/** Efficiency over time. Days under 1 km are dropped: the ratio is meaningless there. */
export function EfficiencyChart({ days }: { days: DayDatum[] }) {
const points = days
.filter((day) => day.km >= 1 && day.kwh > 0)
.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>;
const plot = PLOT;
const max = niceMax(Math.max(...points.map((point) => point.value)));
const step = innerWidth(plot) / Math.max(1, points.length - 1);
const path = points
.map((point, index) => {
const x = plot.padLeft + step * index;
const y = plot.padTop + innerHeight(plot) * (1 - point.value / max);
return `${index === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
})
.join(" ");
const average = points.reduce((total, point) => total + point.value, 0) / points.length;
const averageY = plot.padTop + innerHeight(plot) * (1 - average / max);
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Gün gün ortalama tüketim">
<Grid plot={plot} max={max} format={(value) => value.toFixed(0)} />
<line
x1={plot.padLeft}
x2={plot.width - plot.padRight}
y1={averageY}
y2={averageY}
stroke={GOLD}
strokeWidth={1}
strokeDasharray="4 4"
opacity={0.7}
/>
<path d={path} fill="none" stroke={CYAN} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((point, index) => (
<circle
key={point.date}
cx={plot.padLeft + step * index}
cy={plot.padTop + innerHeight(plot) * (1 - point.value / max)}
r={1.8}
fill={CYAN}
>
<title>{`${point.date} — ${point.value.toFixed(1)} kWh/100 km`}</title>
</circle>
))}
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: CYAN }} />
kWh/100 km
</span>
<span>
<span className="swatch" style={{ background: GOLD }} />
Ortalama {average.toFixed(1)}
</span>
</div>
</>
);
}
export interface CurveDatum {
timestamp: string;
powerKw: number;
socPercent?: number;
}
/**
* Charge curve: power against time, with SOC on its own 0–100 scale.
*
* Two quantities on one plot because the shape of a DC charge is the relationship between
* them — the taper is only legible next to the percentage that caused it.
*/
export function ChargeCurveChart({ curve }: { curve: CurveDatum[] }) {
if (curve.length < 2) return <p className="empty">Bu seans için eğri kaydı yok</p>;
const plot = { ...PLOT, padRight: 26 };
const times = curve.map((point) => new Date(point.timestamp).getTime());
const start = times[0];
const span = Math.max(1, times[times.length - 1] - start);
const maxPower = niceMax(Math.max(...curve.map((point) => point.powerKw)));
const x = (time: number) => plot.padLeft + (innerWidth(plot) * (time - start)) / span;
const yPower = (kw: number) => plot.padTop + innerHeight(plot) * (1 - kw / maxPower);
const ySoc = (percent: number) => plot.padTop + innerHeight(plot) * (1 - percent / 100);
const powerPath = curve
.map((point, index) => `${index === 0 ? "M" : "L"}${x(times[index]).toFixed(2)} ${yPower(point.powerKw).toFixed(2)}`)
.join(" ");
const areaPath = `${powerPath} L${x(times[times.length - 1]).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} L${x(start).toFixed(2)} ${(plot.padTop + innerHeight(plot)).toFixed(2)} Z`;
const socPoints = curve
.map((point, index) => ({ point, index }))
.filter((entry) => typeof entry.point.socPercent === "number");
const socPath = socPoints
.map((entry, order) =>
`${order === 0 ? "M" : "L"}${x(times[entry.index]).toFixed(2)} ${ySoc(entry.point.socPercent as number).toFixed(2)}`)
.join(" ");
return (
<>
<svg className="chart" viewBox={`0 0 ${plot.width} ${plot.height}`} role="img"
aria-label="Şarj gücü ve doluluk eğrisi">
<Grid plot={plot} max={maxPower} format={(value) => value.toFixed(0)} />
<defs>
<linearGradient id="powerFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={GOLD} stopOpacity={0.32} />
<stop offset="100%" stopColor={GOLD} stopOpacity={0.02} />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#powerFill)" stroke="none" />
<path d={powerPath} fill="none" stroke={GOLD} strokeWidth={2} strokeLinejoin="round" />
{socPath && <path d={socPath} fill="none" stroke={CYAN} strokeWidth={1.6} strokeDasharray="5 3" />}
<text x={plot.width - plot.padRight + 4} y={plot.padTop + 4} fill={MUTED} fontSize={9}>
100%
</text>
<text x={plot.width - plot.padRight + 4} y={plot.padTop + innerHeight(plot)} fill={MUTED} fontSize={9}>
0%
</text>
<text x={plot.padLeft} y={plot.height - 6} fill={MUTED} fontSize={9}>
0 dk
</text>
<text x={plot.width - plot.padRight} y={plot.height - 6} fill={MUTED} fontSize={9} textAnchor="end">
{Math.round(span / 60000)} dk
</text>
</svg>
<div className="legend">
<span>
<span className="swatch" style={{ background: GOLD }} />
Güç (kW)
</span>
<span>
<span className="swatch" style={{ background: CYAN }} />
Doluluk (%)
</span>
</div>
</>
);
}
function shortDate(iso: string): string {
const [, month, day] = iso.split("-");
return `${day}.${month}`;
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import type { ReactNode } from "react";
const TABS = [
{ href: "/", label: "Özet", icon: "◈" },
{ href: "/consumption", label: "Tüketim", icon: "▤" },
{ href: "/charging", label: "Şarj", icon: "⚡" },
] as const;
export type Tab = (typeof TABS)[number]["href"];
/**
* The phone frame and the bottom tab bar.
*
* `active` is passed in rather than read from the pathname so every page stays a server
* component — the whole dashboard renders without shipping a line of client JavaScript.
*/
export function Shell({
active,
title,
subtitle,
children,
}: {
active: Tab;
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<>
<main className="shell">
<header className="header">
<div>
<h1 className="title">{title}</h1>
{subtitle && <p className="subtitle">{subtitle}</p>}
</div>
<Link className="signout" href="/logout">
Çıkış
</Link>
</header>
{children}
</main>
<nav className="nav">
<div className="nav-inner">
{TABS.map((tab) => (
<Link key={tab.href} href={tab.href} aria-current={tab.href === active ? "page" : undefined}>
<span className="nav-icon" aria-hidden>
{tab.icon}
</span>
{tab.label}
</Link>
))}
</div>
</nav>
</>
);
}
export function Tile({
label,
value,
unit,
note,
tone,
}: {
label: string;
value: string;
unit?: string;
note?: string;
tone?: "gold" | "teal";
}) {
return (
<div className={`tile${tone ? ` ${tone}` : ""}`}>
<p className="tile-label">{label}</p>
<p className="tile-value">
{value}
{unit && <span className="tile-unit">{unit}</span>}
</p>
{note && <p className="tile-note">{note}</p>}
</div>
);
}
export function Row({ label, value }: { label: string; value: string }) {
return (
<div className="row">
<span className="row-key">{label}</span>
<span className="row-value">{value}</span>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { hasIngestToken, hasSession, checkDashboardPassword } from "./auth";
import { ValidationError } from "./validate";
/** Every endpoint touches MongoDB, which rules out the edge runtime. */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function unauthorized(): NextResponse {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
/**
* Wraps a handler so a validation failure is a 400 and anything else is a 500 whose
* detail stays in the server log. An error message is allowed to name a bad field; it is
* never allowed to carry a connection string.
*/
export async function handle(work: () => Promise<NextResponse>): Promise<NextResponse> {
try {
return await work();
} catch (error) {
if (error instanceof ValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Request failed", error);
return NextResponse.json({ error: "internal server error" }, { status: 500 });
}
}
export function requireIngest(request: Request): NextResponse | null {
return hasIngestToken(request) ? null : unauthorized();
}
/**
* Read access: the dashboard's own session cookie, or the dashboard password as a bearer
* token so a native client can read the same history without a browser.
*/
export async function requireReader(request: Request): Promise<NextResponse | null> {
if (await hasSession()) return null;
const header = request.headers.get("authorization") ?? "";
if (header.startsWith("Bearer ") && checkDashboardPassword(header.slice(7))) return null;
return unauthorized();
}
export async function parseJson(request: Request): Promise<unknown> {
const raw = await request.text();
if (raw.length > 512 * 1024) throw new ValidationError("body is too large");
try {
return JSON.parse(raw);
} catch {
throw new ValidationError("body is not valid JSON");
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The Next-aware half of authentication. Everything cryptographic lives in
* {@link ./session}, which imports nothing from Next and is therefore unit-testable
* without a request, a build, or a running server.
*/
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE, isValidSessionValue } from "./session";
export * from "./session";
export async function hasSession(): Promise<boolean> {
const store = await cookies();
return isValidSessionValue(store.get(SESSION_COOKIE)?.value);
}
/** For pages: send an unauthenticated visitor to the password screen. */
export async function requireSession(): Promise<void> {
if (!(await hasSession())) redirect("/login");
}
+84
View File
@@ -0,0 +1,84 @@
import { chargingCollection, consumptionCollection, type ChargingSession, type DailyConsumption } from "./mongo";
/**
* Reads for the dashboard.
*
* Every query is scoped to one installation id. There is one car today, but the id is in
* the schema and in the unique indexes, so scoping now costs nothing and means a second
* head unit does not silently merge its history into the first one's charts.
*/
export interface Overview {
installationId: string | null;
installationCount: number;
days: DailyConsumption[];
sessions: ChargingSession[];
}
/** The installation that reported most recently — the car in use, if there are ever two. */
export async function latestInstallationId(): Promise<{ id: string | null; count: number }> {
const collection = await consumptionCollection();
const ids = await collection.distinct("installationId");
if (ids.length === 0) {
const charging = await chargingCollection();
const chargingIds = await charging.distinct("installationId");
return { id: chargingIds[0] ?? null, count: chargingIds.length };
}
if (ids.length === 1) return { id: ids[0], count: 1 };
const newest = await collection.find({}, { projection: { installationId: 1 } })
.sort({ date: -1 }).limit(1).next();
return { id: newest?.installationId ?? ids[0], count: ids.length };
}
export async function loadOverview(dayLimit = 60, sessionLimit = 60): Promise<Overview> {
const { id, count } = await latestInstallationId();
if (!id) return { installationId: null, installationCount: 0, days: [], sessions: [] };
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]);
const [days, sessions] = await Promise.all([
consumption.find({ installationId: id }, { projection: { _id: 0 } })
.sort({ date: -1 }).limit(dayLimit).toArray(),
charging.find({ installationId: id }, { projection: { _id: 0 } })
.sort({ startedAt: -1 }).limit(sessionLimit).toArray(),
]);
// Charts read left-to-right in time; the queries sort newest-first so the limit keeps
// the recent end rather than the beginning of history.
return { installationId: id, installationCount: count, days: days.reverse(), sessions };
}
export interface ChargeTotals {
energyKwh: number;
cost: number;
sessions: number;
seconds: number;
socGained: number;
peakKw: number;
}
export function sumCharging(sessions: ChargingSession[]): ChargeTotals {
return sessions.reduce<ChargeTotals>(
(total, session) => ({
energyKwh: total.energyKwh + session.energyKwh,
cost: total.cost + session.totalCost,
sessions: total.sessions + 1,
seconds: total.seconds + session.durationSeconds,
socGained:
total.socGained +
(session.startSocPercent !== undefined && session.endSocPercent !== undefined
? Math.max(0, session.endSocPercent - session.startSocPercent)
: 0),
peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0),
}),
{ energyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 },
);
}
export function sumDays(days: DailyConsumption[]) {
return days.reduce(
(total, record) => ({
km: total.km + record.day.km,
kwh: total.kwh + record.day.kwh,
hours: total.hours + record.day.hours,
}),
{ km: 0, kwh: 0, hours: 0 },
);
}
+48
View File
@@ -0,0 +1,48 @@
/** Turkish formatting for every user-facing number and date. */
const NUMBER = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
const PRECISE = new Intl.NumberFormat("tr-TR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const CURRENCY = new Intl.NumberFormat("tr-TR", { style: "currency", currency: "TRY", maximumFractionDigits: 2 });
const DATE = new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "long", year: "numeric" });
const DATE_TIME = new Intl.DateTimeFormat("tr-TR", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
export function num(value: number): string {
return NUMBER.format(value);
}
export function precise(value: number): string {
return PRECISE.format(value);
}
export function money(value: number): string {
return CURRENCY.format(value);
}
export function isoDate(iso: string): string {
const [year, month, day] = iso.split("-").map(Number);
return DATE.format(new Date(Date.UTC(year, month - 1, day)));
}
export function dateTime(value: Date | string): string {
return DATE_TIME.format(new Date(value));
}
/** `2 sa 05 dk` — the same shape the car's charging pane uses. */
export function duration(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (hours === 0) return `${minutes} dk`;
return `${hours} sa ${String(minutes).padStart(2, "0")} dk`;
}
/** Energy per 100 km, or a dash — never a ratio computed from a distance of nothing. */
export function efficiency(kwh: number, km: number): string {
if (km < 1) return "—";
return `${num((kwh * 100) / km)} kWh/100 km`;
}
+96
View File
@@ -0,0 +1,96 @@
import { MongoClient, type Collection, type Db } from "mongodb";
/**
* One MongoClient per warm lambda, cached on globalThis.
*
* A serverless function is re-entered far more often than it is cold-started, and a fresh
* MongoClient per request opens a fresh pool per request — Atlas starts refusing
* connections long before the traffic justifies it. The global survives module reloads in
* dev, where Next re-evaluates modules on every edit.
*/
const globalForMongo = globalThis as unknown as { mongoClient?: Promise<MongoClient> };
export interface Totals {
km: number;
kwh: number;
hours: number;
socDrop: number;
}
export interface DailyConsumption {
installationId: string;
date: string;
recordedAt: Date;
day: Totals;
lifetime: Totals;
sohPercent?: number;
createdAt: Date;
updatedAt: Date;
}
export interface CurvePoint {
timestamp: Date;
powerKw: number;
socPercent?: number;
}
export interface ChargingSession {
installationId: string;
sessionId: string;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
energyKwh: number;
pricePerKwh: number;
totalCost: number;
startSocPercent?: number;
endSocPercent?: number;
curve: CurvePoint[];
createdAt: Date;
updatedAt: Date;
}
function clientPromise(): Promise<MongoClient> {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("MONGODB_URI is not configured");
if (!globalForMongo.mongoClient) {
globalForMongo.mongoClient = new MongoClient(uri, {
serverSelectionTimeoutMS: 10_000,
maxPoolSize: 10,
}).connect();
}
return globalForMongo.mongoClient;
}
async function db(): Promise<Db> {
const client = await clientPromise();
return client.db(process.env.MONGODB_DATABASE || "fatihsmg4");
}
export async function consumptionCollection(): Promise<Collection<DailyConsumption>> {
return (await db()).collection<DailyConsumption>("daily_consumption");
}
export async function chargingCollection(): Promise<Collection<ChargingSession>> {
return (await db()).collection<ChargingSession>("charging_sessions");
}
/**
* Creates the indexes the Express service used to create at boot.
*
* There is no boot here — a lambda starts on a request — so this runs on the first write
* of each warm instance and is skipped afterwards. `createIndex` is idempotent, but it is
* still a round trip, and the uniqueness constraints are what make the upserts safe.
*/
let indexesEnsured = false;
export async function ensureIndexes(): Promise<void> {
if (indexesEnsured) return;
const [consumption, charging] = await Promise.all([consumptionCollection(), chargingCollection()]);
await Promise.all([
consumption.createIndex({ installationId: 1, date: 1 }, { unique: true }),
consumption.createIndex({ installationId: 1, date: -1 }),
charging.createIndex({ installationId: 1, sessionId: 1 }, { unique: true }),
charging.createIndex({ installationId: 1, startedAt: -1 }),
]);
indexesEnsured = true;
}
+80
View File
@@ -0,0 +1,80 @@
import crypto from "node:crypto";
/**
* Two separate credentials, deliberately.
*
* The car holds {@link INGEST_TOKEN} and can only write. It is baked into a build that
* lives on a head unit and is published as an APK, so it must be assumed extractable —
* and an extracted write token buys nothing but the ability to post junk under an
* installation id the attacker would have to guess.
*
* Reading the history needs the dashboard password, which never leaves the browser's
* cookie jar and is never compiled into anything.
*/
export const SESSION_COOKIE = "fatihsmg4_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
/** Constant-time compare that does not leak length through an early return. */
function sameSecret(supplied: string, expected: string): boolean {
const a = crypto.createHash("sha256").update(supplied).digest();
const b = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(a, b);
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value;
}
/** Does this request carry the car's write token? */
export function hasIngestToken(request: Request): boolean {
const expected = process.env.INGEST_TOKEN;
if (!expected || expected.length < 24) return false;
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
return sameSecret(header.slice(7), expected);
}
export function checkDashboardPassword(supplied: string): boolean {
const expected = process.env.DASHBOARD_PASSWORD;
if (!expected || expected.length < 8) return false;
return sameSecret(supplied, expected);
}
/**
* `<expiry>.<hmac>` — no server-side session store, which a serverless deployment has
* nowhere to keep anyway. The expiry is inside the signed payload, so a browser that
* keeps the cookie past its date cannot use it.
*/
export function issueSessionValue(): string {
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
return `${expiresAt}.${sign(String(expiresAt))}`;
}
function sign(payload: string): string {
return crypto.createHmac("sha256", requireEnv("SESSION_SECRET")).update(payload).digest("hex");
}
export function isValidSessionValue(value: string | undefined): boolean {
if (!value) return false;
const separator = value.lastIndexOf(".");
if (separator <= 0) return false;
const expiresAt = Number(value.slice(0, separator));
if (!Number.isSafeInteger(expiresAt) || expiresAt * 1000 < Date.now()) return false;
try {
const expected = Buffer.from(sign(String(expiresAt)), "hex");
const supplied = Buffer.from(value.slice(separator + 1), "hex");
return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied);
} catch {
return false;
}
}
export const sessionCookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_SECONDS,
} as const;
+105
View File
@@ -0,0 +1,105 @@
/**
* Request validation for the ingest endpoints — a direct port of the Express service's
* rules, kept pure so it can be unit-tested without a database or a request.
*
* The car is the only writer and it is trusted, but a bad field here is written once and
* read forever. Every number is bounded; an out-of-range value is rejected rather than
* stored and quietly plotted.
*/
import type { ChargingSession, CurvePoint, DailyConsumption, Totals } from "./mongo";
export class ValidationError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export type ConsumptionInput = Omit<DailyConsumption, "createdAt" | "updatedAt">;
export type ChargingInput = Omit<ChargingSession, "createdAt" | "updatedAt">;
export function validateConsumption(body: unknown): ConsumptionInput {
const record = asObject(body);
const date = record.date;
if (typeof date !== "string" || !ISO_DATE.test(date)) throw new ValidationError("date is invalid");
const sohPercent = record.sohPercent;
return {
installationId: validateUuid(record.installationId, "installationId"),
date,
recordedAt: validateDate(record.recordedAt, "recordedAt"),
day: validateTotals(record.day, "day"),
lifetime: validateTotals(record.lifetime, "lifetime"),
...(sohPercent === undefined ? {} : { sohPercent: bounded(sohPercent, 0, 100, "sohPercent") }),
};
}
export function validateChargingSession(body: unknown, pathSessionId: string): ChargingInput {
const record = asObject(body);
const sessionId = validateUuid(record.sessionId, "sessionId");
if (sessionId !== pathSessionId) throw new ValidationError("sessionId does not match path");
if (!Array.isArray(record.curve) || record.curve.length > 720) {
throw new ValidationError("curve is invalid");
}
const curve: CurvePoint[] = record.curve.map((entry) => {
const point = asObject(entry);
const socPercent = point.socPercent;
return {
timestamp: validateDate(point.timestampMs, "curve.timestampMs"),
powerKw: bounded(point.powerKw, 0, 1000, "curve.powerKw"),
...(socPercent === undefined ? {} : { socPercent: bounded(socPercent, 0, 100, "curve.socPercent") }),
};
});
const startSocPercent = record.startSocPercent;
const endSocPercent = record.endSocPercent;
return {
installationId: validateUuid(record.installationId, "installationId"),
sessionId,
startedAt: validateDate(record.startedAt, "startedAt"),
endedAt: validateDate(record.endedAt, "endedAt"),
durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"),
energyKwh: bounded(record.energyKwh, 0, 1000, "energyKwh"),
pricePerKwh: bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"),
totalCost: bounded(record.totalCost, 0, 1_000_000_000, "totalCost"),
...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }),
...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }),
curve,
};
}
export function validateUuid(value: unknown, name: string): string {
if (typeof value !== "string" || !UUID.test(value)) throw new ValidationError(`${name} is invalid`);
return value;
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ValidationError("body is required");
}
return value as Record<string, unknown>;
}
/** Epoch milliseconds on the wire; a Date in the database. */
function validateDate(value: unknown, name: string): Date {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new ValidationError(`${name} is invalid`);
}
return new Date(value);
}
function bounded(value: unknown, min: number, max: number, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new ValidationError(`${name} is invalid`);
}
return value;
}
function validateTotals(value: unknown, name: string): Totals {
const record = asObject(value);
const result = {} as Totals;
for (const field of ["km", "kwh", "hours", "socDrop"] as const) {
const number = record[field];
if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) {
throw new ValidationError(`${name}.${field} is invalid`);
}
result[field] = number;
}
return result;
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { test } from "node:test";
process.env.SESSION_SECRET = "0123456789abcdef0123456789abcdef";
process.env.DASHBOARD_PASSWORD = "correct-horse-battery";
process.env.INGEST_TOKEN = "a".repeat(64);
const { checkDashboardPassword, hasIngestToken, isValidSessionValue, issueSessionValue } =
await import("../src/lib/session.ts");
test("a freshly issued session validates and a tampered one does not", () => {
const value = issueSessionValue();
assert.equal(isValidSessionValue(value), true);
const [expiry, signature] = value.split(".");
// Moving the expiry out invalidates the signature, so a cookie cannot extend itself.
assert.equal(isValidSessionValue(`${Number(expiry) + 86_400}.${signature}`), false);
assert.equal(isValidSessionValue(`${expiry}.${"0".repeat(signature.length)}`), false);
assert.equal(isValidSessionValue(undefined), false);
assert.equal(isValidSessionValue("garbage"), false);
});
test("an expired session is refused even with a valid signature", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
// Signed by the same secret, so only the embedded expiry rejects it.
const crypto = await import("node:crypto");
const signature = crypto.createHmac("sha256", process.env.SESSION_SECRET as string)
.update(String(past)).digest("hex");
assert.equal(isValidSessionValue(`${past}.${signature}`), false);
});
test("the dashboard password is exact", () => {
assert.equal(checkDashboardPassword("correct-horse-battery"), true);
assert.equal(checkDashboardPassword("correct-horse-batter"), false);
assert.equal(checkDashboardPassword(""), false);
});
test("the ingest token is only accepted as a bearer header", () => {
const token = "a".repeat(64);
const bearer = (value: string) => new Request("https://x/v1", { headers: { authorization: value } });
assert.equal(hasIngestToken(bearer(`Bearer ${token}`)), true);
assert.equal(hasIngestToken(bearer(token)), false);
assert.equal(hasIngestToken(bearer(`Bearer ${"b".repeat(64)}`)), false);
assert.equal(hasIngestToken(new Request("https://x/v1")), false);
});
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ValidationError, validateChargingSession, validateConsumption } from "../src/lib/validate.ts";
const INSTALLATION = "6f1a2b3c-4d5e-4f60-8a9b-0c1d2e3f4a5b";
const SESSION = "11111111-2222-4333-8444-555555555555";
const day = { km: 42.5, kwh: 7.25, hours: 0.9, socDrop: 11.5 };
test("accepts a well-formed daily record", () => {
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1_760_000_000_000,
day,
lifetime: { km: 12000, kwh: 2100, hours: 240, socDrop: 3400 },
sohPercent: 97.5,
});
assert.equal(record.date, "2026-10-07");
assert.equal(record.day.kwh, 7.25);
assert.ok(record.recordedAt instanceof Date);
});
test("a missing SOH is omitted rather than defaulted", () => {
// The car omits a field it could not read. Writing 0 here would be a battery at
// nothing, which is the same class of lie the telemetry side refuses to send.
const record = validateConsumption({
installationId: INSTALLATION,
date: "2026-10-07",
recordedAt: 1,
day,
lifetime: day,
});
assert.equal("sohPercent" in record, false);
});
test("rejects malformed identifiers, dates and out-of-range numbers", () => {
const base = { installationId: INSTALLATION, date: "2026-10-07", recordedAt: 1, day, lifetime: day };
assert.throws(() => validateConsumption({ ...base, installationId: "nope" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, date: "07.10.2026" }), ValidationError);
assert.throws(() => validateConsumption({ ...base, sohPercent: 140 }), ValidationError);
assert.throws(() => validateConsumption({ ...base, day: { ...day, kwh: "7" } }), ValidationError);
assert.throws(() => validateConsumption({ ...base, recordedAt: -1 }), ValidationError);
assert.throws(() => validateConsumption(null), ValidationError);
});
test("a charging session must match the id in its path", () => {
const body = {
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1_760_000_000_000,
endedAt: 1_760_002_000_000,
durationSeconds: 1800,
energyKwh: 31.4,
pricePerKwh: 7.5,
totalCost: 235.5,
startSocPercent: 18,
endSocPercent: 72,
curve: [{ timestampMs: 1_760_000_060_000, powerKw: 84.2, socPercent: 20 }],
};
const record = validateChargingSession(body, SESSION);
assert.equal(record.curve.length, 1);
assert.equal(record.curve[0].powerKw, 84.2);
assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError);
});
test("refuses a curve longer than the car can produce", () => {
const curve = Array.from({ length: 721 }, (_unused, index) => ({
timestampMs: 1_760_000_000_000 + index * 60_000,
powerKw: 50,
}));
assert.throws(
() =>
validateChargingSession(
{
installationId: INSTALLATION,
sessionId: SESSION,
startedAt: 1,
endedAt: 2,
durationSeconds: 1,
energyKwh: 1,
pricePerKwh: 1,
totalCost: 1,
curve,
},
SESSION,
),
ValidationError,
);
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}