Skip to content

Navigator v3.0.0 - #115

Draft
roncodes wants to merge 108 commits into
mainfrom
release/v3.0.0
Draft

roncodes wants to merge 108 commits into
mainfrom
release/v3.0.0

Conversation

@roncodes

Copy link
Copy Markdown
Member

Release branch for Navigator v3. All development now targets release/v3.0.0: feature branches merge into feature/v3-redesign, and feature/v3-redesign merges here (#109 landed this way).

What's in it

  • Waypoint redesign built in a parallel src/v3/ tree behind NAVIGATOR_V3=true, chosen at App.tsx. The v2 tree still ships unchanged when the flag is off.
  • Foundations: Waypoint theme (4 schemes + white-label), 45-status registry, component library, 5-tab shell, durable MMKV mutation queue with idempotency keys, NavigatorAdapter, normalised order store, i18n layer (Spanish, pseudo-locales, RTL edges).
  • Screens: Today, Orders list and detail with a config-driven stepper, proof capture, payload item edit, timeline, destination edit, fuel log, issues, Inbox, Account, org switcher, profile, vehicle, sign in, navigation hand-off, permissions primer, self-hosted connection, sync queue, ad-hoc offers, settings, help.
  • Route tab on the FleetOps v0.6.61 manifest endpoints; DVIR / inspections flow on a persisted offline draft; trailers (view); gated earnings.
  • Realtime (v3: realtime order changes, reassignment, order chat and bookings #114): v3's socket, order-ops and chat events, live order changes, push routing, order threads.

Still to land before release

Release operations

  • Merging this into main runs the shared Fleetbase release-tag workflow, which tags v3.0.0 and publishes the GitHub release from RELEASE.md.
  • Play Store and App Store publishing are still manual.

Both `legacy/` (a complete standalone RN 0.6x app copy, including its own
android/ and ios/ projects) and `src/legacy/` were provably unreachable: a
repo-wide import grep found exactly one reference, and it was a comment.

Also removes modules that were orphaned, broken, or both:

  - navigation/stacks/OrderStack.tsx — unimported, and imported a
    screens/OrderHistoryScreen that does not exist
  - components/TabBar.tsx — 0 bytes
  - hooks/use-chat.ts — stub returning a constant; real chat state is in
    ChatContext
  - hooks/use-fleetbase-data.ts — references an undefined `storefront` in a
    dependency array and conditionally calls useStorage vs useState, violating
    hook ordering; would crash if used
  - hooks/use-locale.ts — imports a `setLanguage` that localize.js does not
    export
  - hooks/use-cart.ts, utils/auth.js — Storefront leftovers
  - screens/TestScreen.tsx — reachable only via CoreStack's unused default
    export; CoreStack updated to drop the route

Verified with a module-resolution pass over the whole tree: no dangling
relative imports were introduced.

~2.5 MB and 249 files.
Three pieces of tooling were broken before this change:

  - `yarn lint` extended `@react-native-community`, which is not installed.
    The project depends on @react-native/eslint-config, its renamed successor,
    so eslint failed before linting anything.

  - `yarn test` had no jest config at all. @react-native/jest-preset was
    already a devDependency but was never wired up, so the project's single
    test failed on the RN/Tamagui transform. Adds the preset,
    transformIgnorePatterns for the RN ecosystem, and mocks for the native
    modules a unit test cannot construct (MMKV, config, device-info, gesture
    handler, reanimated, bootsplash).

    @gorhom/bottom-sheet is stubbed at the library boundary rather than
    mocking reanimated 4's worklets surface, which turned into whack-a-mole;
    sheet contents still render and can be asserted.

    __tests__/App-test.js is excluded with its rationale in the config: it
    smoke-renders the v2 provider pyramid, has never passed, and making it
    pass means mocking the whole native stack for a tree that is deleted at
    cutover. src/v3/__tests__/app.test.tsx is its replacement and does pass.

  - tsconfig.json extends nothing and there is no typecheck script, so
    TypeScript has only ever been stripped by Babel, never checked. Adds
    tsconfig.v3.json (strict, scoped to src/v3 so the new tree can be strict
    without first fixing every pre-existing v2 error) and a `typecheck`
    script. It also points at @fleetbase/sdk's shipped declarations, which
    its package.json fails to reference.

Lint and typecheck each immediately found real defects in the new code.

src/utils/index.js carried TypeScript annotations inside a .js file — valid
only because Babel strips them. Converted to JSDoc so tsc can parse the module.
The design specifies Archivo for UI and JetBrains Mono for identifiers. The
project had no font configuration at all — tamagui.config.ts spreads
@tamagui/config/v3 wholesale, so the app ran on stock Inter.

Ships static TTFs (5 Archivo weights, 3 JetBrains Mono) linked into
UIAppFonts and android assets.

Sourced from the Omnibus-Type and JetBrains upstream repos rather than
google-webfonts-helper: the gwfh Archivo statics are mislabeled — every weight
reports family "Archivo SemiBold" with PostScript names like
ArchivoSemiBold-Medium, which makes weight selection impossible on iOS.
Verified by parsing each file's name table.

Licences live in assets/font-licenses/, deliberately outside the linked
directory: react-native.config.js links everything in assets/fonts into the
bundle, and shipping licence text inside the IPA/APK is waste. Attribution
belongs on the in-app legal screen.
A parallel presentation tree so main stays shippable during the rebuild.
src/v3 imports nothing from the v2 tree.

Theme — four schemes (dark default, light, sunlight, night) at the design's
exact hex, with the brand primary as an independent build-time override.
Two v2 defects fixed and pinned by tests:

  - CUSTOM_COLORS / _LIGHT / _DARK were spread *before* the semantic
    definitions in tamagui.config.ts and the Tailwind ramp landed last, so no
    env override could ever reach a semantic token. White-labelling had never
    worked. Overrides now apply last.
  - Every status and feedback family ships a resolved Fill/Border/Text triple
    per scheme, which is what removes the ~30 components that hand-branched on
    isDarkMode because the token layer carried too little information.

Status registry — one map covering 45 real statuses. v2 mapped 38 strings onto
6 Tailwind families with a `yellow` catch-all, so every fuel-report status
rendered as an identical yellow badge. A test parses Enums.ts and the legacy
switch and fails if either grows a value with no tone.

Components — Button, StatusPill, Surface, StopRow, OrderCard, OfferCard,
VehicleCard, ListRow, Identifier, Field, RouteProgress, HosGauge, StepBar,
ScannerOverlay and the empty/error/offline set, all rendering in four schemes
from one tree with no scheme branch. Ports fix three latent v2 bugs:
BottomSheetSelect called isObject() without importing it (a guaranteed
ReferenceError for object options), had a TDZ self-reference that broke
search, and a stale-closure dep array; OdometerNumber clamped every non-zero
value to 0.

Shell and navigation — Today/Route/Orders/Inbox/Account with a custom tab bar,
so badge counts subscribe to context inside a component rather than from
`options` callbacks the way v2 did. Duty control replaces the unlabeled online
switch and degrades honestly: break has no backing endpoint until Phase 4a, so
it renders disabled with the reason instead of faking it.

Data layer — durable mutation queue (strictly sequential with head-of-line
blocking, idempotency keys minted at enqueue and reused across retries, 4xx
parked rather than retried forever), a NavigatorAdapter that intercepts the
SDK's single request() funnel without forking it, one shared Fleetbase
instance whose adapter identity survives a token change, and a normalised
order store with identity-stable views. Replaces the v2 context that rebuilt
six SDK collections on any change and built MMKV keys with second precision.

Settings screen (R2 frame H1) with live theme switching.

Verified on an iPhone 17 Pro simulator in all four schemes; that run caught
three bugs the unit tests could not — semantic tokens.zIndex keys (Tamagui
validates natively only), a dead useTheme() in ChatProvider that still
required the provider, and the bootsplash never being hidden.

109 tests, typecheck and lint clean.
App.tsx becomes a root selector: NAVIGATOR_V3 chooses the v2 or v3
presentation tree. The two must never share a TamaguiProvider — v2 components
read tokens ($red-600, $borderColorWithShadow) the Waypoint config does not
define, and v3 components read tokens ($surfaceRaised, $onPrimary, the status
families) v2 does not; mixing them resolves to undefined rather than failing
loudly. The v2 tree moves to App.v2.tsx verbatim, deleted at cutover.

App.tsx is also the only place v3 touches v2: DriverBridge adapts the v2 auth
and chat contexts into the plain props V3App takes, so the bridge is visibly
temporary and goes away with the data layer.

OrderManagerProvider is deliberately absent from the v3 branch — it reads
theme['$red-600'] (OrderManagerContext.tsx:69) from the Tailwind ramp, which
the Waypoint config does not carry, so mounting it there throws.

ChatContext: removes a dead useTheme() call. The result was never used, but
the hook still required a TamaguiProvider ancestor, which broke the v3 root
where the data providers deliberately sit outside the theme provider.

docs/redesign/ carries the audit that motivated the plan, the round 1 design
brief, and the round 2 gap spec (45 screens plus six corrections to round 1).
Stragglers from the dead-code pass that were staged but not captured in the
first commit.
04-LOOP-PROMPT.md is fed to /loop: one finished vertical slice per iteration,
with i18n, real data wiring, every state, tests and device verification as
part of the definition of done rather than a follow-up.

05-IMPLEMENTATION-LEDGER.md is the working state the loop reads and updates.
Slices are tiered by whether their endpoints exist today, so the loop never
starts work that is blocked on backend it cannot do — each Tier 3 entry names
the endpoint it is waiting for.
First slice off the ledger. Two pieces.

**i18n** — v3 could not reuse src/utils/localize.js: it imports `../utils`,
which imports the v2 tamagui.config, so pulling it in would load a second
Tamagui config. Built directly on i18n-js instead, sharing the same
translations/en.json catalogue so v2 and v3 keys stay in one file.

Plurals and interpolation go through the library, never concatenation —
`itemCount` is a real plural rule with one/other, and progress strings use
`%{completed} of %{total}`. Concatenating either breaks any language whose
plural categories or word order differ from English. Missing keys warn in dev
and fall back silently in production.

**Orders (R1 s05/s18)** — segments, search across tracking number, customer and
address, and every state the frame implies: skeleton, per-segment empty copy,
no-search-results with a clear action, retryable error, and offline showing
cached rows with a notice rather than an error. Segmentation is by lifecycle
(started/dispatched/scheduled/terminal), not by matching status strings, so an
unfamiliar status still lands somewhere sensible.

Rows select from the normalised store, so the list's data array keeps its
identity across unrelated re-renders and the memoised cards actually skip.
Reads go through the adapter and are deliberately never queued — replaying a
stale GET on reconnect is wrong.

Two fixes fell out of building it:

  - The driver's unit preference never reached the cards. OrderCard and StopRow
    now take `units`, so an imperial driver sees "11.3 mi" rather than always
    metric. Covered by a test.
  - ErrorState hardcoded "Try again". The component library must carry no copy
    of its own, so it now takes `retryLabel` from the caller. The remaining
    literals in OfflineBanner/SyncedBanner are recorded in the ledger.

Verified on an iPhone 17 Pro: the shell, all five tabs with the design's stroke
icons, and Orders showing its real empty state — no fabricated data.

120 tests, typecheck and lint clean.
Second slice. This is the correction-1 deviation from the R2 review — the one
flagged as most likely to cause rework — so it is built as a dynamic renderer
rather than to the mockup.

The R1 frame draws a fixed CREATED → EN ROUTE → ARRIVED → COMPLETED bar. Real
flows come from `order-configs`, whose public projection returns an ordered
`flow` of `{code, status, details, complete, require_pod, pod_method}` and
varies per organisation. ActivityStepper renders whatever length it is given;
tests drive it with 2, 5 and 7 steps. Past four it scrolls rather than
squeezing steps below a legible size, and labels wrap instead of clipping
because other languages run longer.

The next action is derived from the flow, not hardcoded: `nextActivity()`
returns the following step, or nothing at a terminal one, so the button says
"Mark Arrived" for this config and something else for another. An unrecognised
status leaves every step upcoming rather than falsely showing progress.

Advancing is a mutation, so it goes through the adapter: offline it queues, the
store is advanced optimistically, and the screen says the work is saved rather
than failing. Config fetch failure degrades honestly — the stepper is hidden
and a notice shown, with no invented flow and no action to advance into.

One conflict surfaced by a test: `orderStatuses.driver_enroute` in
translations/en.json said "Driver en-route", which silently beat the registry's
"En route". The catalogue's status labels are now generated from palette.ts, so
the design's wording is the single source and the two cannot drift.

Order cards now open the detail screen.

134 tests, typecheck and lint clean.
Third slice. `GET orders/{id}/editable-entity-fields` has existed since before
the redesign and the v2 app never called it, so payload entities were read-only
regardless of how an organisation had configured them.

The screen is a field renderer over that allowlist rather than a fixed form: a
field is editable, or it is shown locked with the reason and the config that
locked it. Locked fields still display their value — a driver needs to read a
serial number they cannot change.

Two deliberate choices, both tested:

  - **A failed allowlist locks everything.** Defaulting to permissive would
    write fields the organisation protects, so an unreachable endpoint means
    read-only plus a notice, never an open form.
  - **Only allowed fields are sent.** The PUT body is built from the allowlist,
    not from the entity, so a stale field on screen cannot leak into the write.

Saving goes through the adapter, so an edit made offline queues with its
idempotency key instead of being lost.

Entity rows on the order detail screen open the editor as a modal.

Also raises the jest timeout to 30s. Tamagui render tests mount a full themed
tree four times per suite; under parallel workers on a loaded machine two
suites took 63s and 77s and tripped the 5s default, while passing in ~2s when
run alone. A green suite should mean correct, not lucky.

145 tests, typecheck and lint clean.
Ran against the local instance in .env.dev (http://localhost:8000) instead of
invented fixtures, and three of my assumptions were wrong:

  - `order_config` comes back as a bare public id string
    ("order_config_rvle6d5CAn"), not `{ id }`. OrderDetailScreen read `.id` off
    it, got undefined, and the activity stepper would never have loaded against
    real data — the config-driven stepper built in the previous commit was
    silently dead.
  - `tracking_number` is an **object** — `{ id, tracking_number, status_code,
    qr_code, … }` — not a string. `String(order.tracking_number)` renders
    "[object Object]", in place of the identifier the entire design is built
    around and which a driver reads off the screen to match a parcel.
  - `OrderRecord` *typed* tracking_number as `string`, so the wrong shape was
    baked into the model as well as the reads. Now `string | TrackingNumberRef`,
    with `order_config` and `pod_required` corrected alongside it.

All three came from guessing at shapes rather than checking. accessors.ts now
owns every field read with more than one possible form — trackingNumberOf,
displayIdOf, orderConfigIdOf, trackingStatusCodeOf, podRequiredOf,
customerNameOf, payloadOf, entityTrackingNumberOf — so a surprise is fixed once
rather than per screen. Orders, order detail and the item editor use them.

The accessor tests use shapes copied from the live response, including the
fallback chain that must never produce "[object Object]".

The app builds and runs clean against the live API; it stops at the auth gate
because a company API key is not a driver session. The ledger records how to
point a worktree at the instance and what is still needed to get past the gate.

154 tests, typecheck and lint clean.
Fourth slice, built so the app could be driven against the real API rather than
fixtures.

Sign in (R2 A1): email/password with the alternates below the fold rendered
from per-organisation config — the frame is annotated "renders 1, 2 or 4 of
them", so passing none hides the section rather than leaving a heading over
empty space. Tests cover 0, 1, 2 and 3.

Sign-in is the one flow that genuinely cannot be queued — there is no session
to act on behalf of — so offline blocks here, unlike every other mutation in
the app. Failures report inline without revealing which half was wrong.

Adds secure text entry to `Field` rather than making a separate password
component, so a password field keeps the same label / focus / error / accessory
treatment as everything else.

Verified end to end against http://localhost:8000: `drivers/login` returns a
session, and the shell then renders the real organisation ("Fleetbase Pte Ltd")
and driver ("Ron") in the header.

Also records a blocker in the ledger: **touch input does not reach v3
components on device**. Fields never take focus and tab taps stop switching
once past the auth gate, while every render test passes — so it is purely
event propagation. Tab taps worked at 3c6eeb1, so it is a regression and
bisectable. Every remaining slice is interactive, so this is fixed before any
more screens are built.

167 tests, typecheck and lint clean.
The previous commit recorded "touch input does not reach v3 components on
device" as a blocker. That was wrong, and the claim was made from a stale
screenshot rather than evidence.

v3 touch handling is fine. With nothing overlaying the screen, the duty pill
opens the duty sheet on the first tap — and the sheet renders exactly as
designed, with "On break" disabled and labelled "Not available for this
organisation yet".

Two environmental obstructions produced the false signal:

  - react-native-background-geolocation raises three native permission alerts
    in sequence after sign-in. Each is a modal that swallows every tap, and the
    later two appear on a delay — so a screenshot taken before them looks clear
    while the taps that follow are eaten.
  - The LogBox dev toast occupies the same strip as the tab bar, so a tap at
    the tab-bar coordinate hits the toast. Dev-only; LogBox is absent from a
    release build.

Neither is a code defect and neither needed a fix. The ledger now documents both,
and the loop prompt gains the rule that produced the mistake: screenshot
immediately before the tap and confirm nothing is overlaying the control, rather
than reasoning from an earlier frame.

Also confirms statically that all 63 t() keys referenced in src/v3 resolve
against translations/en.json — the two flagged as missing were text inside a
doc comment.
Item detail (R2 D4) renders the entity's photo, its full tracking number, the
scannable barcode and QR, its metadata and its scan history. Three things about
the live API shaped it:

- `barcode` and `qr_code` arrive as bare base64 PNGs, with no `data:` prefix.
  Wrapping them makes D4's "scannable barcode" real rather than decorative.
- Scan history is not an entity sub-resource; it hangs off the entity's tracking
  number, via `tracking-statuses?tracking_number={id}`. An entity without one has
  no history to show, which is a state, not an error.
- Most entity metadata is nullable and usually null. Rows with no value are
  omitted rather than printed as a column of em dashes.

Two live-only defects fixed along the way. Every entity a real instance returns
has `id: null` — identity is in `internal_id`. So `entities/${entity.id}` was a
PUT to `entities/null`, and gating the payload row's onPress on `e.id` meant the
edit screen could never be opened at all. Both now go through `entityIdOf`.
Neither could show up against fixtures, which have ids.

Money needed the same care: `price` is a *string* in *minor units* ("2850" USD is
$28.50), so `formatMoney` divides — except for the zero-decimal currencies, which
do not.

The larger fix is navigation. Screen components were being created during render
(`const Orders = () => <OrdersStack driverId={id} />`, `component={placeholder(
'Today', P3)}`), so every parent render produced a new component type and React
remounted the whole screen subtree. Nothing looked wrong in a screenshot, but
continuity was destroyed on each render: scroll position reset, inputs could not
hold focus, and touches that began before a remount never landed. The content
area appeared completely dead to touch while the header and tab bar — which are
not remounted — kept working. Every screen is now declared at module scope, with
screen-scoped values travelling by context, and a test guards the rule.

That also corrects the ledger. An earlier pass cleared the touch blocker on the
evidence that the duty pill opened the duty sheet — but the duty pill is in the
header, outside the navigator, so it proved nothing about the content that was
actually broken. The note now says so, and says what evidence would have settled
it.

Recorded as an open defect: text inputs still never take focus anywhere in v3.
It reproduces with `enableScreens(false)` and with a bare RN TextInput swapped
into `Field`, so it is neither react-native-screens nor Tamagui; the provider
stack is the remaining suspect. No form can be verified on device until it is
fixed.

Verified against the live instance at localhost:8000 as ron@fleetbase.io:
Orders -> order -> item detail -> edit, with the barcode, QR and a real scan
event rendering. 209 tests, typecheck and v3 lint clean.
Seven suspects excluded on device against the live instance, each noted with the
evidence that excluded it, plus the two experiments that would most cut down
what is left.

Also records that the simulator has a hardware keyboard attached, so no software
keyboard appears anywhere — iOS Spotlight included. Spotlight still shows a
caret and accepts injected text, so focus works at system level and the missing
keyboard is a red herring. The real signal is the absent caret and focus ring.
The activity log (R2 D5), reached from order detail. `tracking_statuses` is
already embedded on the order, so the screen paints from the store immediately
and the network call is only a refresh — a failed refresh keeps the cached
history on screen with a retry, rather than emptying the page.

Chronological, oldest first, so it reads as progression; the newest entry is
marked current from its position in the sorted list rather than by guessing from
the order's status.

Two places where the live data does not match the frame, both handled by showing
less rather than by inventing:

- **Location.** Every event carries `city`/`province`/`country` as null and a
  GeoJSON point at exactly [0, 0]. That is a placeholder, not somewhere the
  driver has been, so `isRealPoint` rejects it and the row omits the location.
  Rendering it would have put every event in the Gulf of Guinea, which is the
  kind of wrong that looks plausible.
- **Actor.** D5 asks who made each status change. A tracking status does not
  record one, so no actor is shown and the screen says why, instead of labelling
  everything "Driver" and being wrong whenever dispatch acted.

The refresh error is captured and surfaced in dev. The first device run showed a
failed refresh with the cached history rendering correctly beside it and nothing
saying what had failed; it proved transient, but a degraded state that cannot be
diagnosed is only half built.

Verified on device against the live instance: a four-event order renders in
order with correct timestamps, the current marker on the newest, no locations,
and the refresh succeeding. 230 tests, typecheck and v3 lint clean.
The earlier note said text inputs never take focus anywhere in v3. That was too
broad. A bare TextInput focuses on the same screen as the field — as a sibling
directly above it and directly below it, inside the navigator and outside it,
controlled and uncontrolled. Only the Field component fails.

It is also less severe than recorded: a bare input bound to the same query state
focuses, types, filters the list and renders the no-matches state, so Orders
search is functionally complete and the field renders its value correctly. The
failure is confined to tap-to-focus.

Twelve suspects are now excluded with the evidence for each, including the whole
provider stack and — this pass — every Tamagui element of the field itself. A
Field rebuilt entirely from react-native primitives still cannot be focused, so
that rewrite was reverted rather than kept as churn that fixed nothing.

The note now says what the next step actually is: hit-test the field's frame with
the inspector to find which view takes the touch. Substituting candidates has
been exhausted.
Every text field in v3 was unusable — tapping gave no caret, no keyboard and no
typed text, anywhere, sign-in included.

The focus ring was a `focused` variant on the element wrapping the input, driven
by the input's own onFocus. On iOS, changing the style of any ancestor of a
focused TextInput makes it resign first responder, so the field focused and was
blurred again inside the same tap. The ring destroyed the focus it existed to
show.

Field now separates the two: the frame containing the input is constant by
contract, and the border, colour and shadow that react to focus live on an
absolutely-positioned, always-mounted, pointerEvents="none" sibling. Changing a
sibling is safe; changing an ancestor is not. The design's focused and error
treatments are unchanged in all four schemes.

Worth recording how this was found, because I got it wrong twice first. Twelve
components were swapped out one at a time — react-native-screens, the gesture
root, the sheet provider, the toast overlay, Tamagui's Input, the styled wrapper,
the outer stack — and every one appeared to fail, because in each case the field
still focused and still blurred. Substitution cannot tell "never focused" from
"focused and blurred". Instrumenting the input settled it in three runs:
measureInWindow proved the frame was where it was drawn, the touch counters
proved the tap arrived, onFocus/onBlur read foc 1 blr 1, and a ref-callback
counter read mounts 1 — an explicit blur, not a remount. Holding the wrapper's
props constant kept focus.

The test asserts the invariant that matters: the frame's style is identical
before and after focus while the ring's is not. Reintroducing the bug fails six
of its nine tests.

Verified on device against the live instance: the search field takes focus,
shows the ring, accepts typing, and filters to the matching order. 239 tests,
typecheck and v3 lint clean.
Tier 1 #6. Reached from Account, which is still a placeholder — so placeholders
now carry links to the built routes beneath them, since the whole point of that
screen is that the shell stays navigable.

Economy is the part the design asks for that the API does not store, so it is
derived: distance since the previous fill on the *same vehicle*, as L/100km
beside litres or mpg beside gallons. The first fill on a vehicle legitimately has
none and says so rather than showing a zero, a non-advancing odometer is treated
as a correction rather than infinite economy, and an unknown volume unit yields
nothing rather than a guess. The create form previews it live, so a mistyped
odometer is visible at the pump rather than after saving.

Four things the frames ask for have no backing and are omitted rather than
collected and discarded: fuel type is accepted and silently dropped (a create
sending "diesel" returns type: null), there is no station column at all, the
resource carries no receipt association, and the fuel-card match fields are
internal-only — so F2's "matched to card" state and the reason behind a rejection
cannot be shown to a driver at all. The screens say so where it matters.

One scoping trap worth the test that guards it: fuel-reports filters on
`driver=<public id>`, while `driver_uuid` and `driver_assigned` are ignored and
return every driver's fuel spend in the company.

Two defects fixed along the way. A draft report rendered as "Created", because
describeStatus falls back to the *tone's* label when a status has no translation
of its own, and draft shares a tone with created — rejected read "Failed" and
approved read "Completed" for the same reason. All 41 unlabelled statuses now
have their own entry and a test asserts every registry status does. And the list
was a snapshot from whenever the tab was opened, so a fill logged through the app
did not appear on return; the navigator now counts arrivals and the list refetches
on focus.

Verified end to end on device against the live instance: logged a fill through
the app (odometer 31,980, 41.2 L), which persisted, appeared on return, derived
5.6 L/100km against the previous fill, and carried that economy into detail.
282 tests, typecheck and v3 lint clean.
Tier 1 #7. The taxonomy is the org's own: picking a type narrows the categories
from src/constants/IssueCategory rather than offering all forty at once, and the
labels are converted to the snake_case the API stores.

Creating an issue requires a location — CreateIssueRequest demands it, unlike
fuel reports — and v3 had no location source. v2 already runs background
geolocation for the whole app, so its last fix is bridged through App.tsx rather
than starting a second consumer of the same hardware. With no fix the screen says
so and disables filing *before* the driver writes anything, rather than letting
them describe a fault and then bounce off server validation. [0,0] is treated as
no fix, since that is the placeholder the API itself uses.

F3 asks for a status timeline. `issues/{id}/timeline` is registered only under
the console's int/v1 prefix, so a driver token cannot read it; detail shows the
filed and resolved timestamps and says the rest is unavailable.

Five defects found by running it:

- `open` and `resolved` were missing from the status registry, so a live issue
  fell to the fallback tone. Neither appears in src/constants/Enums.ts, which is
  what the coverage test reads — statuses seen on the wire count as much as
  declared ones.
- BottomSheetSelect rendered nine rows of "[object Object]" for an ordinary
  {label, value} list, because without an explicit optionLabel it fell back to
  String(item). It also showed the raw value after selection, so the trigger read
  "VEHICLE" once the driver had picked "Vehicle".
- Its trigger collapsed and clipped its own label: it is a Tamagui Button and the
  Waypoint config carries no `size` scale for it to read a default from.
- Banner's `meta` took its full intrinsic width, so a long one squeezed the
  message out of existence entirely.
- A stray console.log fired on every select render, left over from the v2 port.

The create screen is pushed rather than presented as a native modal: its selects
open a sheet through MainPortal, and a native modal sits above that host, so the
sheet rendered behind the form and never appeared.

Verified on device against the live instance: filed an issue through the app,
which persisted with the report, priority lowercased to "medium", and GeoJSON
longitude-first at [103.8198, 1.3521]; it appeared on return and its detail
rendered. 323 tests, typecheck and v3 lint clean.
Tier 1 #8. Chat lives in core-api rather than fleetops, under the same
fleetbase.api guard the driver's token already passes, so no backend work was
needed.

The server hands back one pre-merged `feed` of logs and messages, which is
exactly what G1's self / other / system bubbles want — the screen renders
`entry.type` plus "is this sender me" and does no merging of its own. Logs are
shown from `resolved_content`, since `content` still carries
"{subject.0.name}" placeholders.

Three identity traps, all found by running it against the instance:

- The SDK's `Resource` defines a getter for `id` and nothing else — every other
  attribute needs getAttribute(). `driver.user` was silently undefined, so the
  app believed the driver was in no conversation: titles kept their own name,
  every message rendered as someone else's, and sending was disabled.
- `available-participants` returns *User* records, where the user id is `id` and
  there is no `user` key, while a channel's participants are chat_participant_*
  records that do have one. Reading `.user` on the former listed the driver as
  someone to start a conversation with, and would have posted add-participant
  with no user at all.
- send-message takes the *chat participant* id as `sender`, not a user or driver
  id, and 422s otherwise; the driver's participant is resolved by matching user.

Also: the channel title is already computed per viewer and excludes the person
asking, so rebuilding it from participants was both unnecessary and how the
driver's own name leaked back into every row. And the quick replies were three
equal-width buttons, which truncated "Running 10 min late" to "Runnin…" — they
scroll now, so a longer translation cannot clip either.

Attachments are not built: the API takes a `files` array, so that needs the
upload half plus a picker, and a composer button that did nothing would be worse
than none. G1's order-context header has nothing to hang on — the public channel
resource carries no order reference.

Verified on device against the live instance: opened a real channel, saw system
logs and my own message on the right, sent "Held up at the loading dock…"
through the composer and confirmed it persisted, and checked the picker no
longer offers the driver themselves. 361 tests, typecheck and v3 lint clean.
Everything found while rebuilding, with severity, status and where it lives:
eleven open items needing a decision or backend work, twenty fixed in this
branch, and twelve pre-existing v2 defects recorded rather than repaired.

Most of these were invisible until the app ran against a live instance —
fixtures had ids where the API returns null — so the register also keeps the two
diagnoses that were nearly missed, and why.
Tier 1 #9. Replaces the placeholder that Fuel log and Issues were reached
through, so it is now the real hub for everything filed under Account:
identity, organisation, assigned vehicle, the links, and sign out.

Two rows from the prototype are deliberately absent. Earnings has no backing
anywhere in FleetOps — no earnings, payout or rate data exists for a driver — and
a zero would be a lie while a placeholder would imply it is coming. Licence
details render only when the fields are populated; they are null on the instance.

Separately, and more seriously: App.tsx was passing FLEETBASE_KEY — the
organisation's admin-scoped API key — into the adapter's platformToken slot.
That is precisely the credential the audit flagged as the security hole, handed
to the pre-auth code path. It now passes FLEETBASE_PLATFORM_TOKEN, which
ConfigContext resolves. Note that react-native-config bakes .env into the native
build rather than the JS bundle, so the token only reaches the app after a
rebuild; until then it is undefined, which is strictly safer than what it
replaced.

Endpoint note worth recording: GET /v1/organizations needs a platform token and
401s for a driver, but organizations/current and drivers/{id}/organizations both
work with the driver token — so the org switcher is not blocked after all.

Verified on device against the live instance: the screen renders Ron, driver id
FP138717, Fleetbase Pte Ltd, and vehicle EAS-01 "Nissan NV200 2025" from the
vehicle record; the licence card is absent as expected; and Issues opens through
the new hub. 380 tests, typecheck and v3 lint clean.
Reported by the user: the UI carried far heavier depth than the design draws.

`elevation` is an Android style prop, but Tamagui also treats it as a shorthand
and expands it into shadow props of its own. A token written
`shadowOpacity: 0.12, shadowRadius: 2` therefore resolved to `shadowOpacity: 1`,
radius 3 — a fully opaque near-black shadow under every card, row and sheet.

It survived several device passes because a wrong shadow looks deliberate:
nothing is misaligned, nothing errors, it just reads as a heavier design than
the one specified. Reading the resolved style back found it in one run.

The elevation tokens now carry shadow values only, and Button's elevated variant
likewise. `ui/__tests__/elevation.test.tsx` asserts the *resolved* opacity in all
four schemes, so a token can no longer say one thing while the app renders
another.

Two rules added to the loop prompt as a result. Match the design as drawn — the
frames are the specification, and depth is where drift hides. And: specified is
not resolved — when a visual property matters, render the component and read the
style back rather than trusting the source.

The loop prompt now also requires defects to be logged to the register in the
same commit that finds them, rather than batched at the end, with severity,
status and — where a diagnosis went wrong first — what the misleading evidence
was.

Android note recorded: shadowOpacity alone draws nothing there, so its elevation
needs a prop the style system does not rewrite.

389 tests, typecheck and v3 lint clean. Verified on device: cards read as flat
surfaces with a hairline border.
Tier 1 #10. Not blocked on the platform token after all: GET /v1/organizations
needs one and 401s for a driver, but drivers/{id}/organizations is driver-scoped
and works with the Sanctum token. A driver who belongs to one organisation is
told so plainly rather than shown a one-item list that looks like a choice.

Switching replaces the session — the API returns a new driver with a new token —
so the screen performs the request and hands the driver up to the host app,
which owns auth. Written in v3 rather than reusing v2's switchOrganization,
because that one swallows every failure into a console.warn, so a driver whose
switch failed would never learn.

The test written to prove failures are reported found something worse: the
failure was being *queued*. An organisation switch that fails on transport would
have been replayed on reconnect, moving the driver between organisations twenty
minutes later, unasked and possibly mid-job. Sign-in carried the same exposure —
a queued credential replayed after the fact.

The queue now refuses session-changing paths outright: switch-organization,
login, logout, verify-code, switch-vehicle. They fail loudly so the screen can
say so. Everything else queues as before; the distinction is whether replaying
later is still what the driver meant.

Recorded in the register as F-23 (S1), along with N-13: v2's switchOrganization
console.logs the new driver and its token, putting a credential in the device
log.

Verified on device: the single-organisation state renders with the current
organisation marked. 405 tests, typecheck and v3 lint clean.
Tier 1 #11. Name, email, phone, city and country, sending only the fields that
actually changed — nothing is required on an update, so an untouched form makes
no request at all. Client-side validation matches what the server enforces, so a
three-letter country code is caught here rather than coming back as a 422.

Status, assigned vehicle, vendor and current job are writable on the same
endpoint and every one of them belongs to dispatch — a driver setting their own
status from the phone would be editing the dispatcher's view of the fleet. They
are not offered, and the screen says so rather than staying silent about it.

No password change either, and this one is worth reading twice: PUT
/v1/drivers/{id} accepts `password` and sets it **without asking for the current
one**. Putting that in the app would let anyone holding an unlocked handset take
the account. Logged as O-13 (S1) — it needs a server-side current-password check
before any client offers it. O-14 alongside it: email uniqueness is enforced only
on create, so an update can take an address that already belongs to someone else.

Edits queue offline, unlike the session-changing calls the adapter now refuses —
a name corrected in a tunnel and replayed on reconnect is still what the driver
meant.

Verified on device against the live instance: changed the city through the app,
confirmed it persisted, and reverted it. 418 tests, typecheck and v3 lint clean.
Tier 1 #13. Apple, Google, Waze and Uber, a remembered default that writes to
the same setting the Settings screen exposes, a browser fallback for anything
not installed, and the none-installed state — which on Android with none of the
four present is entirely real.

URL building is kept apart from opening because that is where the mistakes are,
and they are silent ones: a wrong parameter does not throw, it opens the map at
the wrong place and reads as "the app didn't work". Two orderings matter and
disagree with each other — GeoJSON stores [longitude, latitude] while every one
of these URLs wants latitude first, so `fromGeoPoint` is the single place that
flip happens, and a test asserts the transposed pair is never emitted. For
Singapore the transposition lands in the Indian Ocean; for London, Antarctica.

The details that decide whether it navigates or merely drops a pin are asserted
too: Waze needs navigate=yes, Google needs directionsmode=driving, Apple needs
dirflg=d. Apple Maps is never probed — it has no queryable scheme, and is
present on iOS and absent on Android by platform, not by canOpenURL.

Two things checked rather than assumed. Info.plist already declares
comgooglemaps, waze and uber under LSApplicationQueriesSchemes, so canOpenURL
reports honestly — my first read of that file was truncated and I briefly had
this wrong. And a scheme that is *not* declared rejects rather than resolving
false, so the probe treats a rejection as unavailable instead of throwing.

Verified on device: the picker showed 1.3704, 103.8842 — latitude first — with
Apple Maps as the default and the other three correctly marked not installed on
a clean simulator, and tapping through launched Apple Maps from our URL.
452 tests, typecheck and v3 lint clean.
… reason

Tier 1 #14. The OS shows its prompt once. A driver who taps "Don't Allow" out of
reflex has to be walked through Settings from then on, so the screen spends a
moment explaining what each permission buys them before that single chance is
spent.

Two distinctions the screen is built around. `denied` can be asked again;
`blocked` cannot, and only Settings will change it — collapsing the two produces
an Allow button that appears to do nothing, which is the usual way a primer
makes things worse. And iOS will not grant always-on location unless
when-in-use was granted first: asking for always up front is refused without
even showing the escalation, so the request looks like it failed for no reason.
requestPermission asks for the base grant and escalates only on success, and
reports a partial grant plainly — the app works, but stops tracking when
backgrounded, which is worth telling someone.

Verifying it on device turned up something the tests could never have caught:
the iOS prompt read "This app may need to use your camera for your profile
picture". The camera is for proof-of-delivery photos and barcode scanning. Both
photo-library strings were equally vague. These are what the driver reads at the
one moment they decide, and App Review requires them to be accurate — all three
rewritten (F-24). They need a native rebuild to take effect.

Two more logged rather than changed, since both need pod install and a native
rebuild: NSContactsUsageDescription is declared and Contacts is in the Podfile's
setup_permissions, but the app has no contacts feature at all (O-15, a rejection
risk and an unnecessary privacy ask); and two keys in Info.plist are not real
iOS keys and do nothing (O-16).

react-native-permissions needs its official mock under Jest, now in jest.setup.

Verified on device: the primer read real state — location already Allowed,
the others offering Allow — and tapping through fired the OS camera prompt.
483 tests, typecheck and v3 lint clean.
Tier 1 #15, and the point of it is what it does not ask for. v2's
InstanceLinkScreen wants the driver to paste an API key, and its deep link ships
one automatically — a single admin-scoped, org-wide, unrevocable credential on
every handset, which is the security hole the audit opens with.

None of that is needed, because a Fleetbase host identifies itself
unauthenticated: the root endpoint answers with its name and version. So the app
confirms the address is real, and how old the instance is, before anyone types a
password — and the driver then signs in with their own credentials. No shared
secret is stored at any point.

Plain http to a remote host is refused rather than warned about, since it would
put the driver's password on the wire in clear; loopback and private ranges are
allowed, where it is normal. URL parsing is separated from the network call
because that is where the quiet mistakes are: a bare domain, a trailing slash, a
pasted console URL with a path, and a scheme-less "localhost:8000" all have to
work, while a bare "fleetbase" is a typo rather than a host.

Verifying it on device found F-25: pre-auth screens render outside DriverShell,
which is what supplies the top safe-area inset everywhere else, so the title
collided with the Dynamic Island. Sign-in had been papering over the same gap
with a hardcoded paddingTop that happens to clear the island on this device.
Both now read the real inset.

Verified end to end on device: signed out, opened the screen from sign-in,
checked the live address and saw "Fleetbase server found — Version 0.7.53" with
no credential involved, then signed back in. 516 tests, typecheck and v3 lint
clean.
Tier 1 #16. The queue is the app's promise that work done without signal is not
lost; this screen is where that promise becomes auditable. Failed work sorts
above work that is merely waiting, because only the failed entries will not move
on their own, and each carries the reason it was refused. Discarding asks first
and names what would be lost — it is irreversible, and on a proof-of-delivery
capture it means the capture never happened, so "Clear" would be the wrong word.

Building it turned up three defects in the queue underneath.

useQueue served a stale snapshot whenever an item changed in place: its
stability check compared counts and length, none of which move when a retry
takes attempts from 1 to 2 and sets lastError while the item stays pending. The
screen would have shown the attempt count frozen — the one thing it exists to
show. Snapshots were also not immutable: the array was copied but every item
object was shared, so a later retry rewrote a snapshot already handed out,
including React's, which useSyncExternalStore requires to be stable. And queued
work was labelled "POST issues", which is fine in a log and useless to a driver
deciding whether to wait for signal or throw something away.

Labels are now stored as a key rather than a translated string, because the
label is written at enqueue and read much later — freezing English at enqueue
would survive a language change, and a queue that outlives a flight is exactly
where that shows.

I also had the queue's own semantics wrong in an earlier note. Head-of-line
blocking applies to *transient* failures only: a permanent 4xx parks the item
and the pass continues past it, so a rejected entry does not strand everything
behind it. Both behaviours are now pinned by tests, and the ledger corrected.

Device verification is partial and deliberately so: the empty state was checked
against the live instance, but producing genuinely failed entries needs the API
unreachable, which would mean stopping the user's server. The populated states
rest on the 18 tests until the offline pass in the plan's verification section.
537 tests, typecheck and v3 lint clean.
…s, custom_field_values

The form is grouped_fields; a first-cut items form folds into the same shape.
The checklist renders each field type natively — pass/fail with the defect
sheet driven by the field's own rules, number with unit, choice, yes/no,
date, photo (camera) and signature (pad) — every answer persisted in the
draft. buildSubmission sends custom_field_values per the overhaul design and
still derives item_results for a first-cut server; meter and signature
fields stand in for the top-level odometer and signature. Detail shows typed
answers and attached files. 20 new tests.
Fleetbase labels now print a Code 128 barcode carrying the bare tracking
number beside the QR code, and capture-qr accepts it. Scan for it too, so
either code on a label is enough.

Type QrCodeScanner's onScan with vision-camera's Code result: it has always
passed the result object, not a string.
…kens

Mint a short-lived socket token for the signed-in driver from
POST {host}/{namespace}/socket/token and deliver it in the SocketCluster
handshake through an in-memory auth engine. The token is refreshed a
minute before expiry, and re-fetched when the server deauthenticates the
socket, kicks it out of a channel or rejects a subscription; tracked
channels are then resubscribed. Servers without the route (404) and
signed-out sessions connect anonymously as before.

The socket is recreated on login, logout, organization switch and
instance-link changes, and identifies itself with a navigator/<version>
client tag in the handshake query.
The Chat button on the order customer card had no handler. It now looks up
the order's chat channel (created by the storefront backend when a driver is
assigned, tagged with storefront_order_uuid / storefront_order_id in its meta),
reloading channels once if it is not cached, and opens ChatChannelScreen.

If the order has no channel yet a toast explains that instead of failing
silently. The button is hidden for non-storefront orders, for waypoint
customers other than the order customer, and when the chat tab is disabled.
The notification handler navigated to a ChatList route that does not exist
(the chat stack's home screen is ChatHome), and never set the current chat
channel, which ChatChannelScreen renders instead of its route params. So
opening a chat notification showed the previously opened channel, or crashed
when there was none. Also bail out when the channel cannot be loaded, and add
the chat callbacks the listener uses to its effect dependencies.
Copy for the order-operations work that follows: the "this order changed"
banner, item quantity / note / unavailable / substitute, cash on delivery,
delivery instructions and schedule, customer and dispatch contact, booking
jobs and the bring-to-the-job checklist, direct offers (countdown, expired,
unsupported), reassignment notices, and order-thread roles. Booking statuses
`booked` and `provider_enroute` get labels. Spanish kept in parity.
…signed orders

- Which status finishes an order now comes from the order config's
  `complete` activity (data/orderStatus), falling back to the old hard-coded
  sets. The store's "active" view, Orders' segments and the stop screens all
  use it, so a booking flow that ends on its own code finishes correctly.
  Configs are cached outside React (data/orderConfigCache) so the store can
  read them, and a config arriving later recomputes the views.
- Driver-scoped list queries stamp their rows and reconcile the store after
  each run: an order the query no longer returns (within its window) is
  removed; active ones are recorded as reassigned with a notice for the
  driver. upsertMany only ever added, so a reassigned order stayed on the
  device and could be advanced into a server refusal.
- remove(id, 'reassigned') records why, for screens holding the order to
  leave; notices() / dismissNotice() back the "order was reassigned" banner.
- useOrderRecord refetches on every live-refresh bump and on a reload token
  (focus), and treats an order now naming another driver as reassigned.
- pendingOffers(driverId): orders whose meta.offer is `offered` to this driver.
- List queries prefetch the configs behind their rows (once each).
…-ops and chat events

- App.tsx passes the SocketCluster config v2's provider builds (env defaults,
  overridden by a linked instance) to V3App. Without it SocketProvider
  mounted disabled and the app heard nothing.
- SocketProvider holds the socket in state, not a ref. Children subscribe in
  effects that run before the provider's, so the driver channel was handed a
  no-op and never subscribed; `listen` now changes identity when the socket
  exists and every subscriber re-runs. A reconnect bumps live refresh, since
  events broadcast while disconnected are gone.
- Event map gains order.changed (with summary/changes), order.updated,
  order.driver_unassigned, order.offer_expired, the OrderAssigned
  notification's `order.assigned_notification`, and chat message events.
  The order id is also read from data.id. Unknown events are still dropped
  but logged in development.
- applyRealtimeEvent is the one handler for socket and push: unassignment
  removes the order (reassigned), order.changed keeps its summary for the
  order screen's banner, order events bump live refresh, chat events bump a
  separate chat counter.
- useOrderChannel follows `order.{id}` for the open order;
  useChatChannelSubscriptions follows `chat.{id}` / `chat_channel.{id}`.
- Push plumbing: V3App takes `subscribeToPush` (App.tsx adapts v2's
  NotificationProvider); parsePush / pushToEvent / destinationOf describe what
  a push means, and a root navigation ref lets a tapped push open an order or
  conversation from any tab, even before the container is ready.
Contract 1.4: an order put in a driver's name starts with meta.offer
`offered` and an `expires_at`; the driver answers with
POST v1/orders/{id}/accept or /decline. Ad-hoc offers keep start + assign.

- useRespondToOffer posts accept/decline through the adapter, never queued
  (added to NEVER_QUEUE — an answer replayed after the window is not an
  answer). Outcomes: accepted, declined (the order leaves the store, no
  reassignment notice), expired, unsupported (404/405: the server does not
  have the endpoint yet), failed.
- useCountdown / formatCountdown tick down to expires_at.
- OfferCard (previously unused) now translates its copy, shows an expired
  state that withdraws Accept, and wires Decline and Details.
- DirectOfferCard wraps it with the countdown and the answer, for Today,
  Offers and the order itself.
- OffersScreen lists direct offers above the nearby ad-hoc ones and reloads
  on focus. Also fixes a pre-existing test that never unmounted its tree.
…xt step, proof loop

- The order, tracker and next step are refetched on order events (live
  refresh, `order.{id}`) and on focus; previously the store copy was read
  once and the tracker loaded once.
- "This order changed" banner from order.changed / order_changed using the
  server's summary; when the changes include the drop-off it offers
  "Re-open navigation" to the order's current stop.
- Navigate targets the stop being headed to (current_waypoint, else the
  drop-off), not payload.dropoff.
- Items show quantity (meta.quantity), the item note, unavailable items and
  substitutes (meta.substitute_for), per contract 4.
- Cash on delivery: payload.cod_amount, "to collect" per meta.payment.state
  (absent on older orders: the amount alone means cash is due; paid /
  refunded owe nothing). Completing the order waits for "I collected …".
- Delivery instructions (drop-off place meta/instructions), order notes, the
  scheduled window or booking slot, and customer Call / Message plus Message
  dispatch — through the order's own chat threads when they exist.
- Next step from GET orders/{id}/next-activity when the server answers; it
  has evaluated the flow's logic, so a branch becomes a choice of buttons
  instead of "ask dispatch". Falls back to the client sequencer otherwise.
  "Finished" is the config's `complete` flag.
- Proof loop: a capture is recorded against its order and activity
  (proofLedger, persisted); returning from capture advances once, sending the
  proof id with the update, instead of opening capture again.
- Booking orders (meta.is_booking) read as a Job with their slot and a
  "Bring to the job" checklist; booked / provider_enroute get registry
  entries and `confirmed` ranks second in the lifecycle.
- A pending direct offer shows on the order and holds the flow until
  accepted. A reassigned order calls onRemoved and says so.
- Orders and Today show reassignment notices; Orders links to Offers with a
  waiting count, Today shows the first direct offer; Orders' completed
  segment uses the config-driven finished check.
- Chat data: channel meta (order_public_id, kind), participant meta.role,
  message meta.system; orderThreadsOf picks the dispatch (`driver`) and
  customer (`group`) threads; lists refresh on chat events; an unread store
  for the Inbox badge.
…eads, v3 inbox badge

- PushRouter: a foreground push is applied as the socket event it duplicates
  (refresh; remove a reassigned order; keep an order change's summary); a
  tapped push opens the order (order_changed, order_assigned, …), the Orders
  list (order_unassigned) or the conversation (chat_message_received).
- Screens holding a reassigned order (detail, item, timeline, proof capture,
  destination, hand-off) pop to the top of their stack; the list there says
  the order was reassigned.
- The navigation hand-off takes the order id and reads the order's current
  destination from the store, so a drop-off moved after the tap (or while
  the picker is open) still sends the driver to the right place.
- Order detail refetches on focus, opens order threads in the Inbox, and
  falls back to a new conversation for "Message dispatch". Offers is
  reachable from Orders and Today.
- ChatWatcher keeps the driver's channels loaded and followed on the socket;
  the Inbox badge is v3's own unread total instead of v2's ChatContext.
- Conversation: system messages (meta.system) render as notices, order
  threads label each side (Store, Customer, You), and the header and inbox
  row name the order the thread is about.
An order assigned to a driver is simply theirs. Only ad-hoc orders are
offered, through Fleet-Ops' existing ping, and taken with `start` + `assign`
(the Offers screen and its Accept / Dismiss). The direct-offer layer built
on `meta.offer` duplicated that and is removed with its Fleet-Ops side:

- Reverts "Offers: direct offers with a countdown, accept and decline".
- Today, Orders and the order no longer show a direct offer or hold the flow
  for one; the Orders screen keeps a plain button to the ad-hoc Offers screen.
- The order store, realtime (`order.offer_expired`) and strings lose the
  direct-offer pieces.
v3: realtime order changes, reassignment, order chat and bookings
Conflicts came from the v2.0.11 release commits this branch carries:
- src/utils/index.js: keep the image-dimension resize fix, in v3's JSDoc style
- translations/en.json: keep v3's strings and main's location disclosure copy;
  drop the unused skipForNow
- src/legacy/features/Shared/ProofScreen.js: v3 deleted it; QrCodeScanner
  already scans code-128
- package.json: drop main's jest key, which collides with v3's jest.config.js;
  its modulePathIgnorePatterns move there
Conflicts came from the v2.0.11 release commits this branch carries:
- src/utils/index.js: keep the image-dimension resize fix, in v3's JSDoc style
- translations/en.json: keep v3's strings and main's location disclosure copy;
  drop the unused skipForNow
- package.json: drop main's jest key, which collides with v3's jest.config.js;
  its modulePathIgnorePatterns move there
Conflicts came from the v2.0.11 release commits this branch carries:
- src/utils/index.js: keep the image-dimension resize fix, in v3's JSDoc style
- translations/en.json: keep v3's strings and main's location disclosure copy;
  drop the unused skipForNow
- package.json: drop main's jest key, which collides with v3's jest.config.js;
  its modulePathIgnorePatterns move there
feat(scan): read Code 128 tracking barcodes; change list for structured tracking codes
feat(socket-auth): authenticate realtime socket with driver socket tokens
feat(chat): open the storefront order chat from the customer card
Navigator v3 — Waypoint redesign, expansion and refactor
The v3 realtime client connected anonymously. It now builds the same
in-memory auth session the v2 contexts use (services/socket-auth): the
handshake carries a socket token minted for the signed-in driver from
POST {host}/{namespace}/socket/token, refreshed a minute before expiry
via socket.authenticate, and re-fetched when the server deauthenticates
the socket, kicks it out of a channel or refuses a subscription. The
channels `listen` currently holds are then subscribed again. A signed-out
app and a server without the route (404) connect anonymously as before.

V3App passes the API host and driver bearer to SocketProvider, plus a
session key (AuthContext's sessionEpoch, driver and organisation) so the
socket is rebuilt on login, logout, organisation switch and instance
reconfiguration. The handshake identifies the client as
navigator/<version>; the token never reaches storage or the URL.

A listener releasing a channel another listener still holds no longer
closes it under the other.
…release

feat(socket-auth): authenticate the v3 SocketProvider with driver socket tokens
@roncodes roncodes changed the title Release Navigator v3.0.0 Navigator v3.0.0 Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant