production
This commit is contained in:
@@ -31,6 +31,13 @@
|
||||
/// 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.
|
||||
@@ -51,11 +58,37 @@ abstract final class 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',
|
||||
'createdat',
|
||||
];
|
||||
|
||||
@@ -69,27 +102,134 @@ abstract final class ServiceDay {
|
||||
if (v.length >= 10) return v.substring(0, 10);
|
||||
}
|
||||
for (final k in timeKeys) {
|
||||
final raw = (row[k] ?? '').toString().trim();
|
||||
if (raw.isEmpty) continue;
|
||||
final t = DateTime.tryParse(raw);
|
||||
if (t != null) return stamp(t.isUtc ? t.toLocal() : t);
|
||||
// ── 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 with no date is kept, and kept quietly ──
|
||||
/// ── A row that cannot prove it is today's is not today's ──
|
||||
///
|
||||
/// It is on this screen because this session produced it or the API returned
|
||||
/// it for this rider; the missing field is a data-quality problem, not
|
||||
/// evidence that the work happened yesterday. Dropping it would silently
|
||||
/// lose a rider's completed stop, and labelling it — the old `Undated` badge
|
||||
/// — puts an internal defect on a screen he is supposed to read at a glance
|
||||
/// and can do nothing about. So it stays, in today, unmarked.
|
||||
static bool belongsToToday(Map<String, dynamic> row, {String? now}) {
|
||||
/// 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) return true;
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user