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:
co-authored by
Claude Opus 5
parent
e9bfa1217b
commit
8242797ece
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.env
|
|
||||||
npm-debug.log*
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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`
|
|
||||||
Generated
-1040
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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: [] });
|
|
||||||
});
|
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
Generated
+1145
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user