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,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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user