Files
doormile_milderapp/lib/data/assignment_lookup.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

214 lines
8.2 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:miler/data/miler_api.dart';
/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject
/// endpoints key on.
///
/// Why this exists: `POST /miler/assignments/{id}/accept` looks the row up by
/// `bookingassignmentid`, but everything the app renders comes from
/// `GET /miler/bookings`, whose rows only carry `bookingid`. Those two ids come
/// from different sequences and do not match, so sending a booking id makes the
/// backend answer 404 "assignment not found" — the rider taps Accept, sees
/// success, and the booking is never actually accepted server-side.
///
/// `GET /miler/assignments` is the only place the pairing is exposed, so we
/// fetch it and keep a short-lived bookingid -> bookingassignmentid map.
class AssignmentLookup {
AssignmentLookup._();
/// bookingid (as string) -> bookingassignmentid
static final Map<String, int> _cache = <String, int>{};
/// bookingid (as string) -> the hub's solved stop number.
///
/// ── Why the sequence lives here and not on the booking ──
///
/// `step` is written onto **`bookingassignments`**, not onto the booking: the
/// hub's batch-assign endpoint sends each affected rider's whole active set to
/// the route optimizer and writes the returned road-network order back onto
/// the assignment rows. So `GET /miler/bookings` — the app's one read of the
/// day — cannot carry it, and this endpoint is the only place it exists.
///
/// It was being thrown away. This class fetched the assignment rows for their
/// ids and dropped everything else, so `Trip.sortStops` — which documents
/// `step` as authoritative and says the app must never second-guess the
/// admin's order — never saw one and fell back to booked time on every run.
/// The rider was choosing his own order while the hub believed it had solved
/// one for him.
///
/// `step: 0` means *not sequenced*, never *first*, and is not stored.
static final Map<String, int> _steps = <String, int>{};
static DateTime? _fetchedAt;
/// Assignments change whenever the hub assigns work, so the map goes stale
/// quickly. Short enough to pick up new work, long enough that a burst of
/// accepts doesn't refetch per tap.
static const Duration _ttl = Duration(seconds: 30);
/// Assignment states that are still actionable by the rider. A booking can
/// legitimately have several assignment rows over its life (assign → reject →
/// reassign), so when more than one exists we want the live one, not the
/// rejected corpse.
static const Set<String> _actionable = {'Assigned', 'Accepted'};
static bool get _isStale {
final at = _fetchedAt;
return at == null || DateTime.now().difference(at) > _ttl;
}
static void invalidate() {
_cache.clear();
_steps.clear();
_fetchedAt = null;
}
/// The hub's stop order, by booking id, refreshed on the same TTL as the ids.
///
/// Best-effort by contract: sequencing is a separate service and the flow doc
/// is explicit that it being down must leave bookings *assigned but
/// unordered* rather than undo anything. An empty map therefore means "no
/// solved order available", which the sort reads as "fall back to booked
/// time" — not as "every stop is step 0".
static Future<Map<String, int>> steps() async {
if (_isStale) await _refresh();
return Map<String, int>.unmodifiable(_steps);
}
/// The assignment id for [bookingId], or null when the backend has no
/// assignment row for it (or the call fails).
///
/// Refetches once on a cache miss before giving up: a booking the hub just
/// assigned will not be in a cache populated moments earlier, and that is
/// exactly the case the rider hits when accepting fresh work.
static Future<int?> idForBooking(dynamic bookingId) async {
final String key = bookingId?.toString().trim() ?? '';
if (key.isEmpty) return null;
if (_isStale) {
await _refresh();
}
final cached = _cache[key];
if (cached != null) return cached;
// Miss against a warm cache — the assignment may have been created since.
await _refresh();
return _cache[key];
}
static Future<void> _refresh() async {
try {
// Through [MilerApi], not a hand-rolled `http.get`. It was the latter,
// which meant this one call carried its own header building, its own
// envelope unwrapping and its own idea of what a 2xx is — and, because it
// bypassed `MilerApi.client`, it was the only request in the app that a
// test could not stub, so the suite made real network calls to the
// production API while checking a repository.
final res = await MilerApi.assignments();
if (!res.ok) {
debugPrint('[ASSIGNMENTS] fetch failed: HTTP ${res.status}');
return;
}
final List<dynamic> data = res.list;
if (data.isEmpty && res.data is! List) {
debugPrint('[ASSIGNMENTS] no assignment rows in the response');
return;
}
final next = buildIndex(data);
_cache
..clear()
..addAll(next);
final nextSteps = buildStepIndex(data);
_steps
..clear()
..addAll(nextSteps);
_fetchedAt = DateTime.now();
debugPrint(
'[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids, '
'${_steps.length} sequenced',
);
} catch (e) {
debugPrint('[ASSIGNMENTS] fetch error: $e');
}
}
/// Collapses the assignment rows into one bookingid -> bookingassignmentid
/// entry per booking.
///
/// The backend orders by `assignedat DESC`, so the first row seen for a
/// booking is its newest. Keep that one, but let an actionable row override a
/// newer terminal one — a booking that was assigned, rejected, then
/// reassigned must resolve to the live assignment, not the rejected corpse.
@visibleForTesting
static Map<String, int> buildIndex(List<dynamic> rows) {
final index = <String, int>{};
final tookActionable = <String>{};
for (final row in rows.whereType<Map>()) {
final bookingKey = row['bookingid']?.toString().trim() ?? '';
final assignmentId = _asInt(row['bookingassignmentid']);
if (bookingKey.isEmpty || assignmentId == null) continue;
final status = (row['assignmentstatus'] ?? '').toString().trim();
final isActionable = _actionable.contains(status);
if (!index.containsKey(bookingKey)) {
index[bookingKey] = assignmentId;
if (isActionable) tookActionable.add(bookingKey);
} else if (isActionable && !tookActionable.contains(bookingKey)) {
index[bookingKey] = assignmentId;
tookActionable.add(bookingKey);
}
}
return index;
}
/// Collapses the assignment rows into one bookingid -> step entry.
///
/// Same newest-first, actionable-wins rule as [buildIndex] — a booking that
/// was assigned, rejected and reassigned must take the live assignment's
/// sequence, not the rejected corpse's — so the two indexes cannot describe
/// different assignment rows for the same booking.
@visibleForTesting
static Map<String, int> buildStepIndex(List<dynamic> rows) {
final index = <String, int>{};
final tookActionable = <String>{};
for (final row in rows.whereType<Map>()) {
final bookingKey = row['bookingid']?.toString().trim() ?? '';
if (bookingKey.isEmpty) continue;
final step = _asInt(row['step']) ?? 0;
final status = (row['assignmentstatus'] ?? '').toString().trim();
final isActionable = _actionable.contains(status);
final seen =
index.containsKey(bookingKey) || tookActionable.contains(bookingKey);
if (seen && !(isActionable && !tookActionable.contains(bookingKey))) {
continue;
}
if (isActionable) tookActionable.add(bookingKey);
// 0 is "not sequenced". Storing it would make an unsequenced stop look
// like it had been solved into position zero.
if (step > 0) {
index[bookingKey] = step;
} else {
index.remove(bookingKey);
}
}
return index;
}
static int? _asInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return int.tryParse(v?.toString() ?? '');
}
}