Skip to content

Money and metrics

Delivery earnings are small amounts summed many times, which is exactly where binary floating point drifts. Every monetary value in DashPilot is decimal, and every rate derived from one is built on demand rather than stored.

Money

Money wraps Decimal, stores amounts unrounded, and rounds only when a caller asks. Division returns an optional because a zero divisor is a normal state for rate calculations: a shift may have no working time or no recorded distance, and the app must show "no rate" rather than invent one.

Money.formatted(currencyCode:locale:) is the only place a monetary string is built. No view assembles one from a symbol and a number, and no view configures a formatter. It rounds to displayScale there and only there, which keeps rounding a display decision: the store holds what the driver typed, exactly.

The currency is one fixed code (Money.displayCurrencyCode, "USD"), not the device locale's. Nothing in the app converts between currencies or records which currency an amount was earned in, so reading the currency from the locale would relabel a US driver's earnings as euros the moment they set their phone to another region. Locale still decides how the amount is written, meaning symbol placement and separators. It does not decide what the money is.

Reading what a driver types

MoneyInput is the locale-aware layer Money(exact:) deliberately is not. Money(exact:) reads one canonical form for fixtures and stored values, while a driver types whatever their keyboard offers.

Nothing in it reinterprets input to make it work, which is the reason it exists at all. Decimal(string:) stops at the first character it cannot read, so "12abc" would silently become 12 and "1.2.3" would become 1.2. Every candidate is therefore validated in full: a currency symbol and surrounding whitespace are removed, then digits, one decimal separator, and grouping separators only in positions this locale actually writes them, before any number is built from it.

Internal whitespace is rewritten as the grouping separator rather than deleted, because several locales group thousands with a space, and deleting it would read "125 50" as twelve thousand.

The rejections are separate cases because each is a different sentence the interface has to say: nothing entered, not a number, more precision than the currency has, negative, or beyond the bound.

  • More than two fraction digits is refused, not rounded. Rounding at the point of entry would store a number the driver did not type. Rounding belongs to display.
  • Negative is refused. Gross earnings are what a shift paid; money the work cost is an expense, which is recorded separately and is itself never negative. Zero is allowed, and meaningful, on both. The parser is one; only the sentence naming the subject differs, so a driver typing what fuel cost is not told that gross earnings cannot be negative. See Recorded expenses.
  • MoneyInput.maximumAmount (1,000,000) is a guard against pathological input, such as a pasted page of digits or a stuck key, not a judgement about what a driver can earn. Decimal holds 38 significant digits, so without a bound a shift could store an amount no formatting in the app is meaningful for. It is checked in one place and documented so it can be raised if it is ever wrong.

MoneyInput takes its Locale, and the editor passes the environment's, so every parsing and formatting test states the locale it is asserting about instead of inheriting whichever region the machine running the suite happens to be set to.

A figure that is not money still reads through the same locale rules

A vehicle's fuel economy is a plain decimal a driver types, and it is not money: it has no currency, no symbol to strip and no cents, and its zero is refused rather than recorded, because it is the divisor of a fuel estimate. So MilesPerGallonInput owns the meaning and refuses zero in its own words, while the locale-aware reading comes from MoneyInput.decimal(from:), which is amount(from:) stopping one step short of calling the result money. Which character is the decimal separator, where a grouping separator may fall, and that a space inside a number is a separator rather than something to delete are properties of the driver's locale, not of what the number means. A second copy of those rules is how two fields on one phone come to disagree about what "1 234,5" is. See Estimated fuel and net.

Completed-shift metrics

A completed shift is read as three rates and two derived durations. Every rate is gross, every figure is derived, and each says in its own wording what it divides by. The product-level definitions are on Earnings and metrics.

The interval union

DeliveryActiveTimeCalculator turns a shift's deliveries into the time at least one of them was open. It takes plain DeliveryActiveInterval values — acceptedAt, and deliveredAt ?? cancelledAt — imports neither SwiftUI nor SwiftData and queries no store, so every case is tested without a container or a rendered view.

