Data model¶
Eleven persisted entities, and a small set of value types derived from them. Current schema version: v16.
Shift¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
startedAt |
Date |
Recorded when the shift starts, and never rewritten |
endedAt |
Date? |
nil means the shift is still running. This is the only definition of "active" |
deliveries |
[Delivery] |
Cascade delete, inverse of Delivery.shift |
offers |
[Offer] |
Cascade delete, inverse of Offer.shift |
pauses |
[ShiftPause] |
Cascade delete, inverse of ShiftPause.shift |
routeSuspensions |
[RouteSuspension] |
Cascade delete, inverse of RouteSuspension.shift. The stretches the driver recorded the vehicle as parked. Read by nothing that measures time |
grossEarningsAmount |
Decimal? |
Private. nil means no amount recorded, which is not zero |
fuelMilesPerGallonValue |
Decimal? |
The vehicle fuel economy this shift's fuel estimate is worked out under, as the driver typed it. A snapshot, never a reference to a current figure. Always greater than zero where present, because it is the divisor. nil means none recorded |
fuelGasPricePerGallonAmount |
Decimal? |
What a gallon cost, as the assumption this shift is estimated under. nil means none recorded; 0 means the fuel was recorded as costing nothing |
fuelVehicleName |
String? |
What the vehicle this shift's fuel economy came from was called, as recorded when the shift started. A label, never an input: no figure reads it. A copy rather than a reference, so a shift stays intelligible when the profile is renamed or deleted. nil where the economy was typed by hand |
Derived, never stored:
| Member | Meaning |
|---|---|
isActive |
endedAt == nil. Unchanged by pausing: a paused shift is unfinished |
lifecycleState |
running, paused or ended, derived from endedAt and the open pause |
openPause |
The pause with no end, or nil. What "paused" means |
isPaused |
lifecycleState == .paused |
pausesInOrder |
This shift's pauses sorted by start |
pauseIntervals |
One ShiftPauseInterval per pause |
openRouteSuspension |
The stretch parked with no end, or nil. What "parked" means |
isRouteSuspended |
Whether the vehicle is recorded as parked right now. False for an ended shift and for a paused one, in both cases because those close an open stretch |
routeSuspensionsInOrder |
This shift's stretches parked, sorted by start |
routeSuspensionIntervals |
One RouteSuspensionInterval per stretch |
suspendedTime(asOf:) |
A RouteSuspendedTime unioning those stretches within the window. Subtracted from nothing |
completedSuspendedTime |
The same for a finished shift, nil while unfinished |
completedDuration |
Elapsed seconds for a finished shift, clamped at zero |
elapsed(asOf:) |
Elapsed seconds for a running shift, clamped at zero |
measuredWindow(asOf:) |
startedAt to the shift's end, or to the moment being read at |
pausedTime(asOf:) |
A ShiftPausedTime unioning the pauses within that window |
completedPausedTime |
The same for a finished shift, nil while unfinished |
workingDuration(asOf:) |
elapsed − paused, clamped at zero. Stops growing while paused |
completedWorkingDuration |
The same for a finished shift, nil while unfinished |
routeSamples() |
This shift's retained positions, fetched, oldest first. Not a stored collection |
routeSampleCount |
How many positions the route holds, counted rather than loaded |
routeSamples(after:) |
The positions fixed strictly after an instant, which an end-time correction removes |
routeSampleCount(after:) |
How many those are, counted rather than loaded, for the confirmation that states it |
recordedDistance(...) |
A RouteDistance measured from the retained route |
grossEarnings |
The stored decimal as a Money, or nil |
fuelAssumptions |
The two stored columns as a FuelAssumptions, which is the only place they become one |
vehicleContext |
The name and economy this shift recorded, as a ShiftVehicleContext. Reads no preference |
mayCorrectRunningFuelAssumptions(...) |
Whether the shift is still running and its route has measured no distance, which is the correction's whole eligibility rule |
fuelEstimate(for:) |
A FuelEstimate over a recorded distance and this shift's own assumptions, or the reason there is none |
profitability(for:) |
A ShiftProfitability: recorded earnings less the estimated fuel, and that over the shift's working hours |
activeDeliveries |
This shift's deliveries that are neither delivered nor cancelled, in acceptance order |
deliveriesInOrder |
This shift's deliveries sorted by acceptance, with identity breaking a tie |
numberedDeliveries |
The same list paired with the local Delivery 1, Delivery 2 labels |
numberedActiveDeliveries |
The unfinished ones, keeping the numbers they have everywhere else |
deliverySummary |
A DeliverySummary counting completed, cancelled and in-progress |
completedWindow |
startedAt...endedAt for a finished shift, nil while running or if the stored end precedes the start |
deliveryActiveIntervals |
One DeliveryActiveInterval per delivery: acceptedAt, and deliveredAt ?? cancelledAt |
deliveryActiveTime(...) |
A DeliveryActiveTime unioning those intervals within completedWindow |
offersInOrder |
This shift's offers sorted by acceptance, with identity breaking a tie |
activeOffers |
The offers still holding at least one delivery in progress |
numberedOffers |
The same list paired with the local Offer 1, Offer 2 labels, each carrying its deliveries under their shift-wide numbers |
numberedOffer(containing:) |
The numbered offer a delivery arrived in, or nil for one recording none |
beginOffer(deliveryCount:at:) is the only thing in the app that creates a delivery, and the only
thing that records a new acceptance. It rejects a count below one, an offer on an ended shift, and an
acceptance before the shift began, and returns the offer and its deliveries for the caller to insert,
so a refused write leaves nothing behind. There is deliberately no maximum: how much work a driver
accepted is a fact about their work, and the stepper on screen bounds a control rather than the model.
makeOffer(regrouping:) is the only other thing that creates an offer, and it creates no delivery: it
records deliveries the shift already holds as an offer of their own, for a driver correcting which
deliveries arrived together. It requires the deliveries up front, rejects an empty group and a
delivery from another shift, and takes the earliest acceptance among them as the new offer's own,
which is a moment the driver really recorded. Unlike beginOffer it is allowed on a shift that has
ended, because restating a grouping is not recording new work.
beginPause(at:) rejects a pause on an ended shift, a second open pause, and a start before the
shift's. endOpenPause(at:) rejects a resume with nothing open and one on an ended shift; the
returned pause is inserted by the caller, so a refused write leaves nothing behind.
end(at:) rejects ending a shift twice or ending it before it started. apply(_:) is the only thing
that rewrites endedAt afterwards, taking a ShiftEndCorrection that has already been checked and
rejecting a shift whose recorded end is not the one that correction was built against;
endCorrection(to:nextShiftStartedAt:) is the adapter that gathers the shift's start, its recorded
end, its pauses and every lifecycle instant its deliveries record for that check. Both are reached
through ShiftEndCorrectionService, which refuses a running shift, deletes the route positions an
earlier end puts outside the shift, and commits once for the two together, so no ordering exists
in which the end moves while the positions survive. setGrossEarnings(_:)
rejects a negative amount and an amount on an unfinished shift. clearGrossEarnings() removes the
amount, which is a distinct operation from recording zero.
ShiftPause¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
startedAt |
Date |
When the driver recorded pausing |
endedAt |
Date? |
nil while the driver has not resumed. An open pause is what "paused" means |
offer |
Offer? |
The accepted offer this delivery arrived in. Optional because SwiftData models a reference that way, and because a pre-v12 store had none until the migration gave each delivery its own. It groups and does not govern: no timestamp, figure, fetch or delete rule reads it |
shift |
Shift? |
The only place the relationship is declared; Shift holds no matching collection. Optional only because SwiftData models a reference that way. The initializer requires a shift. Carries no delete rule, so ShiftService.deleteCompletedShift(_:) removes a shift's positions explicitly |
A row rather than a flag on Shift. A boolean could say a shift is paused now but not for how long
or how many times; an accumulated "paused seconds" would be a running sum the app had to keep correct
across every crash and failed save, which is the kind of derived value this project does not persist.
Shift.endedAt is untouched by pausing, so endedAt == nil is still the only definition of
"unfinished" and a paused shift is recovered after a relaunch by the same fetch as a running one.
end(at:) rejects closing a pause twice or closing it before it began. apply(_:) rewrites both
timestamps to a correction that ShiftPauseCorrection has already checked, and rejects an open
pause: a pause with no end is the state the driver is in, and it is closed by resuming or by ending
the shift, which reconcile route capture and the Live Activity as they go.
Shift.pauseCorrection(from:to:replacing:) is the adapter that gathers the shift's window, its other
pauses and its delivery intervals for that check; Shift.addMissedPause(_:) is the only thing that
creates a pause outside the live lifecycle, and like beginPause(at:) it leaves the context insert
to the caller. Both are reached through ShiftPauseCorrectionService, which refuses a running shift
outright and commits once per correction.
RouteSuspension¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
startedAt |
Date |
When the driver recorded parking |
endedAt |
Date? |
nil while the driver has not recorded driving again. An open row is what "parked" means |
shift |
Shift? |
The shift this belongs to, and never a delivery. Optional only because SwiftData models the inverse of a to-many that way |
A row rather than a flag, for the reason ShiftPause is one: a boolean could say the vehicle is
parked now and not for how long, how many times or when, and a route's coverage has to be able to say
all three. It also means a shift left parked when the app is terminated comes back parked with no
recovery code, because the row is the only place the state lives.
It joins the shift and nothing else, at any version. Whether the vehicle is moving is a fact about the driver and their vehicle: a driver shopping for one order while carrying another has one vehicle and it is parked. So there is at most one open row however many deliveries are in progress, and no delivery owns, starts or ends one.
It is not a pause, and the two are separate entities so that they cannot become one. A
ShiftPause says the driver stopped working and is subtracted from the shift's working duration;
this says they are working on foot and is subtracted from nothing. A single entity with a kind column
would be one if away from a pause subtracting a shopping trip from somebody's hours. No duration,
rate, period figure or exported total reads a suspension as time not worked.
What it does change is the route: capture is stopped for its whole length, so a walk is never
written into a coordinate history, and driving again mints a new capture session, so
RouteMileageCalculator refuses to measure across the stretch by the rule it already had.
beginRouteSuspension(at:) refuses an ended shift, a paused one and a second open row, and leaves
the context insert to the caller like beginPause(at:). endOpenRouteSuspension(at:) is allowed on
an ended or paused shift, unlike opening one, because both of those close an open row as part of
their own write. ShiftService.parkActiveShift(at:) and resumeDrivingOnActiveShift(at:) are the
only callers, and nothing anywhere ends a row because a speed changed or a delivery advanced.
RouteSample¶
| Field | Type | Notes |
|---|---|---|
timestamp |
Date |
When the platform fixed the position, not when the app received it |
latitude |
Double |
Degrees |
longitude |
Double |
Degrees |
horizontalAccuracy |
Double |
Radius of uncertainty in metres, as reported when the fix was taken |
captureSessionID |
UUID? |
The uninterrupted period of capture this sample belongs to. nil for samples written before v3 |
offer |
Offer? |
The accepted offer this delivery arrived in. Optional because SwiftData models a reference that way, and because a pre-v12 store had none until the migration gave each delivery its own. It groups and does not govern: no timestamp, figure, fetch or delete rule reads it |
shift |
Shift? |
The only place the relationship is declared; Shift holds no matching collection. Optional only because SwiftData models a reference that way. The initializer requires a shift. Carries no delete rule, so ShiftService.deleteCompletedShift(_:) removes a shift's positions explicitly |
Nothing else is stored. Core Location also reports speed, course, altitude and their accuracies; none are kept, because nothing implemented reads them.
Delivery¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
acceptedAt |
Date |
Acceptance is the delivery's creation, not an optional event |
arrivedAtPickupAt |
Date? |
nil until the driver records reaching the pickup |
pickedUpAt |
Date? |
nil until the driver records collecting the order |
deliveredAt |
Date? |
Terminal. Cleared by exactly two corrections: reopening a delivery on a running shift, and correcting a historical completion to a cancellation |
cancelledAt |
Date? |
Terminal. Set without erasing the events that preceded it. A historical correction sets it to the delivery's own former deliveredAt rather than to a new instant |
offer |
Offer? |
The accepted offer this delivery arrived in. Optional because SwiftData models a reference that way, and because a pre-v12 store had none until the migration gave each delivery its own. It groups and does not govern: no timestamp, figure, fetch or delete rule reads it |
shift |
Shift? |
The only place the relationship is declared; Shift holds no matching collection. Optional only because SwiftData models a reference that way. The initializer requires a shift. Carries no delete rule, so ShiftService.deleteCompletedShift(_:) removes a shift's positions explicitly |
pickupPlace |
PickupPlace? |
Optional and often absent. A reference, so two deliveries from one place share a row. Nullify on delete |
grossEarningsAmount |
Decimal? |
Private. What this one delivery paid, as the driver typed it. nil means no amount recorded, which is not zero. Unrelated to Shift.grossEarningsAmount |
expectedEarningsAmount |
Decimal? |
Private. What the driver expects this delivery to pay, entered while it was active. Not earnings: nothing counts it, and it never becomes the column above. nil means none recorded, which is not zero |
additionalTips |
[DeliveryTip] |
Tips received outside grossEarningsAmount. Cascades on delete, so deleting a shift reaches its deliveries and on to their tips. Empty is the ordinary case |
Derived, never stored:
| Member | Meaning |
|---|---|
state |
A DeliveryState, read from which timestamps exist. There is no stored state column |
isActive |
Neither delivered nor cancelled |
lastEventAt |
The most recent recorded event, which the next one may not precede |
pickupWait |
pickedUpAt - arrivedAtPickupAt, or nil if either end is missing or the pickup precedes the arrival |
completedDuration |
deliveredAt - acceptedAt, or nil unless the delivery was delivered |
grossEarnings |
The stored decimal as a Money, or nil |
expectedEarnings |
The stored expected decimal as a Money, or nil. No rate is derived from it, here or anywhere |
hasUnconfirmedExpectedEarnings |
An expectation is recorded and no gross amount is. The state the completion confirmation and the history screen offer to resolve |
additionalTipsInOrder |
The tips oldest first, with identity breaking a tie, so the list's numbering is repeatable |
effectiveEarnings |
An EffectiveDeliveryEarnings: the platform amount, the tips, and what the two come to. The total is nil whenever the platform amount is, tips or no tips |
hasRecordedMoney |
A platform amount or a tip. Asked by the corrections that promise not to touch what the driver recorded |
effectiveEarningsPerDeliveryHour |
A DeliveryEarningsRate: the effective earnings over this delivery's own completedDuration, or the reason there is none |
acceptedBefore(_:_:) |
The total, repeatable order over deliveries: acceptance ascending, identity breaking a tie |
makeHistoricalOffer() |
The v11 to v12 migration's one write: the one-delivery offer a delivery recorded before offers existed belongs in. nil, changing nothing, for a delivery that already holds one or belongs to no shift |
move(into:) |
The one place a delivery's grouping changes. Returns the offer it left, so the caller can decide what happens to an offer left holding nothing. Refuses another shift's offer, the offer it is already in, and an offer accepted after this delivery was |
markArrivedAtPickup(at:), markPickedUp(at:) and markDelivered(at:) refuse a skipped step, a
repeated event, a transition after a terminal state, and a timestamp earlier than the last recorded
event. cancel(at:) is allowed from every active state. setGrossEarnings(_:) rejects a negative
amount and an amount on a delivery that is still in progress; a cancelled delivery may carry one, and
is never forced to zero. clearGrossEarnings() removes the amount, which is a distinct operation
from recording zero. setExpectedEarnings(_:) is the mirror of it and rejects a negative amount and
an amount on a delivery that has finished; clearExpectedEarnings() is unconditional, because
removing a figure claims nothing. setPickupPlace(_:) is deliberately
unconditional: a pickup place is not an event, so correcting one changes no interval and is allowed
on a finished delivery. Nothing identifying a customer or an address is stored.
The amount is a second independent fact, not a share of anything. It is what the driver typed
against this one delivery; it is never derived from Shift.grossEarningsAmount, never checked
against it, and no total is ever divided among a shift's deliveries. See
Earnings and metrics.
The expected amount is a third independent fact, and two columns rather than one flagged column
is the substantive decision. A flag would have left every existing reader of grossEarningsAmount
free to report an expectation as earnings, and each reader that was missed would have done so
silently. A separate column fails the other way: a reader that has not been taught about
expectations cannot see them, which is exactly what every aggregate in the app wants. Finishing a
delivery never moves a value from one column to the other.
DeliveryTip¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
amountValue |
Decimal |
Private. Always more than zero: a tip of nothing is refused on the way in, so there is no missing-versus-zero question to ask about it |
methodRawValue |
String |
Private. DeliveryTipMethod's raw value. Read back through stored(_:), which returns no method for a word this build cannot name, because both cases are substantive claims and neither may stand in for an unknown one |
recordedAt |
Date |
When the driver wrote the tip down. Never edited, and not when the money changed hands |
delivery |
Delivery? |
The delivery this tip was received for. Optional only because SwiftData models the inverse of a to-many that way; the initializer requires one |
Delivery.recordAdditionalTip(_:method:at:) is the only thing that creates one, so the two rules
it keeps cannot be bypassed: the delivery has to be finished, and the amount has to be more than
nothing. update(amount:method:) replaces both values together and moves no timestamp. There is no
"when it arrived" field and no picker for one: correcting a historical timestamp is its own decision
with its own rules, and a field that looked like the moment money changed hands while holding the
moment it was typed would be the worst of both.
A tip is a separate recorded fact, never a rewrite of Delivery.grossEarningsAmount. That column
stays the platform-recorded pay, including whatever the platform already folded into it. What the
delivery actually paid is the two added on demand by EffectiveDeliveryEarnings, and no total is
stored anywhere. See Earnings and metrics.
It is not cash-on-delivery accounting: no order total, no cash collected, no platform deduction, no reimbursement and no customer balance is stored anywhere in DashPilot.
Offer¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute. Local to the device, and not an identifier any delivery platform would recognise |
acceptedAt |
Date |
Acceptance is the offer's creation, not an optional event |
shift |
Shift? |
Optional only because SwiftData models the inverse of a to-many that way. The initializer requires a shift |
deliveries |
[Delivery] |
Cascade delete, inverse of Delivery.offer |
Derived, never stored:
| Member | Meaning |
|---|---|
deliveriesInOrder |
The offer's deliveries in the order the shift numbers them |
deliveryCount |
How many deliveries the driver said this offer contained |
isGrouped |
More than one delivery, which is the only case the interface shows a grouping for |
couldHaveContained(_:) |
Whether a delivery accepted at that instant could have arrived in this offer, which is the one ordering rule correction keeps |
earliestDeliveryAcceptance |
The earliest acceptance among this offer's deliveries, or nil for one holding none |
activeDeliveries |
The offer's deliveries that are neither delivered nor cancelled |
state |
An OfferState, read from the states of the deliveries it holds |
isTerminal |
Every delivery has finished. An offer holding none is deliberately not terminal |
deliverySummary |
A DeliverySummary over the offer's own deliveries |
acceptedBefore(_:_:) |
The total, repeatable order over offers, the same rule deliveries use |
An offer is one acceptance event; a delivery is one dropoff with its own lifecycle. One offer
may contain several deliveries, and an offer accepted later is a different offer even if its
deliveries overlap in time with an earlier one's. Nothing merges two offers on its own: that happens
only where the driver says the grouping they recorded was wrong, through OfferCorrectionService,
which moves membership and never a lifecycle fact, an amount or an acceptance timestamp. An offer
left holding no deliveries by a correction is removed in the same write, so OfferState.empty
describes a store the app cannot produce rather than an outcome of ordinary use.
It holds no money, no duration and no distance. Expected pay and recorded gross earnings stay on
the delivery, and no total, rate or coverage figure anywhere is derived from an offer. Cancelling is
per delivery: there is no control that cancels an offer, and an offer whose deliveries all ended
cancelled is cancelled while one with a mix is partiallyCompleted, which is neither.
Delivery.shift is kept beside Delivery.offer rather than replaced by it. A delivery's shift could
be read through its offer, but that column is what every existing fetch, aggregate, export figure and
delete rule is built on, and moving the membership would rewrite a stored foreign key across a
driver's whole history to express something the store already holds. Since beginOffer is the only
thing that creates either, the two cannot be recorded disagreeing.
PickupPlace¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
displayName |
String |
The driver's own spelling. The first accepted one wins against matching, and only rename(to:) rewrites it |
normalizedName |
String |
The comparison key from PickupPlaceName. Never shown, spoken or logged |
createdAt |
Date |
When the place was first named on this device. Used for ordering, not analysis |
deliveries |
[Delivery] |
Nullify delete, inverse of Delivery.pickupPlace |
Derived, never stored:
| Member | Meaning |
|---|---|
lastUsedAt |
The latest acceptedAt among the deliveries naming this place, or nil if none do |
pickupWaitSamples |
Each referencing delivery's recorded wait, oldest first. Deliveries missing either end are skipped |
pickupWaitMetrics(using:) |
A PickupWaitMetrics: sample count, median, shortest, longest and most recent. See Pickup wait |
namedBefore(_:_:) |
The total, repeatable order over places: creation ascending, identity breaking a tie |
displayedBefore(_:_:) |
Alphabetical presentation order: localizedStandardCompare on the display name, namedBefore breaking a tie. Used for merge destinations |
Mutating:
| Member | Meaning |
|---|---|
rename(to:) |
Writes displayName and normalizedName together from one PickupPlaceName. Leaves id, createdAt and deliveries alone. Collision detection is the service's, not the model's |
No aggregate is stored. There is no medianWait, averageWait or pickupCount column, and adding
one is what a test in PickupWaitMetricsTests exists to fail on.
normalizedName is deliberately not a .unique attribute: a unique constraint in SwiftData
resolves a collision by upserting, which would overwrite the row the reuse rule exists to preserve.
Uniqueness is enforced in PickupPlaceService instead. No counter, visit total, last-used date,
median wait or score is stored on a place — every such figure is derived from its deliveries when
asked, and a stored copy could drift away from them. There is no address, coordinate, phone number, store
number or platform identifier, and nothing here came from anywhere but the driver's keyboard. See
Pickup identity.
Renaming a place and merging one place into another are relationship and attribute mutations
only — no version of the store records an alias, a merge history, a redirect identifier or a
tombstone, and neither operation changed the schema. A merge reassigns Delivery.pickupPlace for
every delivery on the source and then deletes the source, in one commit.
A delivery is independent of every other delivery: it derives its state from its own timestamps alone, so several can be active at once with overlapping lifecycles, and nothing here records a relationship between them. See Delivery lifecycle.
Expense¶
| Field | Type | Notes |
|---|---|---|
id |
UUID |
Unique attribute |
occurredAt |
Date |
When the cost was incurred, as the driver recorded it, never when the row was typed. This is what period membership is decided by |
amountValue |
Decimal |
Private. Required: an expense with no amount is not a record of anything. Never negative; a recorded 0.00 is a recorded amount |
categoryRawValue |
String |
Private. ExpenseCategory's raw value. A stored word this build cannot name reads as other |
note |
String? |
The driver's own short reminder, trimmed, up to 120 characters. nil when they wrote none |
Derived, never stored:
| Member | Meaning |
|---|---|
amount |
The stored decimal as a Money |
category |
The stored word as an ExpenseCategory, through ExpenseCategory.stored(_:) |
expenseRecord |
An ExpenseRecord for aggregation: date, amount and category, and deliberately not the note |
recordedBefore(_:_:) |
The total, repeatable order over expenses: most recent first, identity breaking a tie |
init(...) and update(...) reject a negative amount and a note over the length limit, and
update(...) validates every value before writing any of them, so a refused edit leaves the record
exactly as it was.
There is no relationship to Shift and none to Delivery, and that is the substantive decision
in this entity. An expense carries the moment it happened; a period contains it if that moment
falls inside the period, by the same rule that puts a shift in a period. Attaching a cost to
whichever shift happened to be running when it was typed would record an attribution the driver
never made. Deleting a shift therefore removes no expense, and no cost is ever divided across
shifts, deliveries, days or miles. See Recorded expenses.
VehicleProfile¶
One vehicle the driver works in, kept so its fuel economy is typed once rather than on every shift.
| Field | Type | Meaning |
|---|---|---|
id |
UUID |
Unique. What DriverSettings.selectedVehicleID points at |
name |
String |
What the driver calls this vehicle. Trimmed, non-empty and length-limited |
milesPerGallonValue |
Decimal |
The vehicle's fuel economy. Always greater than zero |
createdAt |
Date |
When the profile was created, which is the order the list is drawn in. A stable order that does not move under a rename |
It is a preference, not history, and nothing joins it to a Shift. When a shift starts, the
selected profile's name and fuel economy are copied onto that shift, and every estimate and exported
value is derived from the copy. Editing this row afterwards changes no figure the driver has already
seen, and deleting it leaves every shift it ever started exactly as it was. There is deliberately no
relationship in either direction, so the delete cascades nowhere.
What it deliberately does not hold: a VIN, a plate, a make, a model, a trim, an odometer reading, a service schedule, an insurance record or a purchase price. See Settings and vehicles.
DriverSettings¶
The driver's current preferences. At most one row, by construction: the identifier is a constant and is unique, so two attempts to create it resolve to one row rather than to two sets of preferences.
| Field | Type | Meaning |
|---|---|---|
id |
UUID |
Unique, and always DriverSettings.singletonID |
gasPricePerGallonAmount |
Decimal? |
What the driver says a gallon currently costs. nil means none recorded; 0 means the fuel is recorded as costing nothing |
selectedVehicleID |
UUID? |
The VehicleProfile.id new shifts are recorded under, or nil when none is selected |
Nothing derived reads this row. It is read at exactly one moment, when a shift starts, and copied onto that shift. Changing a setting tomorrow changes nothing recorded today.
The selected vehicle is an identifier rather than a relationship, so a deleted profile leaves a selection that resolves to nothing, which reads as no vehicle selected. The service clears it in the same save as the delete, so the ordinary path never leaves one dangling.
The row is created the first time the driver opens Settings. A migration never creates one.
Schema versions¶
| Version | Change |
|---|---|
| 1.0.0 | Shift: id, start, optional end |
| 2.0.0 | Adds RouteSample and Shift.routeSamples, removed again in 10.0.0 |
| 3.0.0 | Adds RouteSample.captureSessionID |
| 4.0.0 | Adds Shift.grossEarningsAmount |
| 5.0.0 | Adds Delivery and Shift.deliveries |
| 6.0.0 | Adds PickupPlace and Delivery.pickupPlace |
| 7.0.0 | Adds Delivery.grossEarningsAmount |
| 8.0.0 | Adds Expense. No existing entity changes, and no relationship is added |
| 9.0.0 | Adds ShiftPause and Shift.pauses |
| 10.0.0 | Removes Shift.routeSamples. RouteSample.shift is unchanged, and no stored value moves |
| 11.0.0 | Adds Delivery.expectedEarningsAmount. No existing attribute moves, and no delivery gains one |
| 12.0.0 | Adds Offer, Delivery.offer and Shift.offers. The first custom stage: every existing delivery is given a one-delivery offer of its own, and no two are grouped together |
| 13.0.0 | Adds DeliveryTip and Delivery.additionalTips. Lightweight, and nothing is backfilled: a delivery holding no tip is the ordinary shape in this build too, so every figure a migrated store derives is the figure it already was |
| 14.0.0 | Adds Shift.fuelMilesPerGallonValue and Shift.fuelGasPricePerGallonAmount. Lightweight, and nothing is backfilled: a shift recording no assumptions reports which half is missing rather than an estimate of $0.00 |
| 15.0.0 | Adds VehicleProfile, DriverSettings and Shift.fuelVehicleName. Lightweight, and nothing is backfilled: a v14 store holds no evidence of which vehicle any shift was worked in, so no profile is invented, no settings row is created and no shift is given a name |
| 16.0.0 | Adds RouteSuspension and a cascading Shift.routeSuspensions. Lightweight, and nothing is backfilled: a gap in a v15 route is left by a pause, a lost permission or a terminated process just as readily as by a driver walking into a shop, and the route holds no evidence of which |
Every step but 12.0.0 is a lightweight stage, and none but that one writes a value. See Migrations.
Two capabilities needed no version of their own. Supporting several concurrent deliveries changed no
persisted shape: Shift.deliveries was already a to-many relationship, so the store could always
describe more than one unfinished delivery for a shift, and "at most one active delivery" was an
application invariant rather than a constraint the database imposed. Delivery active time changed
none either — it is unioned from timestamps already stored, every time it is shown.
Domain value types¶
| Type | Purpose |
|---|---|
Money |
Decimal-backed monetary value. Unrounded in memory, rounded only for display |
DeliveryTipMethod |
cash or platform, and a closed set. A stored word this build cannot name reads as no method, because there is no neutral third case for one to fall back on |
EffectiveDeliveryEarnings |
A delivery's platform amount plus its recorded tips. The total is absent whenever the platform amount is |
MoneyInput |
Locale-aware parsing of what a decimal pad produces, with typed rejections |
RoutePoint, LocationSample |
Framework-free position values used by the filter and calculator |
RouteSampleFilter |
The capture acceptance policy and its rejection reasons |
RouteCaptureState |
Active, stopped because the driver paused the shift, paused because a session could not start off screen, permission required, unavailable |
RouteDistance |
Metres, segments, gaps, usable positions, inferred continuity, isMeasured, isPartial |
RouteMileageCalculator |
Splits a route into continuous segments and sums within them |
RouteQuality |
The tested vocabulary describing a measured route |
GeographicDistance |
One haversine implementation, shared by capture and measurement |
ShiftLifecycleState |
Running, paused or ended, derived from a shift's own rows |
ShiftVehicleContext |
The vehicle name and fuel economy a shift recorded, and the words the running shift's panel says them in. Reads the shift's snapshot and never a preference |
ShiftPauseInterval |
One recorded pause as a value: its bounds, its clipping and its malformed case |
ShiftPausedTime, ShiftPausedTimeCalculator |
The union of a shift's pauses, with the counts behind it |
DateRangeUnion |
The one sweep that merges overlapping stretches, shared by paused time and delivery active time |
ShiftMetrics, ShiftMetricsCalculator |
The three derived rates, working duration, and their precision rules |
ShiftRate, ShiftRateUnavailability |
An available shift rate, or the reason there is none |
DeliveryEarningsRate, DeliveryRateUnavailability |
What one delivery earned per recorded delivery hour, or the reason there is none |
LocationAuthorization and its enums |
Permission facts, condition precedence and recovery |
ShiftLifecycleError |
Refused start, pause, resume, end and delete transitions |
DeliveryState, DeliveryAction |
The five lifecycle states, the one action each offers next, and the wording |
DeliverySummary |
How many deliveries a shift recorded, how they ended, and how many are in progress |
NumberedDelivery |
A delivery with the local number the interface labels it with. Presentation only, never persisted |
NumberedOffer |
An offer with the local Offer 1 number, its deliveries under their shift-wide numbers, and the grouping wording |
DeliveryGroup |
The deliveries on one screen arranged by the offer they arrived in. Presentation only |
OfferState, OfferError |
Where an offer has reached, read from its deliveries, and the refusals when recording one |
DeliveryError, DeliveryLifecycleError |
Refused delivery transitions, and why |
PickupPlaceName |
The normalisation policy: a display spelling and the key identity is decided by |
PickupPlaceNameError, PickupPlaceError |
Refused pickup names, rename collisions, refused merges and failed pickup writes, and why |
PickupPlaceIdentity |
A place's id and spelling as a Sendable value, so a rename collision can name what it collided with without a thrown error holding a model |
ExpenseCategory |
The five conservative categories, their wording, and how a stored word is read back |
ExpenseNote |
The optional note's rule: trimmed, absent when empty, bounded in length |
ExpenseRecord |
One recorded cost reduced to date, amount and category, for aggregation without a store |
ExpenseTotalsCalculator |
The one definition of a recorded-expense total, its categories, and the net after it |
PeriodExpenseTotals, ExpenseCategoryTotal |
A period's recorded costs, the count behind them, and their split by category |
PeriodNetAfterExpenses |
Recorded gross earnings less recorded expenses, with the counts behind both halves, and the rules for when there is no figure |
ExpenseError, ExpenseNoteError, ExpenseRecordingError |
Refused expense values and failed expense writes, and why |
What is not in the store¶
Durations, distances, rates, route quality wording and capture state are all computed when they are
needed. A shift's lifecycle state is derived from its end timestamp and its open pause rather than
stored as a word, and its working duration is derived by subtracting the union of its pauses from its
elapsed time, for the same reason: a stored answer can disagree with the rows beside it after a
crash, a failed save or a migration, and a derived one cannot. A delivery's state is derived the same way, and so is the Delivery 1 / Delivery 2
numbering the interface shows for concurrent deliveries — it is counted from the acceptance
timestamps rather than stored beside them. A pickup place's recency is derived from the deliveries
that reference it, for the same reason. What a delivery earned per recorded delivery hour is derived
from its own effective earnings and its own two timestamps, every time it is shown, and is never
added to another delivery's. A period's recorded expense total, its split by category and the net
after it are derived from the expense rows the same way, and no cost per hour, per mile or per
delivery exists at all. The store holds timestamps, positions, the optional amounts a driver typed
against a shift and against individual deliveries, the costs they entered, and the names and notes
they typed, and nothing that could disagree with them.