dispatch map, plan vs actual, and a fleet page
Three of the features the old console had and ours did not, built on what the data can actually support rather than on what the column names imply. Two findings changed the shape of the work: `riderlogs` is not a GPS trail. Every ping a rider sends carries the SAME coordinate — one rider's 2,404 pings on 14 August all read 11.052998, 76.929958, and the same holds on every day and region checked. Distance, speed and "time moving" cannot come from it. The Fleet page therefore reports presence only: who was online and for how long, inferred from the gaps between check-ins, because `login`, `logout` and `workhours` are empty on all 320,132 August rows. It says out loud that it cannot tell a rider parked all day from one who crossed the city. The delivery ladder is not the order its columns are in. `starttime` is later than `arrivaltime` on 316 of 316 rows, which looks corrupt and is not: `starttime` is per-DROP, stamped when the rider sets off for that address having finished the last one. Read as assign -> arrive -> pickup -> start -> deliver, every duration is positive. On tenant 916 that shows the bottleneck is not the riding: a median 69.5 minutes passes between handing an order to a rider and that rider reaching the shop, against 0.6 minutes at the counter. - Map tab on dispatch, fed by `deliveries.riderslat/lon` — the only rider positions that move (353 distinct across 461 rows). Tenant-scoped, so a shop sees its own rounds. The line joins stops in worked order and says it is not a route. - Plan vs actual tab: promised against delivered, and a step breakdown of where the hours go. `actualkms` is excluded — it equals the planned `kms` to the decimal on every delivered row, so it is a copy, not a measurement. - Fleet page in the platform console: a presence gantt and a map of where each rider is registered. - `ridername` holds a delivery status on more rows than it holds a name for two riders in five, so names are resolved by excluding the status vocabulary first. - leaflet, wrapped directly rather than via react-leaflet, lazy-loaded so only the pages with a map pay for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JYEsb8PNZ19G9R8gUjTU7n
This commit is contained in:
@@ -13,6 +13,7 @@ const NAV: readonly NavEntry[] = [
|
||||
{ to: '/nearle/onboard/tenant', label: 'Onboard tenant' },
|
||||
{ to: '/nearle/catalogue', label: 'Global catalogue' },
|
||||
{ to: '/nearle/partners', label: 'Rider partners' },
|
||||
{ to: '/nearle/fleet', label: 'Fleet' },
|
||||
{ to: '/nearle/uploads', label: 'Uploads' },
|
||||
];
|
||||
|
||||
|
||||
399
src/features/nearle-admin/pages/FleetPage.tsx
Normal file
399
src/features/nearle-admin/pages/FleetPage.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { AlertTriangle, Bike, CheckCircle2, Clock, Info, MapPin, Timer } from 'lucide-react';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { TrailMap, trailColour, type MapPin as Pin } from '@/components/TrailMap';
|
||||
import { useAllPartners, usePartnerRiderLogs } from '@/queries/hooks';
|
||||
import { duration, fleetDay, recommend, type RiderDay, type Severity } from '../riderShifts';
|
||||
import './fleet.css';
|
||||
|
||||
/**
|
||||
* Who was online, and for how long.
|
||||
*
|
||||
* ── Why this is a platform page and not a shop's ────────────────────────────
|
||||
*
|
||||
* `getriderlogs` filters on a partner or on a region — never on a tenant. A
|
||||
* partner's riders serve every merchant that partner supplies (one covers 48
|
||||
* shops, another 63), so there is no shop this could belong to, and scoping it
|
||||
* by region would hand one merchant every rider in the city.
|
||||
*
|
||||
* ── Why there is no distance, speed or utilisation on this page ─────────────
|
||||
*
|
||||
* Because the data cannot support any of them. `riderlogs` carries a position
|
||||
* on every row and it is the SAME position on every row for a given rider —
|
||||
* 2,404 pings from one rider on 14 August 2026, all reading 11.052998,
|
||||
* 76.929958, and the same on every day and region checked. The app stamps a
|
||||
* location once and repeats it on each heartbeat. A "moved 62% of their shift"
|
||||
* figure drawn from that would be invented, and it would be believed.
|
||||
*
|
||||
* The positions that do move are written on the delivery rows when a rider
|
||||
* moves a job along, and they are tenant-scoped — so the shop's own dispatch
|
||||
* board draws the map, and this page does not pretend to.
|
||||
*
|
||||
* ── What is real, and worth the page ────────────────────────────────────────
|
||||
*
|
||||
* The timestamps. A heartbeat means the app was open and the rider was
|
||||
* reachable, so the pings say who was on and for how long. The columns built to
|
||||
* answer exactly that — `login`, `logout`, `workhours`, `shorthours` — are
|
||||
* empty or zero on all 320,132 rows read for August, so this is the only way to
|
||||
* know, and it is inferred rather than recorded. The page says so out loud.
|
||||
*/
|
||||
export function FleetPage() {
|
||||
const partners = useAllPartners();
|
||||
const [partnerid, setPartnerid] = useState<number>(0);
|
||||
const [day, setDay] = useState(() => yesterday());
|
||||
|
||||
// The first partner, once they load — an empty page with a dropdown on it
|
||||
// makes the reader do a step the page could have done.
|
||||
useEffect(() => {
|
||||
if (partnerid === 0 && partners.data.length > 0) {
|
||||
setPartnerid(partners.data[0]?.partnerid ?? 0);
|
||||
}
|
||||
}, [partnerid, partners.data]);
|
||||
|
||||
const logs = usePartnerRiderLogs(partnerid || undefined, { fromdate: day, todate: day });
|
||||
|
||||
const fleet = useMemo(() => fleetDay(logs.data ?? []), [logs.data]);
|
||||
const advice = useMemo(() => recommend(fleet), [fleet]);
|
||||
const [focused, setFocused] = useState<number | null>(null);
|
||||
|
||||
/* One pin per rider, and each is the single location that rider's app has
|
||||
ever reported — hollow, because a filled pin says "here now" and this is
|
||||
not that. It is worth drawing anyway: it shows how the fleet is spread
|
||||
across the city, which nothing else on the platform answers. */
|
||||
const pins: Pin[] = useMemo(
|
||||
() =>
|
||||
fleet.riders.flatMap((rider, index) =>
|
||||
rider.place
|
||||
? [
|
||||
{
|
||||
id: rider.userid,
|
||||
lat: rider.place.lat,
|
||||
lng: rider.place.lng,
|
||||
label: rider.username,
|
||||
lines: [
|
||||
`${duration(rider.onlineMs)} online · ${rider.coverage.pings} check-ins`,
|
||||
'The only location this rider’s app has reported',
|
||||
],
|
||||
colour: trailColour(index),
|
||||
isFaded: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
[fleet.riders],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap={3}>
|
||||
<PageHeader
|
||||
title="Fleet"
|
||||
actions={
|
||||
<HStack gap={1} align="center" wrap="wrap">
|
||||
<select
|
||||
className="fleet-select"
|
||||
aria-label="Rider partner"
|
||||
value={partnerid}
|
||||
onChange={(event) => {
|
||||
setPartnerid(Number(event.target.value));
|
||||
setFocused(null);
|
||||
}}
|
||||
>
|
||||
{partners.data.length === 0 ? <option value={0}>No partners</option> : null}
|
||||
{partners.data.map((partner) => (
|
||||
<option key={partner.partnerid} value={partner.partnerid}>
|
||||
{partner.partnername ?? `Partner ${partner.partnerid}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
className="fleet-select"
|
||||
aria-label="Day"
|
||||
value={day}
|
||||
max={today()}
|
||||
onChange={(event) => {
|
||||
setDay(event.target.value);
|
||||
setFocused(null);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Banner advice={advice} />
|
||||
|
||||
<div className="kpi-grid">
|
||||
<KpiCard
|
||||
label="Riders online"
|
||||
value={String(fleet.riders.length)}
|
||||
note={
|
||||
fleet.riders.length === 0
|
||||
? 'nobody checked in on this day'
|
||||
: fleet.measured === fleet.riders.length
|
||||
? 'all with enough check-ins to time'
|
||||
: `${fleet.measured} with enough check-ins to time`
|
||||
}
|
||||
tone="accent"
|
||||
icon={<Bike size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Hours online"
|
||||
value={duration(fleet.onlineMs)}
|
||||
note="inferred from check-ins, not clock-ins"
|
||||
tone="neutral"
|
||||
icon={<Clock size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Average day"
|
||||
value={duration(fleet.measured > 0 ? fleet.onlineMs / fleet.measured : null)}
|
||||
note={`longest ${duration(fleet.longestMs)}`}
|
||||
tone={toneOf(advice.severity)}
|
||||
icon={<Timer size={15} />}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Reported places"
|
||||
value={String(pins.length)}
|
||||
note="one fixed point per rider — not a live position"
|
||||
tone="neutral"
|
||||
icon={<MapPin size={15} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fleet-body">
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
Where the fleet is registered
|
||||
</Text>
|
||||
<TrailMap
|
||||
pins={pins}
|
||||
height={420}
|
||||
emptyNote={
|
||||
logs.isLoading
|
||||
? 'Reading the day…'
|
||||
: 'No rider reported a location on this day.'
|
||||
}
|
||||
/>
|
||||
<Note>
|
||||
One pin per rider, and each is the only location that rider’s app has ever sent — it
|
||||
does not change between check-ins, so this is where they are registered, not where
|
||||
they are. Live positions are written when a rider moves an order along, and appear on
|
||||
that shop’s own dispatch board.
|
||||
</Note>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1.5} padding={2}>
|
||||
<Text type="label" size="sm" weight="semibold">
|
||||
The day, rider by rider
|
||||
</Text>
|
||||
{logs.isLoading ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Reading check-ins…
|
||||
</Text>
|
||||
) : fleet.riders.length === 0 ? (
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Nobody checked in on {day}.
|
||||
</Text>
|
||||
) : (
|
||||
<div className="gantt">
|
||||
{fleet.riders.map((rider, index) => (
|
||||
<GanttRow
|
||||
key={rider.userid}
|
||||
rider={rider}
|
||||
colour={trailColour(index)}
|
||||
window={fleet.window}
|
||||
isFocused={focused === rider.userid}
|
||||
onClick={() =>
|
||||
setFocused((prev) => (prev === rider.userid ? null : rider.userid))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{fleet.window ? (
|
||||
<div className="gantt-axis">
|
||||
<span>{clock(fleet.window.from)}</span>
|
||||
<span>{clock(fleet.window.to)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{focused !== null ? <ShiftDetail rider={fleet.riders.find((r) => r.userid === focused)} /> : null}
|
||||
</VStack>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2}>
|
||||
<HStack gap={1} align="center">
|
||||
<span className="fleet-note-icon">
|
||||
<Info size={14} />
|
||||
</span>
|
||||
<Text type="label" size="xsm" weight="semibold">
|
||||
What these numbers are, and are not
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
Nothing here comes from a timesheet. The rider app sends a check-in every twenty
|
||||
seconds or so and never closes a shift — <code>workhours</code>, <code>logout</code> and
|
||||
the rest are empty on every row — so a shift is read as a run of check-ins with no
|
||||
silence longer than forty minutes in it. That says who was reachable and when. It says
|
||||
nothing about how hard anybody worked: the location on those rows never changes, so a
|
||||
rider parked all day and one who crossed the city look identical from here. Riders who
|
||||
checked in too rarely to time are listed with blanks rather than given a figure.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── The gantt ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function GanttRow({
|
||||
rider,
|
||||
colour,
|
||||
window,
|
||||
isFocused,
|
||||
onClick,
|
||||
}: {
|
||||
rider: RiderDay;
|
||||
colour: string;
|
||||
window: { from: number; to: number } | null;
|
||||
isFocused: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const span = window ? Math.max(1, window.to - window.from) : 1;
|
||||
const at = (time: number) => (window ? ((time - window.from) / span) * 100 : 0);
|
||||
|
||||
return (
|
||||
<button type="button" className="gantt-row" data-active={isFocused} onClick={onClick}>
|
||||
<span className="gantt-name" title={rider.username}>
|
||||
<i style={{ background: colour }} />
|
||||
{rider.username}
|
||||
</span>
|
||||
|
||||
<span className="gantt-track">
|
||||
{rider.shifts.map((shift) => (
|
||||
<i
|
||||
key={shift.from}
|
||||
className="gantt-bar"
|
||||
style={{
|
||||
left: `${at(shift.from)}%`,
|
||||
width: `${Math.max(0.4, at(shift.to) - at(shift.from))}%`,
|
||||
background: colour,
|
||||
}}
|
||||
title={`Online ${clock(shift.from)}–${clock(shift.to)} · ${shift.pings} check-ins`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<span className="gantt-figure">
|
||||
{rider.onlineMs === null ? (
|
||||
<em title={`Only ${rider.coverage.pings} check-ins — too few to time a shift`}>—</em>
|
||||
) : (
|
||||
duration(rider.onlineMs)
|
||||
)}
|
||||
</span>
|
||||
<span className="gantt-figure gantt-quiet" title="Separate runs of check-ins">
|
||||
{rider.shifts.length || '—'}
|
||||
</span>
|
||||
<span className="gantt-figure gantt-quiet">{rider.coverage.pings}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftDetail({ rider }: { rider: RiderDay | undefined }) {
|
||||
if (!rider) return null;
|
||||
return (
|
||||
<div className="shift-detail">
|
||||
<strong>{rider.username}</strong>
|
||||
{rider.shifts.length === 0 ? (
|
||||
<span>
|
||||
{rider.coverage.pings} check-in{rider.coverage.pings === 1 ? '' : 's'}, too few to make a
|
||||
shift out of.
|
||||
</span>
|
||||
) : (
|
||||
<ul>
|
||||
{rider.shifts.map((shift) => (
|
||||
<li key={shift.from}>
|
||||
<span>
|
||||
{clock(shift.from)} – {clock(shift.to)}
|
||||
</span>
|
||||
<em>{duration(shift.to - shift.from)}</em>
|
||||
{/* A long silence inside a run means the shift is stitched across
|
||||
it, so the total is an upper bound rather than a reading. */}
|
||||
{shift.longestGapMs > 5 * 60_000 ? (
|
||||
<span className="shift-gap">
|
||||
quiet for {duration(shift.longestGapMs)} inside this run
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Chrome ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
function Banner({ advice }: { advice: ReturnType<typeof recommend> }) {
|
||||
const Icon =
|
||||
advice.severity === 'good' ? CheckCircle2 : advice.severity === 'watch' ? Info : AlertTriangle;
|
||||
return (
|
||||
<div className="fleet-banner" data-severity={advice.severity}>
|
||||
<Icon size={17} />
|
||||
<div>
|
||||
<strong>{advice.headline}</strong>
|
||||
<span>{advice.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fleet-inline-note">
|
||||
<Info size={13} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toneOf(severity: Severity): 'success' | 'warning' | 'error' {
|
||||
return severity === 'good' ? 'success' : severity === 'watch' ? 'warning' : 'error';
|
||||
}
|
||||
|
||||
/* ── Dates ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
function iso(date: Date): string {
|
||||
// Built from the local parts, not `toISOString()` — that converts to UTC and
|
||||
// in IST hands back yesterday's date for anything before 05:30.
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return iso(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Yesterday, as the default.
|
||||
*
|
||||
* A day that is still running is half a day, and "average shift 4h" read at
|
||||
* eleven in the morning describes a morning. Yesterday is a whole one.
|
||||
*/
|
||||
function yesterday(): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
return iso(date);
|
||||
}
|
||||
|
||||
function clock(at: number): string {
|
||||
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
263
src/features/nearle-admin/pages/fleet.css
Normal file
263
src/features/nearle-admin/pages/fleet.css
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* The fleet page: a banner, a map beside a gantt, and the gantt itself.
|
||||
*/
|
||||
|
||||
.fleet-select {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.fleet-select:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ── The recommendation banner ───────────────────────────────────────────── */
|
||||
|
||||
/* Severity is carried by the left rail and the icon, not by a wash of colour
|
||||
across the whole strip — a full red panel reads as an error the console has
|
||||
hit, rather than a finding about the fleet. */
|
||||
.fleet-banner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left-width: 3px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.fleet-banner > svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.fleet-banner div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fleet-banner strong {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.fleet-banner span {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.fleet-banner[data-severity='good'] {
|
||||
border-left-color: #0f8a5f;
|
||||
color: #0f8a5f;
|
||||
}
|
||||
|
||||
.fleet-banner[data-severity='watch'] {
|
||||
border-left-color: #b45309;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.fleet-banner[data-severity='act'] {
|
||||
border-left-color: #b91c1c;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Map beside gantt ────────────────────────────────────────────────────── */
|
||||
|
||||
.fleet-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.fleet-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The gantt ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.gantt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* The track is the flexible column; the name and the three figures are fixed,
|
||||
so every row's bars start and end at the same x and the day lines up down the
|
||||
list. A shrink-to-fit name column would stagger them by rider. */
|
||||
.gantt-row {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr) 62px 34px 54px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gantt-row:hover {
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.gantt-row[data-active='true'] {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-tint);
|
||||
}
|
||||
|
||||
.gantt-row:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.gantt-name {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The same colour as this rider's line on the map — the only thing tying the
|
||||
two panels together. */
|
||||
.gantt-name i {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.gantt-track {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.gantt-bar {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.gantt-figure {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-1);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gantt-quiet {
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.gantt-figure em {
|
||||
color: var(--color-ink-4);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.gantt-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* Aligned to the track column, so the two clock readings sit under the ends
|
||||
of the bars they label rather than under the whole row. */
|
||||
padding: 4px 160px 0 150px;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.fleet-note-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ── One rider's runs, opened from the gantt ─────────────────────────────── */
|
||||
|
||||
.shift-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.shift-detail strong {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
.shift-detail > span {
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.shift-detail ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.shift-detail li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.shift-detail em {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-1);
|
||||
}
|
||||
|
||||
/* A long silence inside a run means the shift is stitched across it, so the
|
||||
total above is an upper bound. Said on the row rather than in a footnote. */
|
||||
.shift-gap {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.fleet-inline-note {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface-subtle);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.fleet-inline-note svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
251
src/features/nearle-admin/riderShifts.test.ts
Normal file
251
src/features/nearle-admin/riderShifts.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Presence, and the line between a reading and a guess.
|
||||
*
|
||||
* The refusals matter most: `riderlogs` really does return days with a handful
|
||||
* of pings in them, and "0h 2m online" derived from two heartbeats would be
|
||||
* read as a fact about somebody's working day.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { duration, fleetDay, groupPings, recommend, riderDay, type RiderPing } from './riderShifts';
|
||||
|
||||
const DAY = Date.parse('2026-08-14T09:00:00+05:30');
|
||||
|
||||
/**
|
||||
* Pings every 20 seconds — the live cadence (median gap 19 s, p90 30 s).
|
||||
*
|
||||
* The position is fixed on purpose: that is what production sends. One rider's
|
||||
* 2,404 pings on 14 August all read 11.052998, 76.929958.
|
||||
*/
|
||||
function heartbeat(opts: {
|
||||
from: number;
|
||||
count: number;
|
||||
userid?: number;
|
||||
username?: string;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
}): RiderPing[] {
|
||||
return Array.from({ length: opts.count }, (_, i) => ({
|
||||
userid: opts.userid ?? 7,
|
||||
username: opts.username ?? 'Murali P',
|
||||
logdate: new Date(opts.from + i * 20_000).toISOString(),
|
||||
latitude: opts.lat ?? '11.052998',
|
||||
longitude: opts.lng ?? '76.929958',
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── Grouping ────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('pings are grouped per rider and put in time order', () => {
|
||||
const grouped = groupPings([
|
||||
{ userid: 2, logdate: '2026-08-14T09:00:30+05:30' },
|
||||
{ userid: 1, logdate: '2026-08-14T09:00:20+05:30' },
|
||||
{ userid: 1, logdate: '2026-08-14T09:00:10+05:30' },
|
||||
]);
|
||||
assert.equal(grouped.size, 2);
|
||||
assert.deepEqual(
|
||||
grouped.get(1)!.map((ping) => ping.logdate),
|
||||
['2026-08-14T09:00:10+05:30', '2026-08-14T09:00:20+05:30'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a row with an unreadable date is skipped rather than sorted to the epoch', () => {
|
||||
const grouped = groupPings([
|
||||
{ userid: 1, logdate: '2026-08-14T09:00:00+05:30' },
|
||||
{ userid: 1, logdate: 'not a date' },
|
||||
]);
|
||||
assert.equal(grouped.get(1)!.length, 1);
|
||||
});
|
||||
|
||||
/* ── One rider's day ─────────────────────────────────────────────────────── */
|
||||
|
||||
test('a continuous run of heartbeats is one shift', () => {
|
||||
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 200 }));
|
||||
assert.equal(day.shifts.length, 1);
|
||||
// 199 gaps of 20 s.
|
||||
assert.ok(Math.abs((day.onlineMs as number) - 199 * 20_000) < 1000);
|
||||
});
|
||||
|
||||
// Forty minutes of silence is the app closed or the rider gone home. Counting
|
||||
// it as time online would inflate every figure on the page.
|
||||
test('a long silence splits the day and is not counted as time online', () => {
|
||||
const day = riderDay(7, 'Murali P', [
|
||||
...heartbeat({ from: DAY, count: 100 }),
|
||||
...heartbeat({ from: DAY + 3 * 60 * 60 * 1000, count: 100 }),
|
||||
]);
|
||||
assert.equal(day.shifts.length, 2);
|
||||
assert.ok(
|
||||
(day.onlineMs as number) < 75 * 60 * 1000,
|
||||
'the three-hour gap was counted as time online',
|
||||
);
|
||||
});
|
||||
|
||||
test('a ten-minute pause stays inside one shift', () => {
|
||||
// The first run is 100 pings at 20 s, so it ends 33 minutes in; the second
|
||||
// starts ten minutes after that.
|
||||
const firstRunMs = 99 * 20_000;
|
||||
const day = riderDay(7, 'M', [
|
||||
...heartbeat({ from: DAY, count: 100 }),
|
||||
...heartbeat({ from: DAY + firstRunMs + 10 * 60 * 1000, count: 100 }),
|
||||
]);
|
||||
assert.equal(day.shifts.length, 1);
|
||||
assert.ok(day.shifts[0]!.longestGapMs > 8 * 60 * 1000, 'the pause is reported, not hidden');
|
||||
});
|
||||
|
||||
test('opening the app once and closing it is not a shift', () => {
|
||||
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 1 }));
|
||||
assert.deepEqual(day.shifts, []);
|
||||
assert.equal(day.onlineMs, null);
|
||||
assert.equal(day.first, DAY, 'the ping still happened, and is still reported');
|
||||
});
|
||||
|
||||
// The refusal that matters.
|
||||
test('a day with four heartbeats states no hours', () => {
|
||||
const day = riderDay(7, 'Murali P', heartbeat({ from: DAY, count: 4 }));
|
||||
assert.equal(day.onlineMs, null);
|
||||
assert.equal(day.coverage.isSound, false);
|
||||
assert.equal(day.coverage.pings, 4);
|
||||
assert.equal(day.shifts.length, 1, 'the run is still drawn, it just carries no figure');
|
||||
});
|
||||
|
||||
test('no pings at all is an empty day rather than a throw', () => {
|
||||
const day = riderDay(7, 'Murali P', []);
|
||||
assert.deepEqual(day.shifts, []);
|
||||
assert.equal(day.onlineMs, null);
|
||||
assert.equal(day.first, null);
|
||||
assert.equal(day.place, null);
|
||||
});
|
||||
|
||||
/* ── The one position ────────────────────────────────────────────────────── */
|
||||
|
||||
// Not a trail. The whole module exists because this value never changes.
|
||||
test("the rider's single reported position is read from the pings", () => {
|
||||
const day = riderDay(7, 'M', heartbeat({ from: DAY, count: 50 }));
|
||||
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
|
||||
});
|
||||
|
||||
// 0,0 is the Atlantic and is what a phone sends before it has a fix. One of
|
||||
// these becomes a map centred on the Gulf of Guinea.
|
||||
test('a 0,0 fix is not taken as a place', () => {
|
||||
const day = riderDay(7, 'M', [
|
||||
...heartbeat({ from: DAY, count: 10 }),
|
||||
...heartbeat({ from: DAY + 200_000, count: 5, lat: '0', lng: '0' }),
|
||||
]);
|
||||
assert.deepEqual(day.place, { lat: 11.052998, lng: 76.929958 });
|
||||
});
|
||||
|
||||
test('a rider whose app never sent a position has none, rather than a zero', () => {
|
||||
const day = riderDay(7, 'M', [
|
||||
{ userid: 7, logdate: new Date(DAY).toISOString() },
|
||||
{ userid: 7, logdate: new Date(DAY + 20_000).toISOString() },
|
||||
]);
|
||||
assert.equal(day.place, null);
|
||||
});
|
||||
|
||||
/* ── The fleet ───────────────────────────────────────────────────────────── */
|
||||
|
||||
test('the fleet is every rider in the batch, longest day first', () => {
|
||||
const day = fleetDay([
|
||||
...heartbeat({ from: DAY, count: 60, userid: 9, username: 'Varun Edward' }),
|
||||
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'Murali P' }),
|
||||
]);
|
||||
assert.deepEqual(day.riders.map((rider) => rider.username), ['Murali P', 'Varun Edward']);
|
||||
assert.equal(day.measured, 2);
|
||||
});
|
||||
|
||||
test('a rider with too few check-ins is listed but left out of the totals', () => {
|
||||
const day = fleetDay([
|
||||
...heartbeat({ from: DAY, count: 300, userid: 7, username: 'A' }),
|
||||
...heartbeat({ from: DAY, count: 3, userid: 9, username: 'B' }),
|
||||
]);
|
||||
assert.equal(day.riders.length, 2, 'a thin rider still belongs on the list');
|
||||
assert.equal(day.measured, 1);
|
||||
assert.ok(Math.abs(day.onlineMs - 299 * 20_000) < 1000, "B's three pings were added in");
|
||||
});
|
||||
|
||||
test('the window spans every rider, for a shared gantt axis', () => {
|
||||
const day = fleetDay([
|
||||
...heartbeat({ from: DAY, count: 100, userid: 7 }),
|
||||
...heartbeat({ from: DAY + 60 * 60 * 1000, count: 100, userid: 9 }),
|
||||
]);
|
||||
assert.equal(day.window!.from, DAY);
|
||||
assert.ok(day.window!.to > DAY + 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('the longest day is reported, not just the total', () => {
|
||||
const day = fleetDay([
|
||||
...heartbeat({ from: DAY, count: 300, userid: 7 }),
|
||||
...heartbeat({ from: DAY, count: 100, userid: 9 }),
|
||||
]);
|
||||
assert.ok(Math.abs(day.longestMs - 299 * 20_000) < 1000);
|
||||
});
|
||||
|
||||
test('an empty batch gives an empty fleet with no window', () => {
|
||||
const day = fleetDay([]);
|
||||
assert.deepEqual(day.riders, []);
|
||||
assert.equal(day.window, null);
|
||||
assert.equal(day.onlineMs, 0);
|
||||
});
|
||||
|
||||
/* ── The recommendation ──────────────────────────────────────────────────── */
|
||||
|
||||
test('nobody reporting in is stated as ambiguous, not as an idle fleet', () => {
|
||||
const advice = recommend(fleetDay([]));
|
||||
assert.match(advice.headline, /Nobody was online/);
|
||||
assert.match(advice.detail, /app was not opened/);
|
||||
});
|
||||
|
||||
test('thin coverage outranks any finding drawn from it', () => {
|
||||
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 3 })));
|
||||
assert.equal(advice.severity, 'watch');
|
||||
assert.match(advice.headline, /Too few check-ins/);
|
||||
});
|
||||
|
||||
test('an ordinary shift length is graded good', () => {
|
||||
// Eight hours of heartbeats at one every 20 s.
|
||||
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 1440 })));
|
||||
assert.equal(advice.severity, 'good');
|
||||
assert.match(advice.headline, /8h/);
|
||||
});
|
||||
|
||||
test('a fifteen-hour day is flagged', () => {
|
||||
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 2700 })));
|
||||
assert.equal(advice.severity, 'act');
|
||||
assert.match(advice.detail, /long day to be on call/);
|
||||
});
|
||||
|
||||
test('very short days are flagged as either short shifts or a closed app', () => {
|
||||
const advice = recommend(fleetDay(heartbeat({ from: DAY, count: 200 })));
|
||||
assert.equal(advice.severity, 'watch');
|
||||
assert.match(advice.detail, /closed between jobs/);
|
||||
});
|
||||
|
||||
// The claim this module must never make. The position never changes, so a rider
|
||||
// parked all day and one who crossed the city are indistinguishable here.
|
||||
test('nothing in the advice claims to know how hard anybody worked', () => {
|
||||
for (const count of [0, 3, 200, 1440, 2700]) {
|
||||
const advice = recommend(fleetDay(heartbeat({ from: DAY, count })));
|
||||
const text = `${advice.headline} ${advice.detail}`.toLowerCase();
|
||||
for (const word of ['moving', 'idle', 'utilis', 'km', 'distance', 'productiv']) {
|
||||
assert.ok(!text.includes(word), `advice for ${count} pings claimed "${word}": ${text}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('riders left out for thin coverage are named in the advice', () => {
|
||||
const advice = recommend(
|
||||
fleetDay([
|
||||
...heartbeat({ from: DAY, count: 1440, userid: 7, username: 'A' }),
|
||||
...heartbeat({ from: DAY, count: 4, userid: 9, username: 'B' }),
|
||||
]),
|
||||
);
|
||||
assert.match(advice.detail, /checked in too rarely/);
|
||||
});
|
||||
|
||||
test('durations read the way a person says them', () => {
|
||||
assert.equal(duration(null), '—');
|
||||
assert.equal(duration(0), '—');
|
||||
assert.equal(duration(45 * 60 * 1000), '45m');
|
||||
assert.equal(duration(2 * 60 * 60 * 1000), '2h');
|
||||
assert.equal(duration(2 * 60 * 60 * 1000 + 20 * 60 * 1000), '2h 20m');
|
||||
});
|
||||
324
src/features/nearle-admin/riderShifts.ts
Normal file
324
src/features/nearle-admin/riderShifts.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* When a partner's riders were logged in.
|
||||
*
|
||||
* ── What `riderlogs` actually contains ──────────────────────────────────────
|
||||
*
|
||||
* It looks like a GPS trail and is not one. Every row carries a latitude and a
|
||||
* longitude, and every row for a given rider carries the SAME latitude and
|
||||
* longitude. Measured on 2026-09-09 across partner 44 and regions 1, 2 and 23,
|
||||
* on four separate days:
|
||||
*
|
||||
* 14 Aug 12,976 pings, 5 riders — distinct positions per rider: 1, 1, 1, 1, 1
|
||||
* 05 Aug 12,514 pings, 5 riders — 1, 1, 1, 1, 1
|
||||
* 20 Aug 13,595 pings, 6 riders — 1, 1, 1, 1, 1, 1
|
||||
* 28 Aug 8,948 pings, 4 riders — 1, 1, 1, 1
|
||||
*
|
||||
* One rider sent 2,404 pings on 14 August, all reading 11.052998, 76.929958.
|
||||
* The app stamps a position once and repeats it on every heartbeat. So there is
|
||||
* no trail to draw, no distance to total and no "moving versus idle" to compute
|
||||
* — anything of that shape derived from this table would be invented.
|
||||
*
|
||||
* The rider positions that DO move live on the `deliveries` rows, written when
|
||||
* a rider moves a job along; see `deliveryTrack`.
|
||||
*
|
||||
* ── What is real here, and worth having ─────────────────────────────────────
|
||||
*
|
||||
* The timestamps. A ping means the app was open and the rider was reachable, so
|
||||
* the pings say when somebody was on and for how long — which is the one thing
|
||||
* a platform operator cannot get anywhere else, and which the columns built to
|
||||
* answer it (`login`, `logout`, `workhours`, `shorthours`) never carry: they are
|
||||
* empty or zero on every row of all 320,132 read for August.
|
||||
*
|
||||
* So a shift is inferred: a run of pings with no long silence in it. That is a
|
||||
* weaker claim than a clock-in, and everything below is shaped around saying so
|
||||
* rather than rounding it up to a timesheet.
|
||||
*/
|
||||
|
||||
/** One ping as `getriderlogs` returns it. */
|
||||
export interface RiderPing {
|
||||
userid: number;
|
||||
username?: string;
|
||||
logdate: string;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence that ends a shift.
|
||||
*
|
||||
* The app pings every twenty seconds or so — median gap 19 s, p90 30 s, measured
|
||||
* over one rider's 2,404 pings. Forty minutes with nothing is the app closed,
|
||||
* the phone out of signal, or the rider gone home; all three mean the same thing
|
||||
* here. Set long deliberately: too short and a rider waiting out a quiet hour
|
||||
* gets their day chopped into a dozen shifts, too long and two days run
|
||||
* together. Forty minutes survives a long stop and never bridges a night.
|
||||
*/
|
||||
const SHIFT_GAP_MS = 40 * 60 * 1000;
|
||||
|
||||
/** Fewer pings than this in a day and the shift is a guess, not a reading. */
|
||||
const MIN_PINGS_FOR_A_FIGURE = 30;
|
||||
|
||||
/** One unbroken run of pings. */
|
||||
export interface Shift {
|
||||
from: number;
|
||||
to: number;
|
||||
pings: number;
|
||||
/** The longest silence inside it. A big one means the run is stitched. */
|
||||
longestGapMs: number;
|
||||
}
|
||||
|
||||
/** How much evidence stands behind a rider's day. */
|
||||
export interface Coverage {
|
||||
pings: number;
|
||||
/**
|
||||
* The longest silence anywhere in the day, INCLUDING the gaps between shifts.
|
||||
* Distinct from `Shift.longestGapMs`, which is the longest silence inside one
|
||||
* run: a rider with two shifts three hours apart has a three-hour figure here
|
||||
* and a small one on each shift, and both readings are correct.
|
||||
*/
|
||||
longestGapMs: number;
|
||||
/** False when there is too little data to state an hours figure at all. */
|
||||
isSound: boolean;
|
||||
}
|
||||
|
||||
/** The single position this rider's app has ever reported. */
|
||||
export interface RiderPlace {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface RiderDay {
|
||||
userid: number;
|
||||
username: string;
|
||||
shifts: Shift[];
|
||||
/** Sum of shift spans. Blank when coverage is too thin to state one. */
|
||||
onlineMs: number | null;
|
||||
/** First ping of the day, and last. What the gantt draws between. */
|
||||
first: number | null;
|
||||
last: number | null;
|
||||
coverage: Coverage;
|
||||
/**
|
||||
* Where the app says this rider is — one fixed point, not a position.
|
||||
* Null when the app never sent a usable one.
|
||||
*/
|
||||
place: RiderPlace | null;
|
||||
}
|
||||
|
||||
export interface FleetDay {
|
||||
riders: RiderDay[];
|
||||
/** Riders with enough pings to state hours for. The rest are listed, not timed. */
|
||||
measured: number;
|
||||
/** Total hours online across the measured riders. */
|
||||
onlineMs: number;
|
||||
/** The longest anyone was online. The busiest day on the page. */
|
||||
longestMs: number;
|
||||
/** Earliest and latest ping across everyone — the gantt's axis. */
|
||||
window: { from: number; to: number } | null;
|
||||
}
|
||||
|
||||
/** Pings grouped by rider, in time order, with unusable rows dropped. */
|
||||
export function groupPings(logs: readonly RiderPing[]): Map<number, RiderPing[]> {
|
||||
const byRider = new Map<number, RiderPing[]>();
|
||||
for (const ping of logs) {
|
||||
if (!Number.isFinite(Date.parse(ping.logdate))) continue;
|
||||
const list = byRider.get(ping.userid);
|
||||
if (list) list.push(ping);
|
||||
else byRider.set(ping.userid, [ping]);
|
||||
}
|
||||
for (const list of byRider.values()) {
|
||||
list.sort((a, b) => Date.parse(a.logdate) - Date.parse(b.logdate));
|
||||
}
|
||||
return byRider;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one position a rider's app reports.
|
||||
*
|
||||
* Taken from the last ping that carries a usable pair. 0,0 is the Atlantic and
|
||||
* is what a phone sends before it has a fix — the single most common way a map
|
||||
* ends up centred on the Gulf of Guinea.
|
||||
*/
|
||||
function placeOf(pings: readonly RiderPing[]): RiderPlace | null {
|
||||
for (let i = pings.length - 1; i >= 0; i -= 1) {
|
||||
const lat = Number(pings[i]?.latitude);
|
||||
const lng = Number(pings[i]?.longitude);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
return { lat, lng };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One rider's day, from their pings. */
|
||||
export function riderDay(userid: number, username: string, pings: readonly RiderPing[]): RiderDay {
|
||||
const times = pings
|
||||
.map((ping) => Date.parse(ping.logdate))
|
||||
.filter((at) => Number.isFinite(at));
|
||||
|
||||
if (times.length === 0) {
|
||||
return {
|
||||
userid,
|
||||
username,
|
||||
shifts: [],
|
||||
onlineMs: null,
|
||||
first: null,
|
||||
last: null,
|
||||
coverage: { pings: 0, longestGapMs: 0, isSound: false },
|
||||
place: placeOf(pings),
|
||||
};
|
||||
}
|
||||
|
||||
const shifts: Shift[] = [];
|
||||
let start = times[0] as number;
|
||||
let previous = start;
|
||||
let count = 1;
|
||||
let longestInShift = 0;
|
||||
let longestOverall = 0;
|
||||
|
||||
const close = () => {
|
||||
// A shift of one ping has no duration and nothing to draw — somebody
|
||||
// opening the app and closing it. Counting it would add a zero-length shift
|
||||
// to the day and a sliver to the gantt.
|
||||
if (count > 1) {
|
||||
shifts.push({ from: start, to: previous, pings: count, longestGapMs: longestInShift });
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 1; i < times.length; i += 1) {
|
||||
const at = times[i] as number;
|
||||
const gap = at - previous;
|
||||
if (gap > longestOverall) longestOverall = gap;
|
||||
if (gap > SHIFT_GAP_MS) {
|
||||
close();
|
||||
start = at;
|
||||
count = 1;
|
||||
longestInShift = 0;
|
||||
} else {
|
||||
count += 1;
|
||||
if (gap > longestInShift) longestInShift = gap;
|
||||
}
|
||||
previous = at;
|
||||
}
|
||||
close();
|
||||
|
||||
const onlineMs = shifts.reduce((total, shift) => total + (shift.to - shift.from), 0);
|
||||
const isSound = times.length >= MIN_PINGS_FOR_A_FIGURE && onlineMs > 0;
|
||||
|
||||
return {
|
||||
userid,
|
||||
username,
|
||||
shifts,
|
||||
onlineMs: isSound ? onlineMs : null,
|
||||
first: times[0] as number,
|
||||
last: times[times.length - 1] as number,
|
||||
coverage: { pings: times.length, longestGapMs: longestOverall, isSound },
|
||||
place: placeOf(pings),
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole fleet's day, from one batch of `getriderlogs` rows. */
|
||||
export function fleetDay(logs: readonly RiderPing[]): FleetDay {
|
||||
const grouped = groupPings(logs);
|
||||
const names = new Map<number, string>();
|
||||
for (const ping of logs) {
|
||||
if (ping.username && !names.has(ping.userid)) names.set(ping.userid, ping.username);
|
||||
}
|
||||
|
||||
const riders = [...grouped.entries()]
|
||||
.map(([userid, pings]) => riderDay(userid, names.get(userid) ?? `Rider ${userid}`, pings))
|
||||
.sort((a, b) => (b.onlineMs ?? 0) - (a.onlineMs ?? 0));
|
||||
|
||||
const sound = riders.filter((rider) => rider.coverage.isSound);
|
||||
|
||||
let from = Infinity;
|
||||
let to = -Infinity;
|
||||
for (const rider of riders) {
|
||||
if (rider.first !== null && rider.first < from) from = rider.first;
|
||||
if (rider.last !== null && rider.last > to) to = rider.last;
|
||||
}
|
||||
|
||||
return {
|
||||
riders,
|
||||
measured: sound.length,
|
||||
onlineMs: sound.reduce((total, rider) => total + (rider.onlineMs ?? 0), 0),
|
||||
longestMs: sound.reduce((most, rider) => Math.max(most, rider.onlineMs ?? 0), 0),
|
||||
window: Number.isFinite(from) ? { from, to } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export type Severity = 'good' | 'watch' | 'act';
|
||||
|
||||
export interface Recommendation {
|
||||
severity: Severity;
|
||||
headline: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the day suggests, graded.
|
||||
*
|
||||
* Everything stated here is about PRESENCE — who was on and for how long. It is
|
||||
* deliberately silent about how hard anybody worked, because this data cannot
|
||||
* tell: the position never changes, so a rider parked all day and a rider who
|
||||
* crossed the city look identical from here.
|
||||
*
|
||||
* Thin coverage outranks every other finding: hours drawn from four pings are
|
||||
* worse than no hours, because they will be believed.
|
||||
*/
|
||||
export function recommend(day: FleetDay): Recommendation {
|
||||
if (day.riders.length === 0) {
|
||||
return {
|
||||
severity: 'watch',
|
||||
headline: 'Nobody was online on this day',
|
||||
detail:
|
||||
'No rider app reported in. Either no shifts ran, or the app was not opened — the two look identical from here.',
|
||||
};
|
||||
}
|
||||
|
||||
if (day.measured === 0) {
|
||||
return {
|
||||
severity: 'watch',
|
||||
headline: 'Too few check-ins to state hours',
|
||||
detail: `${day.riders.length} rider${day.riders.length === 1 ? '' : 's'} reported in, but none often enough to measure a shift. Hours are left blank rather than guessed.`,
|
||||
};
|
||||
}
|
||||
|
||||
const averageMs = day.onlineMs / day.measured;
|
||||
const thin = day.riders.length - day.measured;
|
||||
const thinNote =
|
||||
thin > 0
|
||||
? ` ${thin} of ${day.riders.length} checked in too rarely to be included.`
|
||||
: '';
|
||||
|
||||
const who = `${day.measured} rider${day.measured === 1 ? '' : 's'}`;
|
||||
|
||||
if (averageMs > 13 * 60 * 60 * 1000) {
|
||||
return {
|
||||
severity: 'act',
|
||||
headline: `${who} averaged ${duration(averageMs)} online`,
|
||||
detail: `That is a very long day to be on call, and the longest was ${duration(day.longestMs)}. Worth checking against what the shifts are meant to be.${thinNote}`,
|
||||
};
|
||||
}
|
||||
if (averageMs < 3 * 60 * 60 * 1000) {
|
||||
return {
|
||||
severity: 'watch',
|
||||
headline: `${who} averaged only ${duration(averageMs)} online`,
|
||||
detail: `Short days, or an app that is being closed between jobs. The order flow for the day says which is more likely.${thinNote}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
severity: 'good',
|
||||
headline: `${who} averaged ${duration(averageMs)} online`,
|
||||
detail: `Ordinary shift lengths, the longest being ${duration(day.longestMs)}.${thinNote}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** "6h 20m" — the gantt's totals and the axis labels. */
|
||||
export function duration(ms: number | null): string {
|
||||
if (ms === null || ms <= 0) return '—';
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
if (hours === 0) return `${rest}m`;
|
||||
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
|
||||
}
|
||||
Reference in New Issue
Block a user