It collects the usable intervals, sorts by start, sweeps once merging each interval into the open stretch when it begins at or before that stretch's end, and adds the merged lengths: O(n log n) on the sort, linear on the sweep. Nothing walks a timeline second by second or buckets the shift, both of which would trade exactness for work.

Two properties follow and are asserted rather than assumed. The result is order-independent — the sort is the only thing that reads order, and merging takes the later of two ends. Touching intervals leave no gap — the merge condition is start <= end, because one delivery ending as the next begins is a continuous stretch of delivery activity.

Anomalies are counted, never repaired

The lifecycle cannot produce an interval with no end, an end before its start, or an interval outside the shift it belongs to. A damaged or unexpected store can. None of them is filled in, reversed or pulled into range, and none becomes a zero-length contribution that would look like a measurement: each is left out and counted on DeliveryActiveTime, so a total short of its sources is visible rather than silent.

Usable intervals are clipped to the completed shift's own window before merging, which is what guarantees active time never exceeds the shift's elapsed duration and keeps the figure honest to its name — time within this shift. Clipping applies to the derived reading only; stored timestamps are never rewritten.

A running shift has no window and no final figure. Active-time metrics describe finished shifts.

Nothing is persisted

No rate has ever needed a schema change. A stored hourlyRate, earningsPerMile or activeDuration would be a second answer to a question the store can already answer: it would keep the old number after the calculation improved, and it would have to be recomputed and rewritten every time a driver edited an amount or recorded a delivery. ShiftMetrics is built on demand from the shift's own timestamps, its recorded amount, its deliveries and its measured route, exactly like RouteDistance is, and a delivery's own grossPerDeliveryHour is built the same way from its amount and its two timestamps.

The store's versions record facts a driver entered — an amount on a shift (v4), an amount on a delivery (v7) — never a figure derived from them.

Explicit unavailable states

ShiftRate is .available(Money) or .unavailable(ShiftRateUnavailability). A Money? would carry the value and lose the reason, and the reasons are the point of the type.

Reason The fact it states
shiftNotCompleted The shift is still running; finalised rates describe finished shifts
earningsNotRecorded No amount has been entered. Not an amount of zero
noWorkingTime The shift recorded no working time: none at all, one clamped to zero by a backwards device clock, or one the driver kept paused throughout
noDeliveriesRecorded No delivery was recorded. Not a delivery active time of zero
deliveryActiveTimeNotMeasurable Deliveries exist, but none describes a usable interval within the shift
zeroDeliveryActiveTime Delivery intervals were measured, and they covered no time
noRouteRecorded The shift retained no usable position at all
routeNotMeasurable Positions exist, but no two of them were recorded continuously
zeroRecordedDistance A distance was measured, and it was zero

The precedence is shiftNotCompleted, then earningsNotRecorded, then the denominator's own reason. A missing numerator is the same absence for all three rates, so it is reported once rather than described three times in the vocabulary of three different denominators.

The enum is CaseIterable, and the wording suite iterates allCases: a reason added without a sentence fails a test rather than reaching a driver as an empty line.

Three distinctions carry the whole design. Missing earnings never become zero: a shift nobody has entered an amount for produces no rate, while a shift recorded as paying nothing produces a real $0.00/hr and $0.00 / recorded mi. An unmeasurable route never becomes zero miles: 0.0 in the denominator is not a small number, it is an absent one, so a shift with no route shows no per-mile rate rather than a rate divided by nothing. No recorded deliveries never becomes zero active time: the shift section shows neither duration at all, rather than 0 min beside a figure that would read as a measurement.

ShiftRateUnavailability.explanation gives each case one sentence, none of them implying zero, and the sentence for a missing amount asks for one ("Add what this shift paid to see this rate").

One delivery's own rate

A delivered delivery that carries an amount also derives grossPerDeliveryHour: its amount over its own acceptedAt to deliveredAt interval. It has the same shape and the same discipline — DeliveryEarningsRate is .available(Money) or .unavailable(DeliveryRateUnavailability), and its three reasons are deliveryNotCompleted (cancelled, or still running), earningsNotRecorded and zeroDuration.

