Files
doormile_milderapp/lib/data/service_day.dart
2026-08-28 15:07:30 +05:30

328 lines
15 KiB
Dart

/// ─────────────────────────────────────────────────────────────────────────
/// 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<String> 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<String> 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',
'expectedpickuptime',
'createdat',
'createdon',
// ── Last, because it is the app's own guess and not the hub's record ──
//
// When the booking first appeared in this rider's queue, written by
// [WorkRepository] and merged onto the row there. It is the weakest answer
// on this list — a booking the rider's phone met for the first time this
// morning may have been raised last night — so every clock the backend
// actually sends outranks it.
//
// It is on the list because the alternative is worse. A row the backend
// dates with nothing is a row no screen can place, and the observable
// result was yesterday's assignments sitting on Home under a heading that
// says today. An approximate day beats no day.
'assignedat',
];
/// 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<String, dynamic> 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<String, dynamic> row, {
String? now,
void Function(Map<String, dynamic> row)? onUndated,
}) {
final day = of(row);
if (day.isEmpty) {
onUndated?.call(row);
return false;
}
return day == (now ?? today);
}
/// The rows of [stops] that belong to [now], for a screen that shows the
/// rider's **live** work — Home's run, Deliveries' queue.
///
/// ── Why the work screens needed this and Activity did not ──
///
/// `GET /miler/bookings` returns the rider's whole **open** set, not his day.
/// A booking assigned on Tuesday and never closed comes back on Wednesday and
/// on Thursday, so every screen built from that call was showing a week and
/// calling it today: yesterday's stops inside the kitchen dropdown, inside
/// the trip counts, inside the progress ring and inside the delivery queue.
///
/// Activity already filtered, with [belongsToToday], and that is why the two
/// halves of the app disagreed about the same order. This is the same
/// predicate, applied at the same kind of boundary — the fetch — so that
/// every figure a screen prints is computed from one set rather than each
/// widget filtering for itself. A filter inside a card would have left the
/// counts above it still totalling Tuesday.
///
/// ── The burden of proof is on the row ──
///
/// Undated rows go too, exactly as they do on Activity. They are rare now:
/// `ApiConfig.pickupFromBooking` carries every clock the backend sends
/// instead of dropping them at the adapter, and `WorkRepository` stamps
/// `assignedat` on everything still in front of the rider. A stop with no
/// date at all is one the app has never seen as open work, which is not this
/// morning's assignment.
///
/// ── One safety net, and it is deliberately narrow ──
///
/// If the day came back with rows and **not one of them** can be dated, that
/// is not a stale queue — it is the app having lost the ability to date
/// anything at all, and the answer to that is not to show a rider an empty
/// screen while his hub believes he has twenty stops. That case keeps
/// everything and says so in the log. One undated row among dated ones is a
/// data defect and is dropped; *every* row undated is a broken build, and
/// blanking the screen would hide it behind something that reads like good
/// news.
///
/// [where] names the caller in the log line, so two screens filtering the
/// same day are told apart. Nothing is deleted anywhere: this is a view
/// filter, and both local stores and the backend keep every record they had.
static List<Map<String, dynamic>> onlyToday(
List<Map<String, dynamic>> stops, {
String? now,
String where = 'DAY',
}) {
if (stops.isEmpty) return stops;
final day = now ?? today;
final kept = <Map<String, dynamic>>[];
var undated = 0;
for (final s in stops) {
if (of(s).isEmpty) {
undated++;
logUndated(s);
continue;
}
if (belongsToToday(s, now: day)) kept.add(s);
}
if (undated == stops.length) {
debugPrint(
'[$where] $day — not one of ${stops.length} stops carries a date. '
'Showing the lot rather than an empty day; check the adapter.',
);
return stops;
}
if (kept.length != stops.length) {
debugPrint(
'[$where] $day — kept ${kept.length} of ${stops.length} stops'
'${undated > 0 ? ' ($undated undated)' : ''}',
);
}
return kept;
}
/// 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<String, dynamic> 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);
}
}