/// ───────────────────────────────────────────────────────────────────────── /// THE OPERATING DAY /// /// Activity is a **shift log**, not an archive. What the rider needs from it is /// "what have I done today" — and today ends when his day ends, not when a /// timer somewhere fires. /// /// ── Why this is a filter and not a cleanup job ── /// /// The obvious build is a scheduled purge: at midnight, delete yesterday. It /// is also the one that cannot work on a phone. The app is closed at midnight /// far more often than it is open; a timer that must fire at 23:59 to keep the /// screen correct is a timer that will not fire, and the rider opens Activity /// at six the next morning to yesterday's finished work presented as today's. /// Worse, a purge is destructive on a schedule nothing observes — one bad /// timezone assumption and it takes the current shift with it. /// /// So nothing is deleted on a clock. Every record carries the service day it /// belongs to, and the screen asks for **today's**. When the date rolls over, /// yesterday stops matching; it does not need to be removed, and it is still /// there for anything that legitimately wants history. Closing the app, force /// quitting it, flying through a timezone or leaving it open across midnight /// all produce the same answer, because the answer is computed at read time. /// /// ── Local, deliberately ── /// /// A rider's day is the day where he is standing. `DateTime.now()` is already /// local, and both stores stamp their records with the local calendar date at /// the moment of writing — so a record written at 23:50 belongs to that day /// and a record written ten minutes later belongs to the next, which is what /// the rider would say too. Nothing here converts to UTC: doing so would move /// the boundary to 05:30 local in IST and split every evening shift in half. /// ───────────────────────────────────────────────────────────────────────── library; import 'package:flutter/foundation.dart' show debugPrint; import 'package:miler/views/Dashboard/activity/activity_format.dart' show parseStamp; abstract final class ServiceDay { /// `2026-08-21` for [at], in the local calendar. The same shape both stores /// write, so a stamp and a query can be compared as strings. static String stamp(DateTime at) => '${at.year.toString().padLeft(4, '0')}-' '${at.month.toString().padLeft(2, '0')}-' '${at.day.toString().padLeft(2, '0')}'; /// The service day the rider is in right now. static String get today => stamp(DateTime.now()); /// The stamps a record carries, newest-meaning first. static const List dayKeys = [ 'completedday', 'skippedday', 'serviceday', ]; /// The timestamps to fall back on when a row carries no day stamp — an API /// row, say, which has never been through either local store. /// /// ── Why this list is the one [happenedAt] uses ── /// /// It used to read `completedat, skippedat, deliveredat, updatedat, /// createdat`, and only the first two of those are fields the backend /// actually sends. A booking finished yesterday comes back from /// `GET /miler/bookings` carrying `pickedtime` and `updatedon` — neither of /// which was looked at — so [of] returned `''`, [belongsToToday] took the /// undated branch and kept it, and yesterday's work sat among this morning's /// on a screen whose entire premise is that it shows today. /// /// The filter was never the problem: it was asking the row a question in /// field names the row does not speak. So the key order is now the same one /// [happenedAt] sorts by — the app's own stamps first, then what the API /// really carries — because "when did this happen" and "which day does this /// belong to" are one fact, and reading it out of two different lists is how /// they came to disagree. static const List timeKeys = [ // Written by the app at the moment the rider acted: the most accurate, and // the only ones present on a stop the backend has not caught up with. 'completedat', 'skippedat', // What the backend sends. 'pickedtime', 'picked_time', 'deliverytime', 'deliveredat', 'updatedon', 'modifiedon', 'updatedat', 'expected_pickup_time', 'createdat', ]; /// The service day [row] belongs to, or `''` when it carries nothing usable. /// /// Empty is a real answer and callers must decide what it means for them — /// see [belongsToToday], which keeps such a row rather than dropping it. static String of(Map row) { for (final k in dayKeys) { final v = (row[k] ?? '').toString().trim(); if (v.length >= 10) return v.substring(0, 10); } for (final k in timeKeys) { // ── Through [parseStamp], and *not* through `toLocal()` ── // // Doormile timestamps are IST wall-clock in naive Postgres columns, and // some come back with a trailing `Z` they never had. A bare // `DateTime.tryParse` believes the marker, `toLocal()` then shifts it by // +5:30, and a stop finished at 23:50 lands on the *next* calendar day — // so the last stop of an evening shift vanished from Activity the moment // it was completed, and reappeared as tomorrow's. [parseStamp] strips // the false marker and reads the digits the backend meant, which is the // day the rider would name. final t = parseStamp(row[k]); if (t != null) return stamp(t); } return ''; } /// Whether [row] belongs to the service day in progress. /// /// ── A row that cannot prove it is today's is not today's ── /// /// This used to return `true` for a row carrying no usable stamp, on the /// reasoning that the API returned it for *this* session so the missing field /// was a data-quality problem rather than evidence of age. That reasoning was /// wrong in the one direction that matters: `GET /miler/bookings` returns the /// rider's whole open set, so "the API returned it" says nothing about when /// the work happened. The branch was not a safety net — it was the hole /// yesterday's finished bookings came through, and Activity's entire premise /// is that everything on it happened today. /// /// So the burden of proof sits with the row. A stamp that resolves to today /// is admitted; anything else — yesterday's, tomorrow's, or nothing at all — /// is not. This is a **view filter only**: nothing is deleted from either /// local store or from the backend, and [of] still answers for any caller /// that legitimately wants history. /// /// The undated case is genuinely rare now that [timeKeys] reads the fields /// the API actually sends, and it is a defect worth seeing rather than /// absorbing — so it is reported through [onUndated] rather than dropped in /// silence. See [logUndated], which is what Activity passes. static bool belongsToToday( Map row, { String? now, void Function(Map row)? onUndated, }) { final day = of(row); if (day.isEmpty) { onUndated?.call(row); return false; } return day == (now ?? today); } /// The standard [belongsToToday] undated reporter: names the row, says which /// fields were looked for, and stays out of release logs. /// /// Deliberately not a `throw` and not a user-visible badge. A rider can do /// nothing about a backend row with no timestamp on it, and the old `Undated` /// chip put an internal defect on a screen he reads at a glance. This is for /// whoever is holding the console. static void logUndated(Map row) { final id = (row['orderid'] ?? row['pickupid'] ?? '?').toString(); debugPrint( '[SERVICEDAY] undated row excluded from today — order $id ' 'carries none of $dayKeys or $timeKeys', ); } } /// ───────────────────────────────────────────────────────────────────────── /// THE DAY TURNING OVER UNDER A SCREEN THAT IS ALREADY OPEN /// /// [ServiceDay] answers "which day is this record" at read time, which is the /// right shape for the data and not sufficient for a *screen*. Activity filters /// against `ServiceDay.today` as read at fetch time, so a rider who leaves the /// tab open through 23:59 — or backgrounds the app on it and picks the phone up /// at six — is looking at a list filtered against a day that has ended. Nothing /// is wrong in the store; the pixels are stale. /// /// This is the small amount of state that notices. It is deliberately a plain /// object with an injectable clock rather than logic inside a `State`: the /// cases worth testing are all "the wall clock moved while nobody was looking", /// and those are impossible to write against a real midnight. /// ───────────────────────────────────────────────────────────────────────── class ServiceDayRollover { /// Reads the wall clock. Injectable so a test can move it. final DateTime Function() clock; String _day; ServiceDayRollover({DateTime Function()? clock, String? day}) : clock = clock ?? DateTime.now, _day = day ?? ServiceDay.stamp((clock ?? DateTime.now)()); /// The service day the screen is currently showing. String get day => _day; /// The service day the wall clock is actually in. String get currentDay => ServiceDay.stamp(clock()); /// Whether the day has moved on since [day] was adopted. bool get hasRolled => _day != currentDay; /// Adopts the current day, reporting whether that was a change. /// /// The caller does the clearing; this only answers the question. Idempotent, /// so the timer and the resume listener can both call it and whichever /// arrives second finds the day already current. bool rollIfNeeded() { final now = currentDay; if (now == _day) return false; _day = now; return true; } /// How long until the next local midnight, plus a second. /// /// The second is not cosmetic. A timer woken *on* the boundary can find /// `DateTime.now()` still reading 23:59:59.999 after its own rounding, adopt /// the day it already had, and leave the screen on yesterday until something /// else happens to trigger it. /// /// `day + 1` is safe across month and year ends — `DateTime` normalises 32 /// January into 1 February — and it is the same local-calendar arithmetic /// [ServiceDay.stamp] does, so the wake lands on exactly the boundary the /// filter tests. Duration get untilNextDay { final now = clock(); final midnight = DateTime(now.year, now.month, now.day + 1); return midnight.difference(now) + const Duration(seconds: 1); } }