Cancelled and running share one reason deliberately. The rate needs a lifecycle that ran to completion, and measuring a cancelled delivery to its cancellation instead would put a figure in the same column as deliveries that finished, under a name — "cancelled hourly rate" — that describes nothing. A cancelled delivery may hold an amount, and showing that amount is the whole claim.

These figures are never aggregated. Stacked lifecycles overlap, so summing or averaging them would produce a denominator that never existed. The figure that spans deliveries is the shift's delivery active time, which unions the intervals. Nothing in the app adds two of them together.

Precision

Money stays exact. The numerator is the Decimal the driver typed, division goes through Money.divided(by:scale:), and no monetary value passes through binary floating point.

The denominators are the boundary, and it is deliberate. A duration is a TimeInterval and a distance is a Double of metres; both are binary before this calculation ever sees them and neither can be made exact afterwards. Each therefore crosses into Decimal exactly once, in ShiftMetricsCalculator.decimal(_:scale:), rounded to a scale far finer than the result is read at: a duration to the millisecond, a distance to a millionth of a mile, which is under two millimetres against positions carrying error radii of up to 100 m.

Every hourly rate in the app, over a shift's working time, over its delivery active time, and over one delivery's own lifecycle — divides through one shared function, ShiftMetricsCalculator.grossPerHour(of:over:), so they cannot drift apart in the last cent. A unioned active duration and a single delivery's duration cross the boundary by exactly the same rule a working one does. That function answers nil, never zero, for a duration that cannot be a denominator; naming what the absence means is the caller's job, because the same zero denominator is "this shift covered no time" in one place and "this delivery's lifecycle covered no time" in another.

Decimal(_: Double) is deliberately not used. Scaling to an integer and dividing by a power of ten is an explicit rule stated in one place rather than a platform's conversion behaviour.

The quotient keeps six fraction digits, four more than the two it is displayed at, so the value the display rounds is effectively the exact quotient rather than one already rounded at an adjacent scale. Rounding to cents happens where every other rounding in the app happens, in Money.formatted.

Miles come from RouteDistance.miles, which is the same conversion formattedMiles(locale:) uses. No metres-to-miles constant exists anywhere else, so the rate and the distance beside it cannot disagree about what a mile is.

Accessibility

Each metric is one combined element with an explicit label, so a rate is heard as a claim ("$19.30 gross earnings per recorded mile") rather than as a heading and a number, and an absent one as "No gross earnings per recorded mile" followed by the reason. The active-hour rate spells out its denominator for the same reason: "$79.62 gross earnings per delivery active hour", never a spoken abbreviation.

A per-delivery amount is spoken with the delivery it belongs to — "Gross earnings for Delivery 2, $14.75" — and so is its rate: "$35.40 gross earnings per recorded delivery hour, for Delivery 2". A log of finished deliveries puts several amounts on one screen, and a bare figure would identify its record by nothing but where it happened to sit. The controls are named the same way: "Add gross earnings for Delivery 2", "Edit gross earnings for Delivery 1", "Remove gross earnings from Delivery 3".

The three durations on a completed shift are told apart in words rather than by position — "3 hours working shift time", "1 hour, 5 minutes delivery active time", "1 hour, 55 minutes non-delivery time". CompletedShiftDetailView.durationText(_:width:) takes a unit width for the reason RouteDistance.formattedMiles(width:) does: hr reads well and hears badly, so the spoken form asks for .wide and gets its words from the same units and the same rule. Nothing rewrites the abbreviated string. The history row is one combined element too, read as sentences rather than as the separators and abbreviations that look right and sound wrong:

Saturday, August 23, 2025. 5:46 PM to 8:46 PM. 3 hr. $86.25 gross earnings recorded. 4.5 miles recorded. Partial route: DashPilot was not recording for part of this shift, so more miles were driven than were recorded. $28.75 gross earnings per working hour.