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