231 lines
8.6 KiB
Dart
231 lines
8.6 KiB
Dart
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// STOP COMPLIANCE — did the rider make the ETA, and did he take the route?
|
|
///
|
|
/// Both facts were already being computed and then thrown away. The bonus-point
|
|
/// rule in `PickupsController.updatePickedupStatus` is literally "was this
|
|
/// closed before `eta_endtime`", and the distance travelled to the stop is
|
|
/// measured to bill rider charges. Neither ever reached the rider: he was paid
|
|
/// (or not) for punctuality he could not see, and "did I go the way the hub
|
|
/// routed me" had no answer anywhere in the app.
|
|
///
|
|
/// This is that pair, stamped onto the stop when it is completed and read back
|
|
/// on the Activity tab and in Rewards.
|
|
///
|
|
/// ── Why it is stamped and not recomputed ──
|
|
///
|
|
/// The inputs are gone by the time anyone looks: the ETA deadline lives in a
|
|
/// SharedPreferences key that the next stop overwrites, and the distance is
|
|
/// measured from the previous stop's location, which has also moved on. The
|
|
/// only moment both are true is the moment of completion, so that is where the
|
|
/// record is written.
|
|
///
|
|
/// ── Unknown is a real answer ──
|
|
///
|
|
/// A hub that assigns no ETA, a stop whose GPS never resolved: those are not
|
|
/// failures by the rider and are never shown as one. Every field is nullable
|
|
/// and the UI says "not measured" rather than inventing a verdict — a
|
|
/// compliance score that quietly counts unknowns as misses is a score that
|
|
/// punishes riders for the back office's gaps.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
library;
|
|
|
|
/// How far past the planned distance still counts as "took the route".
|
|
///
|
|
/// A route figure is a straight-line-ish estimate from the hub's planner, and a
|
|
/// rider obeys one-ways, diversions and closed roads it knows nothing about. A
|
|
/// quarter over plus half a kilometre of slack is the difference between "took
|
|
/// a detour" and "went somewhere else" — tight enough to catch a genuine
|
|
/// wander, loose enough that ordinary traffic never trips it.
|
|
const double kRouteTolerance = 1.25;
|
|
const double kRouteGraceKm = 0.5;
|
|
|
|
class StopCompliance {
|
|
/// Closed before the ETA deadline. Null when no ETA was ever set.
|
|
final bool? onTime;
|
|
|
|
/// How late, when [onTime] is false.
|
|
final Duration? lateBy;
|
|
|
|
/// Distance actually ridden to this stop, and what the hub planned.
|
|
final double? actualKm;
|
|
final double? plannedKm;
|
|
|
|
const StopCompliance({
|
|
this.onTime,
|
|
this.lateBy,
|
|
this.actualKm,
|
|
this.plannedKm,
|
|
});
|
|
|
|
static const StopCompliance unknown = StopCompliance();
|
|
|
|
/// Took the assigned route, within [kRouteTolerance]. Null when either
|
|
/// distance is missing — a planned figure of zero is missing, not perfect.
|
|
bool? get onRoute {
|
|
final actual = actualKm;
|
|
final planned = plannedKm;
|
|
if (actual == null || planned == null || planned <= 0 || actual <= 0) {
|
|
return null;
|
|
}
|
|
return actual <= (planned * kRouteTolerance) + kRouteGraceKm;
|
|
}
|
|
|
|
/// Kilometres beyond what the route allowed for. Null unless [onRoute] is
|
|
/// false — there is no "negative detour" worth reporting.
|
|
double? get extraKm {
|
|
if (onRoute != false) return null;
|
|
return (actualKm! - plannedKm!).clamp(0, double.infinity).toDouble();
|
|
}
|
|
|
|
/// True only when both halves are known and both are met. This is the one
|
|
/// that earns the reward.
|
|
bool get isClean => onTime == true && onRoute == true;
|
|
|
|
/// Nothing was measurable for this stop.
|
|
bool get isUnknown => onTime == null && onRoute == null;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
if (onTime != null) 'ontime': onTime,
|
|
if (lateBy != null) 'latebyseconds': lateBy!.inSeconds,
|
|
if (actualKm != null) 'actualkm': actualKm,
|
|
if (plannedKm != null) 'plannedkm': plannedKm,
|
|
};
|
|
|
|
/// Reads the compliance of a finished stop.
|
|
///
|
|
/// Prefers the stamp written at completion; falls back to whatever the
|
|
/// backend's own row carries, so a stop completed before this existed — or on
|
|
/// another device — still says what it can.
|
|
static StopCompliance of(Map<String, dynamic> stop) {
|
|
final raw = stop['compliance'];
|
|
final Map<String, dynamic> m = raw is Map
|
|
? Map<String, dynamic>.from(raw)
|
|
: const <String, dynamic>{};
|
|
|
|
final bool? onTime = _bool(m['ontime']);
|
|
final int? lateSeconds = _int(m['latebyseconds']);
|
|
|
|
return StopCompliance(
|
|
onTime: onTime,
|
|
lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds),
|
|
// ── Three spellings, because the app writes two of them itself ──
|
|
//
|
|
// `compliance.actualkm` is the contract's. `riderkms` is the backend's
|
|
// own name on a booking row that came straight from the API. And
|
|
// `actualkms` is what **this app** posts on the arrival and pickup writes
|
|
// (`PickupsController`), so it is the key on any record that was stamped
|
|
// locally before the queue caught up.
|
|
//
|
|
// Reading only the first two meant a stop the rider had just finished
|
|
// reported no distance at all until a poll replaced the row — which is
|
|
// most of the rows on Activity for most of a shift, and it is why the
|
|
// day's total read zero.
|
|
actualKm:
|
|
_double(m['actualkm']) ??
|
|
_double(stop['riderkms']) ??
|
|
_double(stop['actualkms']),
|
|
plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']),
|
|
);
|
|
}
|
|
|
|
static bool? _bool(dynamic v) {
|
|
if (v is bool) return v;
|
|
if (v is num) return v != 0;
|
|
final s = v?.toString().toLowerCase().trim();
|
|
if (s == 'true' || s == '1') return true;
|
|
if (s == 'false' || s == '0') return false;
|
|
return null;
|
|
}
|
|
|
|
static int? _int(dynamic v) {
|
|
if (v is num) return v.toInt();
|
|
return int.tryParse(v?.toString().trim() ?? '');
|
|
}
|
|
|
|
static double? _double(dynamic v) {
|
|
if (v == null) return null;
|
|
// ── Zero is not a measurement, whichever type it arrives as ──
|
|
//
|
|
// A numeric `0` returned `0.0` here while the string `"0"` returned null,
|
|
// so the same absent distance resolved two different ways depending on
|
|
// whether the row came off JSON or off a locally stamped map. Callers all
|
|
// guard with `> 0`, so nothing was visibly wrong — which is precisely why
|
|
// it would have gone on being inconsistent.
|
|
if (v is num) return v == 0 ? null : v.toDouble();
|
|
final parsed = double.tryParse(
|
|
v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''),
|
|
);
|
|
if (parsed == null || parsed == 0) return null;
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
/// The day's compliance, for the Rewards page.
|
|
class ComplianceSummary {
|
|
/// Stops that could be judged at all — the denominator.
|
|
final int measured;
|
|
final int onTime;
|
|
final int onRoute;
|
|
|
|
/// Stops where both held. This is what the reward is paid on.
|
|
final int clean;
|
|
|
|
/// Finished stops with nothing measurable, kept separate so they are never
|
|
/// silently counted as misses.
|
|
final int unmeasured;
|
|
|
|
const ComplianceSummary({
|
|
required this.measured,
|
|
required this.onTime,
|
|
required this.onRoute,
|
|
required this.clean,
|
|
required this.unmeasured,
|
|
});
|
|
|
|
static const ComplianceSummary empty = ComplianceSummary(
|
|
measured: 0,
|
|
onTime: 0,
|
|
onRoute: 0,
|
|
clean: 0,
|
|
unmeasured: 0,
|
|
);
|
|
|
|
int get onTimePercent =>
|
|
measured == 0 ? 0 : ((onTime / measured) * 100).round();
|
|
int get onRoutePercent =>
|
|
measured == 0 ? 0 : ((onRoute / measured) * 100).round();
|
|
int get cleanPercent =>
|
|
measured == 0 ? 0 : ((clean / measured) * 100).round();
|
|
|
|
/// Points earned: one per stop that made both its ETA and its route.
|
|
///
|
|
/// Deliberately a count and not a percentage — a rider who did four clean
|
|
/// stops out of four has not earned the same as one who did twelve, and a
|
|
/// percentage would say he had.
|
|
int get points => clean;
|
|
|
|
static ComplianceSummary from(Iterable<Map<String, dynamic>> stops) {
|
|
int measured = 0, onTime = 0, onRoute = 0, clean = 0, unmeasured = 0;
|
|
|
|
for (final stop in stops) {
|
|
final c = StopCompliance.of(stop);
|
|
if (c.isUnknown) {
|
|
unmeasured++;
|
|
continue;
|
|
}
|
|
measured++;
|
|
if (c.onTime == true) onTime++;
|
|
if (c.onRoute == true) onRoute++;
|
|
if (c.isClean) clean++;
|
|
}
|
|
|
|
return ComplianceSummary(
|
|
measured: measured,
|
|
onTime: onTime,
|
|
onRoute: onRoute,
|
|
clean: clean,
|
|
unmeasured: unmeasured,
|
|
);
|
|
}
|
|
}
|