simplify: backend derives lifetime from day totals, app stops sending it

- backend daily PUT: no longer $set lifetime (app's local counter is unreliable after APK wipe)
- backend history GET: derives lifetime as sum of all days' totals, returns it at response level
- app: stops sending lifetime in chunks; reads it from history response
- app: refresh button on consumption pane + 'Şimdi yükle' on log pane (manual cache flush)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-09 21:41:15 +03:00
co-authored by Claude
parent 8b25799d55
commit 06265bacc9
8 changed files with 144 additions and 27 deletions
@@ -29,7 +29,7 @@ import java.util.List;
*/ */
final class ConsumptionCloudClient { final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo"; private static final String TAG = "FatihsMG4.Mongo";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 60_000L; private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L;
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L; private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L; private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
private static final int TIMEOUT_MS = 8_000; private static final int TIMEOUT_MS = 8_000;
@@ -137,14 +137,16 @@ final class ConsumptionCloudClient {
} }
/** /**
* Sends every cached chunk; removes each one that succeeds. Returns how many were sent. * Sends every cached chunk; removes each one that succeeds. A chunk that fails stays in
* A chunk that fails stops the drain — the rest stay in order for the next cycle. * the cache for the next attempt, but does NOT stop the drain — the next chunk is tried
* too, because a 5xx on one day's document is not a reason to hold back the rest.
*/ */
private int drainChunkCache() { private int drainChunkCache() {
JSONArray cache = loadChunkCache(); JSONArray cache = loadChunkCache();
if (cache.length() == 0) return 0; if (cache.length() == 0) return 0;
JSONArray remaining = new JSONArray(); JSONArray remaining = new JSONArray();
int sent = 0; int sent = 0;
int failed = 0;
for (int i = 0; i < cache.length(); i++) { for (int i = 0; i < cache.length(); i++) {
try { try {
JSONObject body = cache.getJSONObject(i); JSONObject body = cache.getJSONObject(i);
@@ -153,20 +155,18 @@ final class ConsumptionCloudClient {
sent++; sent++;
Log.d(TAG, "Cached chunk for " + date + " sent"); Log.d(TAG, "Cached chunk for " + date + " sent");
} else { } else {
// Server rejected — keep this and everything after it. // Server rejected — keep it, try the next one too.
for (int j = i; j < cache.length(); j++) remaining.put(cache.get(j)); remaining.put(body);
break; failed++;
} }
} catch (Exception e) { } catch (Exception e) {
// Network error — keep this and everything after it, stop draining. // Network error — keep it, try the next one too.
for (int j = i; j < cache.length(); j++) { try { remaining.put(cache.get(i)); } catch (Exception ignored) { }
try { remaining.put(cache.get(j)); } catch (Exception ignored) { } failed++;
}
Log.w(TAG, "Cache drain stopped: " + e.getMessage());
break;
} }
} }
saveChunkCache(remaining); saveChunkCache(remaining);
if (failed > 0) Log.w(TAG, "Cache drain: " + sent + " sent, " + failed + " still pending");
return sent; return sent;
} }
@@ -203,8 +203,7 @@ final class ConsumptionCloudClient {
.put("installationId", installationId) .put("installationId", installationId)
.put("recordedAt", nowMs) .put("recordedAt", nowMs)
.put("date", snapshot.date) .put("date", snapshot.date)
.put("day", totals(snapshot.day)) .put("day", totals(snapshot.day));
.put("lifetime", totals(snapshot.lifetime));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
return body; return body;
} }
@@ -290,26 +289,32 @@ final class ConsumptionCloudClient {
/** /**
* Returns the cached cloud history, fetching a fresh copy at most every 5 minutes. * Returns the cached cloud history, fetching a fresh copy at most every 5 minutes.
* Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit * Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit
* off the network. On a fetch failure the previous cache is kept — a transient outage * off the network. The fetch itself runs on a background thread — this method is called
* must not blank the display. * from the UI thread and a network call there throws NetworkOnMainThreadException.
* Returns the current cache immediately; the fresh copy lands on the next poll.
*/ */
List<DayRecord> fetchHistoryIfDue() { synchronized List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory; if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime(); long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) { if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory; return cachedHistory;
} }
lastHistoryFetchMs = elapsed; lastHistoryFetchMs = elapsed;
new Thread(this::fetchHistoryAsync, "history-fetch").start();
return cachedHistory;
}
private void fetchHistoryAsync() {
try { try {
List<DayRecord> fresh = fetchHistory(); List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh; if (!fresh.isEmpty()) cachedHistory = fresh;
Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud"); Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud");
logEvent(true, 200, "History çekildi: " + fresh.size() + " gün");
} catch (Exception e) { } catch (Exception e) {
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
Log.w(TAG, "History fetch unavailable: " + msg); Log.w(TAG, "History fetch unavailable: " + msg);
logEvent(false, 0, "History fetch hatası: " + msg); logEvent(false, 0, "History fetch hatası: " + msg);
} }
return cachedHistory;
} }
/** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */ /** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */
@@ -317,6 +322,37 @@ final class ConsumptionCloudClient {
lastHistoryFetchMs = 0L; lastHistoryFetchMs = 0L;
} }
/**
* Immediately attempts to send every cached chunk and take a fresh one, ignoring the
* 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış";
int cached = drainChunkCache();
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
int total = cached;
if (chunk != null) {
try {
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
total++;
invalidateHistory();
} else {
cacheChunk(body);
}
} catch (Exception e) {
try { cacheChunk(consumptionBody(chunk, nowMs)); } catch (Exception ignored) { }
}
}
int pending = pendingChunkCount();
String msg = total > 0
? total + " chunk yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "")
: (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok");
logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg);
return msg;
}
private List<DayRecord> fetchHistory() throws Exception { private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null; HttpURLConnection connection = null;
try { try {
@@ -327,23 +363,29 @@ final class ConsumptionCloudClient {
connection.setReadTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token); connection.setRequestProperty("Authorization", "Bearer " + token);
int status = connection.getResponseCode(); int status = connection.getResponseCode();
Log.d(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
Log.w(TAG, "History fetch rejected with HTTP " + status); String err = readErrorBody(connection);
Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory; return cachedHistory;
} }
String body = readBody(connection); String body = readBody(connection);
JSONObject json = new JSONObject(body); JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records"); JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList(); if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length()); List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) { for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i); JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date")); LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day")); ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
ConsumptionTracker.Totals life = parseTotals(r.getJSONObject("lifetime"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent") Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null; ? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, life, soh)); result.add(new DayRecord(date, day, lifetime, soh));
} }
return result; return result;
} finally { } finally {
@@ -3,6 +3,7 @@ package com.evsuite.abrp;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.SystemClock; import android.os.SystemClock;
import android.util.Log;
import com.evsuite.hardware.telemetry.EnergySnapshot; import com.evsuite.hardware.telemetry.EnergySnapshot;
@@ -204,6 +205,22 @@ final class ConsumptionTracker {
return missing ? lifetime.snapshot() : stored; return missing ? lifetime.snapshot() : stored;
} }
/**
* Sets the local lifetime baseline from the cloud's last known lifetime, but only when
* the cloud value is higher — an APK wipe zeroes the local counter, and without this the
* car would re-upload a small lifetime that overwrites the real one in the cloud.
* Called once after the first history fetch succeeds.
*/
synchronized void syncLifetimeBaseline(Totals cloudLifetime) {
Totals local = lifetime.snapshot();
if (cloudLifetime.km > local.km) {
lifetime.set(cloudLifetime.km, cloudLifetime.kwh, cloudLifetime.hours, cloudLifetime.soc);
persist();
Log.i("FatihsMG4.Consumption", "Lifetime baseline synced from cloud: "
+ cloudLifetime.km + " km (was " + local.km + " km)");
}
}
synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) { synchronized LocalDate nextStoredDayAfter(LocalDate lastUploaded) {
rollDayIfNeeded(); rollDayIfNeeded();
LocalDate latestComplete = LocalDate.now().minusDays(1); LocalDate latestComplete = LocalDate.now().minusDays(1);
@@ -355,6 +372,13 @@ final class ConsumptionTracker {
km.reset(); kwh.reset(); hours.reset(); soc.reset(); km.reset(); kwh.reset(); hours.reset(); soc.reset();
} }
void set(double kmVal, double kwhVal, double hoursVal, double socVal) {
km.setTotal(kmVal);
kwh.setTotal(kwhVal);
hours.setTotal(hoursVal);
soc.setTotal(socVal);
}
Totals snapshot() { Totals snapshot() {
return new Totals(km.get(), kwh.get(), hours.get(), soc.get()); return new Totals(km.get(), kwh.get(), hours.get(), soc.get());
} }
@@ -215,6 +215,7 @@ public class MainActivity extends AppCompatActivity {
statusText = servicePane.findViewById(R.id.status_text); statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text); callLogText = logPane.findViewById(R.id.call_log_text);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
setUpPager(); setUpPager();
findViewById(R.id.about_button).setOnClickListener(v -> showAbout()); findViewById(R.id.about_button).setOnClickListener(v -> showAbout());
@@ -440,6 +441,7 @@ public class MainActivity extends AppCompatActivity {
consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button); consumptionDateButton = consumptionPane.findViewById(R.id.consumption_date_button);
consumptionDateButton.setText(selectedConsumptionDate.toString()); consumptionDateButton.setText(selectedConsumptionDate.toString());
consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker()); consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker());
consumptionPane.findViewById(R.id.consumption_refresh).setOnClickListener(v -> refreshFromCloud());
consumptionReset = consumptionPane.findViewById(R.id.consumption_reset); consumptionReset = consumptionPane.findViewById(R.id.consumption_reset);
consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout); consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout);
consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input); consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input);
@@ -686,6 +688,39 @@ public class MainActivity extends AppCompatActivity {
// ---------- Call log ---------- // ---------- Call log ----------
/**
* "Şimdi yükle" button — forces a cache drain + fresh chunk on a background thread,
* ignoring the 2-minute throttle. The result lands in the upload log (Kayıtlar tab).
*/
private void manualFlush() {
if (consumptionCloudClient == null || consumptionTracker == null) return;
AbrpUploadService.log().record(new UploadLog.Entry(
System.currentTimeMillis(), 0, true, "Manuel sync başlatıldı…"));
new Thread(() -> {
String result = consumptionCloudClient.flushNow(
consumptionTracker, System.currentTimeMillis());
runOnUiThread(() -> Toast.makeText(this, result, Toast.LENGTH_SHORT).show());
}, "manual-flush").start();
}
/**
* Tüketim sekmesi "Yenile" button — sends cached chunks + fresh delta, invalidates the
* history cache so the next poll fetches fresh data from the cloud, then refreshes the UI.
*/
private void refreshFromCloud() {
if (consumptionCloudClient == null || consumptionTracker == null) return;
AbrpUploadService.log().record(new UploadLog.Entry(
System.currentTimeMillis(), 0, true, "Tüketim yenileniyor…"));
new Thread(() -> {
consumptionCloudClient.flushNow(consumptionTracker, System.currentTimeMillis());
consumptionCloudClient.invalidateHistory();
runOnUiThread(() -> {
refreshConsumption();
Toast.makeText(this, "Tüketim yenilendi", Toast.LENGTH_SHORT).show();
});
}, "cloud-refresh").start();
}
private void refreshCallLog() { private void refreshCallLog() {
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent(); java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
if (entries.isEmpty()) { if (entries.isEmpty()) {
+4 -1
View File
@@ -2,7 +2,10 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true"> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="20dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="TÜKETİM DÖNEMİ" android:textAppearance="@style/Text.EV.Eyebrow"/>
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_refresh" style="@style/Widget.EV.Button.Outlined" android:layout_width="48dp" android:layout_height="48dp" android:minWidth="0dp" android:padding="0dp" android:contentDescription="Yenile" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" app:iconPadding="0dp" app:iconTint="@color/ev_accent"/>
</LinearLayout>
<HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none"> <HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:scrollbars="none">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal"> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
<com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="Tüm zamanlar"/> <com.google.android.material.button.MaterialButton android:id="@+id/consumption_lifetime" style="@style/Widget.EV.Button.Outlined" android:layout_width="wrap_content" android:layout_height="56dp" android:text="Tüm zamanlar"/>
+1
View File
@@ -2,6 +2,7 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false"> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" xmlns:app="http://schemas.android.com/apk/res-auto" />
<TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" /> <TextView android:id="@+id/call_log_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:minHeight="180dp" android:text="@string/call_log_empty" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="12dp" android:textIsSelectable="true" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+1
View File
@@ -107,6 +107,7 @@
<!-- Call log --> <!-- Call log -->
<string name="label_call_log">Recent uploads</string> <string name="label_call_log">Recent uploads</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
<string name="btn_flush_now">Şimdi yükle</string>
<string name="status_starting">Starting…</string> <string name="status_starting">Starting…</string>
<string name="status_running_no_upload">Running — waiting for first upload</string> <string name="status_running_no_upload">Running — waiting for first upload</string>
<string name="status_last_upload">Last upload: %1$s (%2$s)</string> <string name="status_last_upload">Last upload: %1$s (%2$s)</string>
+4 -4
View File
@@ -9,11 +9,12 @@ export const dynamic = "force-dynamic";
/** /**
* Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk * Upsert on (installationId, date), incrementally. The car sends a 2-minute delta chunk
* (not a cumulative total), so each PUT adds to the day's running totals rather than * (not a cumulative total), so each PUT adds to the day's running totals rather than
* overwriting them. Lifetime is a snapshot — the latest one wins, so it is $set not $inc. * overwriting them. The app does NOT send lifetime — the backend derives it as the sum of
* all days' totals when history is read, so an APK wipe that zeroes the local counter can
* never corrupt the cloud's lifetime.
* *
* $setOnInsert must not touch `day` at all: $inc on `day.km` and $setOnInsert on `day` * $setOnInsert must not touch `day` at all: $inc on `day.km` and $setOnInsert on `day`
* conflict in MongoDB ("Updating the path 'day' would create a conflict at 'day'"). * conflict in MongoDB. On an insert, $inc treats a missing field as 0 and adds to it.
* On an insert, $inc treats a missing field as 0 and adds to it, so no seed is needed.
*/ */
export async function PUT(request: Request) { export async function PUT(request: Request) {
const denied = requireIngest(request); const denied = requireIngest(request);
@@ -24,7 +25,6 @@ export async function PUT(request: Request) {
const collection = await consumptionCollection(); const collection = await consumptionCollection();
const updatedAt = new Date(); const updatedAt = new Date();
const $set: Record<string, unknown> = { updatedAt }; const $set: Record<string, unknown> = { updatedAt };
if (record.lifetime) $set.lifetime = record.lifetime;
if (record.sohPercent !== undefined) $set.sohPercent = record.sohPercent; if (record.sohPercent !== undefined) $set.sohPercent = record.sohPercent;
await collection.updateOne( await collection.updateOne(
{ installationId: record.installationId, date: record.date }, { installationId: record.installationId, date: record.date },
@@ -27,6 +27,17 @@ export async function GET(request: Request, context: { params: Promise<{ install
.sort({ date: -1 }) .sort({ date: -1 })
.limit(400) .limit(400)
.toArray(); .toArray();
return NextResponse.json({ records }); // Lifetime = sum of every day's totals. Derived, never stored, so a corrupted or
// stale lifetime field in any single document cannot poison the total.
const lifetime = records.reduce(
(sum, r) => ({
km: sum.km + (r.day?.km ?? 0),
kwh: sum.kwh + (r.day?.kwh ?? 0),
hours: sum.hours + (r.day?.hours ?? 0),
socDrop: sum.socDrop + (r.day?.socDrop ?? 0),
}),
{ km: 0, kwh: 0, hours: 0, socDrop: 0 },
);
return NextResponse.json({ records, lifetime });
}); });
} }