328 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|