Changelog

What we shipped, when.

Every meaningful product change, in chronological order. We don't list every PR — just the moments that mattered. 79 entries across 10 ship days. Click any date to expand or collapse.

5 changes

Quotes: send a proposal before it becomes an order

  • Customers asking for a price BEFORE they commit used to live in a spreadsheet column or a Whatsapp thread — there was no document that the office could send, the customer could accept, and finance could audit. Built a full Quotes module that keeps proposals strictly separate from orders so a quote NEVER pollutes revenue, inventory commitment, or driver assignment until the customer says yes.
  • Lifecycle is its own thing: `draft → sent → accepted | declined | expired → converted (or voided at any point)`. Reference numbers are independent — `Q-00001` versus the order side's `OD-00001` — so a glance at the document tells the customer (and your finance team) which kind they're looking at. New columns on Organization (`quote_ref_prefix`, `quote_ref_counter`) and a `allocate_quote_ref(org_id)` Postgres function mirror the self-healing concurrent-safe allocation pattern we already use for order refs. Schema is in `prisma/migrations/20260521120000_add_quotes/`.
  • Operator surfaces: new Quotes nav item in the sidebar (`src/components/admin/sidebar.tsx`). New `/dashboard/quotes` index with status filters + a search box. New `/dashboard/quotes/new` form to compose a quote — customer details, line items, terms, expiry date. New `/dashboard/quotes/[id]` detail view that mirrors the Order detail visual language: status pill, customer block, items table, event timeline, action buttons (Send, Mark accepted, Mark declined, Void, Convert to order). PDF rendering at `/api/quotes/[id]/pdf` matches the existing label/POD PDF stack so the same operator workflow (download / email / print) Just Works.
  • Customer side: every quote sent gets a tokenised public link `/quote/{token}` — same pattern as the tracking page, no auth required. Token is 22 chars from a 54-char mixed-case alphabet (~128 bits of entropy, deliberately stronger than tracking codes because clicking Accept is a financial commitment). The page shows the line items + terms + expiry, plus a one-tap Accept / Decline. Declines capture a structured reason (price / timing / competitor / no_need / not_ready / other) and a free-text note so the operator can see win/loss patterns over time, not just "declined".
  • Converting to an order: once a quote is accepted, the Convert button on the detail page creates a fresh Order row with `from_quote_id` set, the quote moves to `status=converted`, and the new order page shows a "From quote Q-XXXXX" badge linking back to the source (`src/app/dashboard/orders/[id]/page.tsx`). A unique constraint on `from_quote_id` enforces "one quote → at most one order" at the database level so a double-click on Convert can't silently create twins.
  • Expiry: quotes with an `expires_at` in the past flip to `status=expired` via a `/api/cron/expire-quotes` daily job — same cron-auth pattern as the existing notification engine. Email channel uses the new `src/lib/emails/shell.ts` shared template helper so every quote-related email (sent, accepted-confirmation, decline-receipt) renders inside the same branded chrome as order notifications.

First-run onboarding wizard for new workspaces

  • Brand-new workspaces used to dump the owner straight onto the dashboard with no defaults — no logo, no brand colour beyond the seed blue, no support contacts surfaced on the tracking page, no owner phone for SMS escalations, the Main Depot a blank-address placeholder. Nothing was BROKEN, but the workspace felt unconfigured the first time a customer received a notification. Now sign-up routes to a three-step `/onboarding` wizard that captures the bare minimum to make the workspace look operated by a real business on day one.
  • Step 1 — About you: confirm your display name (pre-filled from sign-up, easy to typo when you're hurrying through), and add an owner mobile in E.164 form. The phone is stored on `Membership.phone` for the owner row so future SMS triggers (flagged orders, failed vehicle checks, dispatch escalations) have a number to dial without us bolting a separate `owner_phone` column onto Organization. Validation uses the existing `normalizePhone()` helper with a ZA default region.
  • Step 2 — Your business: edit the workspace name (also pre-filled from sign-up), upload a logo (PNG / JPG / WebP, soft-capped at 4 MB, presigned upload to R2 under `${orgId}/branding/` so we can later open a public-read CDN rule on the branding prefix specifically), pick a brand colour from an 8-preset swatch row + hex input, and paste an optional Google review link. Logo uploads validate the returned URL through `isOwnR2Url()` server-side so a hand-crafted POST can't pin a third-party hotlink into `Organization.logo_url`.
  • Step 3 — Operations: set the default depot's address (the wizard upserts onto the "Main Depot" sign-up created with a blank address, so the operator doesn't end up with two depots), customer support email (defaulted to their sign-up email so they don't re-type), and customer support phone. Both contacts feed the tracking page footer + email templates.
  • Completion is recorded as `Organization.settings.onboarding_completed_at`. The dashboard layout reads that key and bounces the OWNER back into `/onboarding` if it's missing, so a user who confirms their email later (and lands on `/auth/callback?next=/dashboard`) still hits the wizard once. Skip-for-now still works — it stamps the timestamp with `onboarding_skipped: true` so the operator isn't trapped, but internal reports can later filter on the flag if we want to chase up half-set-up workspaces. Only the owner is gated; invited admins of an already-set-up workspace go straight to the dashboard.
  • Files: `src/app/onboarding/page.tsx` + `wizard.tsx` + `onboarding.css`, `src/lib/actions/onboarding.ts`, plus a one-line redirect change in `src/lib/actions/auth.ts` (signUpFormAction now lands on `/onboarding` instead of `/dashboard`) and a 14-line first-run gate in `src/app/dashboard/layout.tsx`.

Complaints view on Retention analytics

  • Retention analytics surfaced reorder cadence but had no view of the unresolved complaint queue — operators had to scroll order-by-order to find which 1-2 star ratings still hadn't been actioned. The Customers analytics page (`/dashboard/analytics/customers`) now has two tabs at the top: Retention (the cadence view, unchanged) and Complaints (new). Switches via `?view=complaints` so deep-links work; the tab badge shows the open-complaint count straight from the DB so you don't have to flip just to check whether the queue is empty.
  • Complaints view groups every customer-submitted low rating (`rating_stars ≤ 2`) that hasn't been resolved yet by the structured `rating_reason` bucket the customer chose (driver / packaging / timing / items / other). Each row shows the order ref, customer name, stars, the free-text feedback, and a one-click Resolve action that stamps a resolution event on the order so the row drops out of the queue. The reason filters now match how complaints are sliced in the rating-reason chart introduced last week.
  • Backed by a new domain service in `src/lib/services/complaints.ts` (single `listOpenComplaints()` + `summarizeComplaints()` pair so the count and the rows come from the same query — no risk of the tab badge disagreeing with the list below it). Server action `resolveComplaint` lives in the new tab's `resolve-complaint-button.tsx` so the resolve action stays co-located with the surface that triggers it. View toggle component: `view-tabs.tsx`.

Account: destructive actions get branded confirm dialogs

  • "Sign out everywhere" and "Leave workspace" used `window.confirm()` for the are-you-sure step — fine on desktop but on mobile it flashes an OS-chrome sheet that ignores the rest of the brand, and on iOS specifically truncates the message so the user can't see which workspace they're about to leave. Both dialogs now use the in-app `<Dialog>` primitive (same one as the remove-member dialog in `users-list.tsx`), so the destructive-action language reads consistently across Settings and the message actually fits.
  • Also renamed the button label from "Sign out others" to "Sign out elsewhere" — operator feedback was that "others" read as "other people" (their teammates), when the action only signs out their own other sessions. The reset-link toast copy was tightened too — was "We sent a password-reset link to <email>", now "Check <email> — the link arrives in under a minute and works for an hour" so the operator knows both that it's coming and how long they have.
  • Files: `src/app/dashboard/account/account-form.tsx`.

Landing page: new headline + Roobert typography

  • Hero headline rewritten from "Run your delivery business from one place." to "Own every mile between the depot and the doorstep." — the new line frames the product around what the operator actually controls (the route, the proof, the customer experience end-to-end) rather than the generic "all-in-one tool" framing every competitor leads with.
  • Sans-serif on the landing route swapped from Inter to Roobert (commercial sans from Displaay Type Foundry), self-hosted via `public/fonts/` so we don't depend on Adobe Fonts / Typekit. Falls back to Inter automatically if the .woff2 files are missing — the page still renders, just in the default sans, so a missed deploy doesn't blank the marketing surface. App / dashboard / driver surfaces unchanged (still Inter via globals.css) — the typography swap is landing-only.
2 changes

Auth emails: cleaner flow, no more silent failures

  • Every email-link flow that runs through Supabase Auth (sign-up confirmation, password reset, invite, password change) had the same set of paper cuts: an expired or used link dropped the user on /dashboard with no session, the middleware bounced them to /auth/sign-in, and the operator had no idea why. The "Account created — check your inbox" state after sign-up was rendered as a red error banner under the form, reading as a failure even though the account was successfully created. Clicking the Change Password email from the Account page redirected to /dashboard/account with nothing prompting for a new password.
  • New `/auth/error` page handles every failure mode the email-callback can hit — expired link, already-used link, malformed link, missing parameters, exchange failure — each with copy that explains what happened and a single "send me a new link" CTA. The callback route now reads Supabase's `error_code` query parameters when a link is bad (Supabase appends them to the redirect_to URL instead of a usable code) and surfaces the matching reason instead of pretending nothing happened.
  • New `/auth/check-email` page handles the "email confirmation is on, click the link in your inbox" state with a green-toned success card, the actual email address echoed back, and an "Open my inbox" button that deep-links to Gmail / Outlook / Yahoo for the common cases. Sign-up form no longer renders this as a red error.
  • Change Password from Account → Security now actually opens the set-password form on click (was redirecting to /dashboard/account, which has no password input — the user would receive the email, click it, and end up exactly where they started, with no way to type the new password). Also fixed the underlying URL-encoding bug that meant `?reset=1` / `?invited=1` flags were being stripped before reaching the set-password page, so reset emails were showing the generic "Set your password" copy instead of "Set a new password".
  • Auth callback now handles both the PKCE `?code=…` shape (newer Supabase email templates) and the older `?token_hash=…&type=…` shape (default templates and SMTP-customised projects). Either format works without changes to your Supabase configuration.

Tracking links now carry the business name

  • Customer-facing tracking URLs used to be a generic `odro.ai/track/U6Y7RKPHP` — the recipient saw a brandless string in their address bar and a fingertip-typed-it-wrong feeling in the SMS preview. The link is now `odro.ai/track/{your-business}/U6Y7RKPHP`, where the business segment is the workspace slug already allocated at sign-up (the same slug that prefixes your order references). The recipient sees who the order is FROM in the link itself, not just in the page that opens.
  • Backwards-compatible: every old `/track/{code}` link still resolves — the legacy route now 308-permanent-redirects to the branded URL after looking up the order's org. Past emails, past SMS, past Shopify fulfilment URLs, customer bookmarks — all still work and quietly upgrade themselves on the next click.
  • Updated everywhere a tracking URL is built: order confirmation / packed / out-for-delivery / delivered emails (Resend), Shopify fulfilment push (the `tracking_url` field merchants see in Shopify Admin), the public REST API (`GET /api/v1/orders`'s `tracking.url`), and the in-dashboard "Copy tracking link" button. Lookups are still keyed by tracking_code only; the slug segment is cosmetic, so an old bookmark with the wrong slug canonicalises to the right URL on visit rather than 404'ing.
9 changes

Per-device "not yet opened" indicator on every order

  • Operator pain: a new order captured mid-afternoon blended in with the morning's already-triaged ones — there was no visual way to tell "I haven't seen this one yet" from "I've already decided this is going on the second run". The risk on a busy day is that the new order gets forgotten when the truck rolls out for a second load.
  • Built a per-device read-tracker (`src/lib/client/orders-read-tracker.ts`) backed by localStorage. On a brand-new browser the first visit to the orders page stamps a `tracking-since` timestamp, so EXISTING orders are treated as already-read — no spam of 100 dots on day one. Every order created AFTER that stamp gets a small blue dot next to the customer name until the operator opens the detail page (which calls `markRead(id)` via the new `<MarkReadOnMount>` component). The read set is capped at 500 ids with FIFO drop so localStorage stays bounded.
  • Visual surfaces:
  • • Orders table (desktop) — 8px blue dot left of the customer name with a soft primary halo (matches the focus-ring vocabulary established elsewhere).
  • • Orders card list (mobile) — same dot, in the customer-name row.
  • • Map view → Allocation by zone — blue "N new" pill next to each zone header (e.g. "Camps Bay · 2 new") so a dispatcher can spot which areas got fresh orders without scrolling. Renders nothing when the count is 0 so quiet zones stay quiet.
  • All three indicators subscribe to a `df_orders_read_changed` window event so opening an order in another tab clears its dot/count in the list without a refresh. Per-device by design — two operators sharing a workstation share the read state for that browser; same operator on their phone is independent. Can be migrated to a server-side `OrderRead` table later if multi-device sync becomes a real requirement.

Driver app shows the COD amount to collect at the door

  • Drivers landing on a Cash-on-Delivery order used to see a generic "Collect payment (COD)" disclosure button with no number — they had to either remember the total from the order detail screen, or expand the COD capture form and figure it out. The deliver screen now surfaces the exact outstanding amount on the button face so the driver knows what to collect before they tap.
  • Driver order detail (`/driver/order/[id]`) — bright-green callout above the items list when `is_cod && outstanding > 0`: icon + "CASH ON DELIVERY" eyebrow + the outstanding amount (R 950.00) + "Collect this amount at the door" sub-line. If the customer's pre-paid part of it, the sub-line picks that up too: "… · R 200.00 already paid".
  • Confirm Delivery screen (`/driver/deliver/[id]`) — the "Collect payment (COD)" disclosure button is now a green-tinted CTA showing the outstanding amount on its face. When the driver taps to expand, the `CodBlock` amount input is PRE-FILLED with the outstanding value so the common case (full-amount cash capture) is a single tap: Open → Save. The driver can still override (customer pays half cash, half SnapScan).
  • Edge case handled: if the order is somehow `is_cod` but already paid in full at the till, the button collapses to a quiet "COD · already paid in full" state and drops the green styling — no false action prompt.
  • Both screens pull `total_amount` + `amount_paid` straight from the Prisma order row through to the components. Same canonical `custom_fields.cod === true` signal the office-side payment surfaces already use, so the flag stays consistent across operator and driver views.

Analytics: "Fulfilment split" replaced with a delivery hotspot map

  • The fulfilment-split card (delivery vs collection ratio + revenue tiles) was a one-number metric the Lifecycle donut already covers at a glance. Replaced it with a card that answers a far more actionable question for ops planning: where are the deliveries actually landing?
  • New `AnalyticsHotspotMap` client component (`src/components/admin/analytics-hotspot-map.tsx`) — a Leaflet map with one `circleMarker` per suburb, RADIUS scaled by delivery volume (clamped 8–36px so a single-delivery suburb stays visible and the busiest doesn't swallow the map), FILL OPACITY scaled by relative density. Tooltips show "Camps Bay · 14 deliveries" on hover. Pure Leaflet — no `leaflet.heat` plugin needed.
  • Auto-fit bounds: the map zooms to the actual delivery footprint, so if most of the period's orders are in the West Coast, the view focuses on the West Coast instead of staying glued to Cape Town centre.
  • Right-side legend ranks the top 8 suburbs by count with a thin proportional bar + count + percent share, plus a "Open delivery map →" quick-link to the orders page. Layout collapses to a single column at ≤900px viewport.
  • Data: pulls `customer_address` for every non-cancelled delivery order in the analytics window (capped at 5,000 rows), extracts the suburb client-side via the existing `extractSuburb()` + `suburbToLatLng()` gazetteer, rolls up counts per suburb. Replaces the previous `order.groupBy({ by: ["order_type"] })` query and the `OrderTypeSplit` component (≈80 lines deleted).

Map: broader allocation zones (Atlantic Seaboard / False Bay / West Coast split out)

  • Operator feedback on the orders-page map "Allocation by zone" panel: the previous 5-zone preset (CBD / Southern Suburbs / Northern Suburbs / Cape Flats / Helderberg) was too coarse — it lumped every ocean-facing Atlantic Seaboard suburb in with the CBD, and every False Bay coastal town in with the Southern Suburbs. Drivers heading to Sea Point follow a different route than drivers heading to Gardens, but they were sharing a bin.
  • Restructured the Cape Town preset into 8 dispatch-friendly zones:
  • • CBD & City Bowl — the bowl proper (Gardens → Walmer Estate)
  • • Atlantic Seaboard — Sea Point through Hout Bay (the ocean-facing west)
  • • Southern Suburbs — Woodstock through Constantia/Tokai
  • • False Bay & Deep South — Muizenberg through Kommetjie (the ocean-facing south)
  • • Northern Suburbs — Pinelands through Durbanville
  • • West Coast — Milnerton through Melkbosstrand (the ocean-facing north)
  • • Cape Flats — Athlone through Mitchells Plain
  • • Helderberg — Somerset West, Strand, Gordon's Bay
  • Also made the broader preset the DEFAULT for the map view (was `DEFAULT_ZONE_CONFIG` which fell through to suburb-passthrough). Orgs in other cities can still pass `DEFAULT_ZONE_CONFIG` or a custom preset via Settings → Zones to override; the product's primary market is Cape Town and the default now reflects that.

Orders day-strip selected chip now visibly darker

  • The selected-day chip was using `--bg-nav` (#F9F9F9) as its fill, which was almost imperceptibly different from the surrounding white chips. Operators reported the selected day failed its primary job — signalling "this is the day you're looking at".
  • Bumped the selected chip to a clearly-darker treatment: `--bg-hover` (#EFEFF3) fill, `color-mix(in srgb, var(--ink) 18%, var(--line-2))` border, an inner 1px ink-tinted ring (`box-shadow: inset 0 0 0 1px …`), and the day number bumped to `font-weight: 600`. The selected day now reads as a distinct pick at a glance instead of near-white.
  • Issue/overdue/partial signalling stays in the corner-triangle badge — the selected colour is intentionally neutral so an issue-day looks identical whether selected or not, just with a darker fill.

Focus ring softened across the entire site

  • The global `:focus-visible` rule was `outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px` — a hard 2px ring sitting 2px away from the element. On inputs that already had their own wrapper-level focus halo (the weight-chip editor, the price field, the search inputs) the global outline doubled up on top of the wrapper halo and produced the oversized blue-on-blue ring operators were reporting as "way too thick".
  • Switched the rule to `outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 30%, transparent)` — a subtle 3px soft halo at ~30% primary opacity. Modern Tailwind / Linear / Stripe form-focus look. Three behavioural wins: follows the element's actual border-radius (no square-corner mismatch on pill inputs), tucks neatly inside wrapper-level halos so nested fields don't pile up rings, and same keyboard-a11y signal (focus-visible only, not on mouse click).

Order line items: 3-dot disclosure removed

  • The trailing 3-dot button on each order line item expanded an activity-history panel + per-item "Split to next day" / "Mark issue" action buttons. Operators flagged the affordance as noisy on long item lists and duplicative of the order-level More menu that already exposes the same actions via dedicated dialogs.
  • Rewrote `src/components/orders/order-item-row.tsx` as a read-only row: icon + name (+ optional grammage) + qty + total. Per-item history (deliveries / issues / operator splits) is still captured in the underlying data and surfaces through the order's main activity timeline; the dedicated order-level Split + Mark Issue dialogs (reached via the order page's `... More` menu) still let an operator act on specific items when needed.

Admin shell: page content was squeezed into the 60px sidebar slot

  • The sidebar was switched from `position: sticky` to `position: fixed` (to fix a separate wizard-page scroll bug). That removed it from grid auto-placement on the `.admin-shell` 2-column grid (`60px / 232px 1fr`), and CSS grid auto-placed `.admin-main` — the only in-flow child left — into the FIRST column. Result: every page rendered as a 60px-wide vertical strip with content cut off on the left.
  • Fix: explicit `grid-column: 2` on `.admin-main` so the page content stays in the wide track regardless of how many in-flow children the shell has. The sidebar can keep its `position: fixed` for the wizard-scroll fix; the main content now has its own reserved grid track that no auto-placement can claim.

Sidebar profile avatar centred in the collapsed rail

  • When the sidebar was collapsed (60px wide), the user avatar at the bottom was sitting 6px to the LEFT of the Search / Bell / Settings icons above it — a visibly jagged left edge in the bottom utility group.
  • Math: the 20px nav-item icons sit at `padding: 8px 14px` so they centre at x=24 inside the 48px interior rail; the 28px avatar was at `padding: 8px 4px` so its centre was at x=18. Bumped collapsed-state padding to `8px 10px` so `(48 − 28) / 2 = 10` math centres the avatar on the same column. The 2px difference from the expanded state's `8px 8px` transitions smoothly via the existing `transition: padding 0.2s ease`.
10 changes

Messaging integration plan — email + SMS for South Africa and global

  • Wrote a full implementation plan for the email + SMS notification pipeline at `MESSAGING_INTEGRATION_PLAN.md`. The triggering policy table at /dashboard/settings/notifications and the logging surface at /dashboard/notifications already exist; the plan covers everything between them — provider selection, routing, templating, queueing, webhooks, compliance.
  • Email: Resend recommended as the primary provider (modern API, React-Email templates align with our Next.js stack, $0.40 per 1k messages beyond the 50k/mo plan). AWS SES held in reserve as the scale-tier fallback once monthly volume crosses 200k. Postmark as the deliverability-focused backup. Both behind a single `EmailProvider` interface so the call-sites don't change when we swap.
  • SMS: Clickatell recommended for ZA traffic (South-African HQ, ~R0.25/SMS, well-known to local retailers, WASPA-compliant). Twilio for international numbers (~R0.75/SMS but one API for every country). Routing decision: phones starting `+27` → Clickatell, everywhere else → Twilio. Same narrow `SmsProvider` interface in front.
  • WhatsApp Business (Phase 3): 360dialog for ZA, Twilio for international. Costs ~5 cents per order at full notification fanout — not a constraint.
  • Architecture: `src/lib/messaging/` with provider interfaces, React-Email templates per trigger (order-confirmed / packed / out-for-delivery / delivered / reschedule / attempted / payment-reminder / invite), an async send queue with 3-retry exponential backoff, and webhook handlers updating the `notifications` table with real delivery status (delivered / bounced / failed). The Notifications log will finally show real statuses instead of every row reading "sent".
  • Compliance baked in from day one: POPIA for ZA (transactional exemption + opt-out + DKIM/DMARC), GDPR for global rollout, quiet hours 21:00–07:00 for non-urgent SMS, alphanumeric sender ID registration with Clickatell, right-to-be-forgotten wiping the notifications table.
  • Four implementation phases scoped at ~2 weeks each (email-only first, SMS for ZA second, international + WhatsApp third, operator reporting + log improvements fourth) with five open questions for the operator on sending domain, sender ID strategy, reply-to behavior, quiet-hours window, and trial-account policy.

Role display + invite-user dialog cleaned up; Production role removed

  • Role pills throughout the team list and invite dialog now read as clean role tags rather than colored status badges. Dropped the small colored dot prefix (so "• Admin" → "Admin") and squared the pill shape from a 999px capsule to a 6px-radius rectangle — operator feedback was the previous treatment looked dot-heavy and unprofessional, especially in screenshots shared with clients.
  • Invite-user dialog: the role picker was a 2-column grid of small cards crammed with pill + 2 bullet permission previews — easy to mis-tap the wrong role, which is a security risk. Replaced with a single-column stack of full-width radio rows: icon (Admin shield / Manager users / Dispatcher package / Driver truck / Viewer eye), role name + one-line tagline ("Day-to-day operations lead"), and a real radio dot on the right that fills primary-blue when selected. Selected row gets a primary-tinted border + outer ring so the picked role is glance-readable. The full permission list of the chosen role still renders in the detail panel below the grid.
  • Production role removed from the system at operator request — packing-floor work folds into Dispatcher's responsibilities. Removed from the `Role` union, `RequestCtx`, `ROLE_RANK`, the `inviteMember` action signature, and the invite-dialog options. Updated `/` and `/dashboard/layout.tsx` redirects so legacy memberships with `role: "production"` fall through to the dashboard cleanly. The /production page itself stays (it's a useful focused packing-floor terminal view) but is now gated to owner / admin / manager / dispatcher only — anyone with a `production` role in the database can still log in via that page, they just see the admin role-gated view.

Settings: shared shell + persistent left rail + warehouse icon

  • Every settings sub-page now lives inside a single shared layout (`app/dashboard/settings/layout.tsx`) that renders a top "Settings" banner + a persistent vertical rail on the left + the sub-page's content on the right. Replaces the previous treatment where each sub-page rendered its own H1 banner, its own subtitle, and a horizontal `SettingsNav` tab strip — three different chrome elements per page, each rendered fresh on navigation, which produced the "feels like a different menu" perception operators flagged.
  • The new rail uses the same white-pill-on-grey active treatment as the main admin sidebar, sticky-pinned under the page header so it stays visible while scrolling long settings forms. Grouped into Workspace / Operations / Communications clusters with quiet uppercase headers. Includes the Overview row at the top so the rail is a complete inventory of settings — no need to bounce back to the hub to find a section.
  • Every sub-page (Organization, Users, Depots, Products, Delivery, Notifications) stripped down to its own content + a shared `SettingsSectionHeader` (title + one-line description). Previously each used a different `maxWidth` (640 / 720 / 860) so the centered area shifted between sub-pages; standardised on a single shell `max-width: 1040px` for the whole settings area so navigation no longer reflows the page.
  • Depots row in the settings hub was missing its icon — the row had `icon="warehouse"` but the lucide-react `Warehouse` glyph wasn't registered in the icon map. Imported + registered, the row now shows the proper warehouse icon next to the label. Hub directory rows also regrouped (Workspace / Operations / Communications / Account) to match the rail's cluster labels.

Analytics: replaced cancellation reasons with "Issues with orders"

  • The analytics "Why orders are cancelled" card was the wrong question for the day-to-day operator — cancellations are rare and terminal, while reschedules, splits, and item-level driver-reported issues are the recoverable operational pain that actually matters. Replaced the card with a new "Issues with orders" block that surfaces three click-through summary rows and an item-issue breakdown.
  • Rescheduled deliveries: orders that have at least one "Delivery rescheduled" tracking event in the window. Links to /dashboard/orders?status=rescheduled — a new special filter that pulls matching order IDs from the tracking_events table and constrains the orders query.
  • Split for next day: orders with `next_attempt_date` set. Links to /dashboard/orders?status=split — hits the `orders_org_next_attempt_idx` partial index directly.
  • Items damaged or missing: orders with at least one item carrying a driver-reported issue (damaged, wrong item, spoiled, stock-short, etc., excluding the `operator_split` placeholder used by ops splits). Links to /dashboard/orders?status=any_issue (the existing `delivered_with_issues + attempted + failed` union).
  • Below the three top-line rows: a proportional pill list of the top item-issue reasons (Damaged / Wrong item / Customer refused / Spoiled / Short on stock / Returned / Temperature excursion / Recipient unavailable / Address not found / Other) so the operator can see WHICH item problem is most common at a glance. Data fetched in one round-trip via a single Postgres query using `jsonb_array_elements` over `orders.items[].issues[]`.

Analytics charts now have proper hover tooltips (no more native browser popups)

  • The lifecycle donut and the orders/revenue stacked-bar chart previously used SVG `<title>` elements for hover info, which surface the browser's native tooltip ("little square box near the cursor") — operator feedback was that these looked unprofessional and disrupted the design language.
  • Extracted both charts into a new client component (`src/components/admin/analytics-charts.tsx`) that tracks hover state in React and renders styled HTML tooltips:
  • Donut: hovering a segment thickens that arc (stroke 26 → 30), dims the other segments to 35% opacity, swaps the centre label from "Total · 101" to "{segment name} · {count}", and floats a tooltip near the cursor showing label + count + percent share.
  • Bar chart: hovering a day's column highlights both stack segments (brightness 1.08), dims the rest, and replaces the static "Deliveries" caption in the legend row with a pill showing the date + perfect count + issue count + total. No cursor-follow — the chart is 1100px wide and a follow-tooltip would jump around noisily; a fixed-slot tooltip reads as a "current selection" badge instead.
  • Invisible full-slot hit-targets per day so hovering the empty space above a short bar still surfaces that day's tooltip. Bars stay click-throughable — each segment is still an SVG `<a>` that navigates to the orders list filtered by that day + outcome.

Sidebar: profile row is the trigger; stays expanded while menu is open

  • Profile menu popup wasn't opening for operators clicking on the name/role text — the trigger was only the small 28px avatar circle, which is a tiny target. The `UserMenu` component now renders the whole bottom-left profile row (avatar + name + role) as its own dropdown trigger button, so clicking anywhere on the row opens the popup (Dashboard / Settings · Home / Help · Privacy / Terms · Logout).
  • The popup is rendered in a Radix Portal that lives outside the sidebar's DOM tree, so moving the cursor from the trigger into the popup was firing `onMouseLeave` on the aside and collapsing a pinned-collapsed sidebar mid-interaction. Lifted the popup's open state up to the sidebar via `onOpenChange`, and the sidebar now ignores the leave event while the popup is open AND force-pins `data-sidebar-hover-expanded="true"` for the entire popup interaction. Closes back to normal hover behaviour the instant the user clicks outside.

Sidebar Analytics: parent row navigates AND chevron collapses independently

  • Operator complaint: clicking the Analytics parent row in the sidebar navigated to /dashboard/analytics correctly, but clicking it a second time no longer collapsed the submenu — the chevron seemed inert. Root cause: the parent row was a Link with a nested button (the chevron) inside it. Nesting a `<button>` inside an `<a>` is invalid HTML, and event bubbling on some browsers (notably Opera) was firing the link's default navigation instead of the chevron's click handler.
  • Refactored to a sibling structure: a positioning container with the Link + the chevron button as separate siblings. The chevron is absolutely positioned over the right edge of the row so the visual is identical to the previous nested layout, but each element has its own clean event path. Clicking the row navigates + opens; clicking the chevron toggles the submenu without navigating. Active state for the parent and its children is unchanged.

Activity feed: filters removed, internal scroll, compact two-line rows

  • Operator feedback on the main dashboard activity card: the filter chips (All / Orders / Deliveries / Payments / Issues) felt like unnecessary chrome between the title and the events; each event row was too tall (16×22px padding, 36×36 icon disk, 3 lines of content); and the card extended past the Needs-attention rail on the right, forcing the user to scroll the whole page to reach the bottom.
  • Dropped the filter chip row entirely — the card now goes straight from header into events.
  • Two-line rows replace the three-line layout. Top line packs the verb ("Out for delivery") · customer name · ref pill (with status tint) on one row; bottom line is the quiet `by Name · time` attribution with optional inline detail. Icon disk down from 36×36 to 26×26, row padding down from 16×22 to 10×16, fonts nudged smaller (14→13 headline, 11.5→11 attribution). Same readability, less vertical real estate per row.
  • Internal scroll: the card is now capped at `max-height: min(680px, calc(100vh - 220px))` (~the Needs-attention card's natural height on a typical viewport) with a thin scrollbar on the events list. Bucket headers (Today / Yesterday / This week / Older) stick to the top while scrolling. Page no longer extends past the attention rail.
  • Business-customer tag changed from "BIZ" (uppercase, letter-spaced, debug-looking) to "Business" — same violet palette, lowercase + tighter tracking. Operators reported the BIZ pill read as a debug label rather than a real customer-type indicator.

Needs attention: "Overdue payments" replaced with "Items with issues"

  • The Needs-attention rail on the main dashboard previously surfaced four buckets: Unassigned today, Failed deliveries, Overdue payments, Flagged for management. Overdue payments was the weakest of the four — overdue collection is a Payments-page concern that already has its own dedicated dashboard (and an outstanding-balances list at /dashboard/finance), while item-level driver-reported issues are the kind of recoverable operational problem that genuinely needs management's attention RIGHT NOW.
  • Replaced the overdue-payments bucket with "Items with issues". The query finds orders with at least one driver-reported item issue (damaged, wrong item, spoiled, stock-short, etc., excluding the `operator_split` placeholder) using a Postgres `jsonb_array_elements` traversal over `orders.items[].issues[]`. Each row shows the customer name + "{ref} · {N} items flagged" with a "Review" CTA linking to /dashboard/orders?status=any_issue. Empty state: "No item issues today".
  • The attention-total header pill now counts items-with-issues instead of overdue-payments, so the red badge above the panel reflects the new bucket composition.

Site-wide clarity audit + 38-page confusion-point document

  • Ran a comprehensive UX clarity audit across every page in the app — 38 routes covering the admin dashboard, settings, driver app, production terminal, customer tracking, marketing site, auth, docs, and changelog. Wrote `CLARITY_AUDIT.md` with the findings.
  • Method: live-screenshotted 13 key admin pages via the preview server (dashboard, orders list + detail, capture wizard, customers, drivers, analytics, finance, notifications, account, settings hub, public tracking, sign-in, sign-up, bulk import, print labels). In parallel, a code-audit agent read every page file and identified confusion points — first-impression unclear, hidden affordances, jargon, missing labels, empty states without CTAs. Combined both passes into a single doc.
  • Output structure: Section 1 lists the 10 cross-cutting patterns that infect dozens of pages (jargon leaking into UI, invisible affordances, empty states without CTAs, identical-looking badges, label↔URL mismatch, native `confirm()` for destructive actions, icon double-duty, no autosave indicators, broken back navigation, stray N-avatar on public pages). Section 2 ranks per-page findings as Critical (6 entries — sign-in dead-end, marketing CTA pointing nowhere, driver fallback that sends drivers to a page they can't access, etc.), High (14 entries), Medium (12 entries), Low (3 entries). Section 3 is a 5-week ship-order that bundles the cross-cutting fixes first. Section 4 is the Jobs-test summary — 5 questions to run any new screen through.
5 changes

Status dropdown trimmed to 6 happy-path statuses + audit attribution

  • The status dropdown on the order detail page now shows only the 6 happy-path statuses an operator should ever pick directly: Pending, Preparing, Packed, Dispatched, Out for delivery, Delivered. The negative-path statuses are now strictly system-derived — operators reach them through dedicated actions that capture the why, not through a generic dropdown pick:
  • • Partially delivered ← `splitOrder` (operator defers part of an attempt) OR a partial POD by the driver.
  • • Delivered with issues ← `reportItemIssue` (operator records a problem) OR driver-reported issues during POD.
  • • Attempted ← driver app `submitAttempt` flow.
  • • Failed / Returned ← driver POD escalation.
  • • Cancelled ← dedicated Cancel order action (with reason + optional note).
  • Why: putting these on the dropdown let operators shortcut the real flow — pick 'Cancelled' without recording a reason, pick 'Delivered with issues' without recording the issue — and the audit trail lost the WHY. The validator stays free-form for internal callers (cancel / split / driver POD) that DO capture the reason, so the schema didn't have to change.
  • Operator label rename: 'Confirmed' is now displayed as 'Preparing' everywhere operators see it (dropdown, badge, orders table). Underlying DB enum value stays `confirmed` for compat with notification policies. Matches the customer-facing wording already shipped on the tracking page.
  • Audit attribution upgrade: every action that writes a system comment now stores the actor's user_id, and the activity feed resolves that to a name. Comments show 'Alice · 12 May, 12:30' or 'Driver Mike · 12 May, 12:30' on a dedicated row above the body. Driver attribution pulls from the drivers table (so dispatch sees 'Driver Mike' not the personal auth account name).
  • Comment bodies redesigned for clarity. Split order: `Order split — deferring 2× chuck to Tue 13 May. Remaining items still scheduled for today's delivery.` Item issue: bulleted per-item breakdown (`• 2× chuck: wrong cut — note: "wanted ribeye"`). Cancel: reason + optional note on separate lines. Driver POD issues: same bulleted per-item format as operator-flagged. Driver attempt failure: reason + optional note on separate lines. Multi-line bodies render correctly because the feed uses `white-space: pre-wrap`.

Dropdowns redesign — pill triggers + roomy rounded popovers

  • New visual language for every dropdown across the app, inspired by the modern native-select aesthetic (rounded pill trigger, soft inner-shadow, generous popover with rounded rows). The status pill on the order detail page is the headline example — it now reads as a tactile pill with the status icon, label, and chevron, opening a 16px-radius popover where each row is itself a pill that softly tints when selected.
  • Component-level: applied to StatusMenu, MoreOptionsMenu, line-item per-row actions, the account / user menu, and the mobile orders-page status/date filter pills. All four were previously bespoke inline-style menus that diverged in radius, padding, and hover treatment — now they share one CSS contract.
  • CSS surface: `.pill-select-trigger` for the closed pill, `.pill-select-menu` for the popover, `.pill-select-row` for each item (with `data-selected="true"` for the soft-tinted current pick), `.pill-select-section` for optgroup-style headers, `.pill-select-divider` for separators, and `select.pill-select-native` for native-`<select>` triggers on mobile (the OS picker still owns the open state). Z-index 1600 so the popover wins over the map + filter layers but stays under dialogs.
  • Mobile-specific: pill rows are 44px tall (Apple HIG), pill-select-native is 44px with a 16px font so iOS doesn't zoom on focus. The mobile status/date filter pills on /dashboard/orders got the rounded-full + inner-shadow treatment too.

Lifecycle: terminal-state reversals are now allowed too

  • Dropping the inner re-assert inside updateOrderStatus that fired when the operator picked "Delivered" — historically it ran a SYSTEM-table check against the derived status (delivered_with_issues / partial) to backstop the old strict manual table. Now that manual is free-form, the re-assert was the last remaining gate that occasionally threw on legitimate operator overrides — specifically when flipping a `cancelled` / `failed` / `returned` order back to delivered AND any item had an issue, the SYSTEM table's empty transitions from terminal states blocked the move.
  • Behaviour now: operator can flip between any statuses freely (Shopify-style). Driver / system / retry sources keep their strict transition tables — those are automated paths that can't walk an order backwards.

Tracking page: "Confirmed" → "Preparing your order"

  • The customer-facing tracking page used to label the post-pending step as "Confirmed". That's operator vocabulary — customers don't care about an internal confirmation state, they want to know someone is *doing something* with their order. Renamed across the customer surfaces:
  • Tracking page hero banner — now reads "Preparing your order" when the order is in `confirmed` state.
  • Tracking page step row — "Preparing your order · We're preparing your order for packing." (was "Confirmed · Your order is in the system.")
  • Customer email at the confirmed milestone — heading "We're preparing your order", subject "...is being prepared", body "we're preparing your order" (was "Your order is confirmed" / "we've got your order").
  • Tracking_event row written by `updateOrderStatus` when an order flips to `confirmed` — same friendly wording.
  • Operator-facing surfaces (orders table, status dropdown, badge, notification policy settings) still say "Confirmed" because that's the internal vocabulary operators reach for when scanning the dashboard. The change is strictly customer-facing.

Status dropdown — operator picks any status, free-form

  • The order status dropdown used to be governed by a strict state machine: from `packed` you could only move to a forward state (dispatched / out_for_delivery / etc.), from `delivered` you couldn't move anywhere, system-derived statuses (assigned, partially_delivered, delivered_with_issues) were hidden from operators entirely. The intent was to prevent accidental walking-backwards. The reality was that legitimate corrections — undoing a wrong cancel, reverting an incomplete pack back to confirmed, flipping a misclassified row to the right state — hit the validator and surfaced as opaque "Failed" toasts with no recovery path. The state machine was producing more friction than safety.
  • Reverted the manual table: any operator-driven status change is now allowed from any status to any other status. The dropdown shows every status except the current one, including the previously-hidden system-derived labels. The operator is a human in the loop with the order in front of them; trust them. (Same-status no-op is still a no-op; an invalid garbage from-status is still rejected.)
  • Critical: the driver-app POD flow, the system items[]-derived path, and the retry-reschedule path all keep their strict tables. Those are AUTOMATED sources that should never accidentally walk an order backwards or jump arbitrarily — only the manual operator dropdown is now free-form.
  • Test suite updated to lock the new behaviour in. 41 tests now cover the four sources: manual is permissive (positive coverage for rewinds, terminal-exits, system-only picks), driver/system/retry stay strict (full table coverage retained). Run via `npm run test:lifecycle`.
24 changes

Server-side audit — timeouts, notifications, tenant filter, races

  • Eight long-running transactions across the order + driver actions were running on Prisma's default 5-second timeout. Under any concurrent load (driver POD + operator status change in the same window, slow Frankfurt pooler, cold serverless instance) they'd sporadically hit the wall and throw P2024. Added `{ timeout: 15_000 }` to every one — updateProductionStatus, assignDriver, rescheduleAttempt, rescheduleDelivery, updateTimeWindow (flag-order-update), splitOrder, reportItemIssue, submitPOD. All match the 15s setting createOrder + updateOrderStatus already used.
  • Two driver-app code paths bypass `updateOrderStatus` and write the order's status field directly (submitPOD writes the derived delivered/with-issues/partial status; rescheduleAttempt writes "packed" for the retry). Both were silently missing the customer-facing notification dispatch that `updateOrderStatus` triggers automatically — operators reported customers "never got the Delivered email". Fixed: each path now calls `sendOrderNotification({ orderId, triggerEvent: "order.<status>" })` after its tx commits, best-effort (failures log but don't roll back the delivery).
  • Membership write hole: `removeMember` updated rows by `where: { id: membershipId }` with no tenant filter on the WRITE. The role check above gated it correctly today, but the Membership middleware doesn't filter `update` / `updateMany` writes by tenant, so any future refactor breaking the role check would silently leak. Switched the write to `updateMany` with an explicit `{ id, org_id: adminMembership.org_id }` filter so a UUID for a membership in another org can never flip is_active here.
  • Missing cache invalidations after member changes: `inviteMember` and `removeMember` previously only revalidated `/dashboard/settings/users`. Now they also revalidate `/dashboard/settings/organization` (member count) and `/dashboard` (workspace switcher in sidebar) so the change is visible everywhere after the next interaction. `assignDriverAction` now also revalidates `/driver` so a newly-assigned driver sees the order on their list without a hard refresh.
  • `assignDriver` (services/orders.ts) used to read `order` and `driver` OUTSIDE the transaction, then write inside — opening a race window where a concurrent operation could change either between read and write. Both reads moved inside the tx so the snapshot we validate against is the same one we mutate.
  • `updateStatusAction` + `rescheduleDeliveryAction` + `rescheduleAttemptAction` + `assignDriverAction` now use the structured `logActionError` helper for catch-block diagnostics — Vercel Function Logs get a single `[actionName] failed` payload with the inputs, the Prisma code, and a stack trace, in one indexable entry. Easier to grep when something fails than the universal `[safeError]` line.

P2024 connection-pool deadlock + actionable error codes everywhere

  • Real root cause of the "Failed — database rejected the request (code P2024)" toast: a connection-pool deadlock inside the Prisma middleware. Whenever an order action wrote a `tracking_event` (or `scan_event` / `proof_of_delivery`) inside a `$transaction`, the middleware's cross-tenant ownership check did a SECONDARY `client.order.findMany(...)` via the GLOBAL Prisma client — trying to acquire a second pooled connection while the current tx was still holding one. Under any concurrency (operator changing status + driver POD landing in the same window) the pool exhausted and P2024 fired. Wrapped every relevant `$transaction` in `withInternalWrites(...)` (7 sites total: updateOrderStatus + updateProductionStatus in services/orders.ts, plus rescheduleAttempt / rescheduleDelivery / split / report-issue / flag / cancel / submitPOD in the action layer). The wrap tells the middleware to skip its cross-tenant check inside trusted service code that's already verified ownership via `tx.order.findFirst`, so the second connection acquisition never happens. createOrder already used this pattern — the rest just hadn't been migrated.
  • Operator-facing error messages now distinguish Prisma error categories instead of dumping a raw code. P2024 "connection pool timeout" reads as "server busy (P2024). Try again in a few seconds." — telling the operator the right next step (retry) rather than "contact support". P2002 (unique conflict) reads as "that value already exists — pick a different one." P2025 (record not found) reads as "record not found — it may have been deleted by another user." The Prisma code stays in the message so support can correlate without leaking schema details.
  • New `logActionError(actionName, context, err, fallback)` helper in `safe-error.ts`. Drop-in replacement for the boilerplate `console.error + safeErrorMessage` pair that was repeated in every action's catch block. Logs the action name + input context + Prisma error code + stack trace in one structured payload (so Vercel Function Logs can index it), then returns the user-safe error message. Wired into updateStatusAction + rescheduleDeliveryAction so far; remaining actions still get coverage via safeErrorMessage's existing universal `[safeError]` log.

Cross-app mobile audit — every dashboard page padded + grids collapse

  • Comprehensive sweep across every dashboard route after the user flagged repeated edge-touching / overflow problems. Each page checked against the standard mobile primitives: MobilePageHeader at the top + .page-content wrapper for 14px horizontal padding + responsive grid collapses.
  • Eight pages now use the standard mobile chrome (were rendering raw `<h1>`s in `.fade-in` with zero mobile header and content touching the screen edges): /account, /orders/import, /orders/labels, /settings/depots, /settings/products, /settings/users, /settings/notifications, /settings/organization. All keep their desktop layout untouched — the new MobilePageHeader is `display: none` above 720px, and the desktop `<h1>` is wrapped in `.desktop-only`.
  • New `.stack-on-mobile` utility class that overrides inline `grid-template-columns` to a single column on phones (`!important` to beat the inline style). Applied to: the three side-by-side `1fr 1fr` Card pairs on analytics (Channels/Payment, Driver perf/Top customers, Cycle times/Customer mix), the Outstanding/Cash-up pair on finance, the cycle-times sub-grid, the funnel failures grid, the customer-mix split, the order-type split, AND the customer-detail KPIs (3-col → 1-col on mobile). One CSS rule, six call sites, zero refactors.
  • Driver-stops report table on /dashboard/drivers/[id]/stops was a 9-column table that on a 360px phone was wider than the viewport, with no scroll wrapper. Now wrapped in `.driver-stops-table-wrap` with `overflow-x: auto` and `min-width: 720px` on the inner table so it scrolls cleanly. The print stylesheet at the top of the file overrides both back to normal layout for paper.
  • The stops page's filter form (date inputs + Apply + Print) had no `flex-wrap` so on a narrow phone all five controls jammed into one row past the screen edge. Added `flex-wrap: wrap` so they stack neatly.

Scan-sheet leak + driver detail mobile layout

  • Opening the scanner from the orders tab used to leave the filter pill rendering on top of the camera view. Root cause: `.orders-filter-popover-root` sits at `z-index: 1500` so it can paint above Leaflet map controls, but the ScanSheet was at 200 — way below. Re-set the z-index hierarchy across the app: dock 90 → Leaflet map 200–1000 → orders filter popover 1500 → calendar popover 1600 → dialog overlay 1700 + dialog content 1701 → ScanSheet 1800 → toast 1900. The scanner now correctly sits above every page-level surface.
  • Driver detail page (per-driver view) was a mess on mobile: no header at all (the topbar was `desktop-only`), no `.page-content` wrapper so the stat cards touched the screen edges, and an inline `grid-template-columns: minmax(0, 1fr) minmax(0, 320px)` with no media-query collapse — on a 360px phone the 320px right rail dominated and squeezed the main column to ~25px, causing the "overlapping sections" the user reported. Three fixes: added a MobilePageHeader (driver name + back to /drivers + Available/Off-duty subtitle), wrapped the body in `.page-content` for the standard 14px horizontal padding, and extracted the layout grid into a new `.driver-detail-grid` class that collapses to a single column at ≤720px.

Customer reviews surface + dock no longer eats dialog buttons

  • The dock used to paint over the bottom-right corner of dialog bottom sheets on mobile — Cancel and Confirm buttons inside Reschedule delivery (and a few other flows) were unreachable. Root cause: Radix dialogs default to z-index: 50 while the dock sits at 90, so the dock won the paint order. Fixed two ways for defence in depth: the dock is now hidden whenever any dialog is open (via `body:has([role="dialog"][data-state="open"]) .dock { display: none }`), and the dialog's overlay + content z-index were raised to 200/201 so even on a browser without `:has()` support the dialog still wins.
  • Customer ratings now surface on the order detail page. The Customer card carries a new "Rating" line — shows the star rating for THIS order if the customer left one, or "No reviews" otherwise. Tapping the line opens a new per-customer reviews page (`/dashboard/customers/[id]/reviews`) that lists every order this customer has rated, with the stars, written feedback, dates, and a link back to each order. Average-stars summary in the page header. Empty state explains where reviews come from (the tracking-link rating form).
  • Confirmed (no code change needed): the audit-trail note operators type in the Reschedule delivery dialog is already written to `order_comments` as a system comment and renders in the order's Activity feed. So "why was this order moved?" stays answerable from the order detail page.

Order detail: end the horizontal-overflow plague

  • Real root cause this time: a `min-width: 0` cascade was missing from `.admin-main` down through `.order-detail-wrap` / `.order-detail-grid` / `.order-detail-top-cards` and into the `DetailRow` value `<span>`. A grid/flex item's default min-width is `auto` (= its intrinsic min-content width), so an unbreakable string like a phone number or street address forced every ancestor in the chain to stretch to that string's width. The shell's `overflow-x: clip` was hiding the visible overflow but the LAYOUT stayed wider than the viewport — that's why the page had an inset scrollbar and the right edge of every customer value was cut off. Fixed by adding `min-width: 0` to every grid/flex parent in the chain at the mobile breakpoint, AND `overflow-wrap: anywhere` + `word-break: break-word` on the DetailRow value so the parser actually has something to break.
  • Items card row used fixed `width: 90px` + `width: 100px` columns for qty and price — combined with the icon + name column + actions menu, that pushed past 380px-wide phones. Converted both cells to `flex: 0 0 auto` so they shrink to their natural content width.
  • Action row redesigned from the user's feedback: "Cancel order button is way too big, Mark packed is too big, possibly just remove that black button, can you move council order to the more section". Done: every action is now a small pill (`.btn-sm` sizing), all sharing the same height. Primary CTA carries the accent colour but no longer dominates as a full-width 44px block. Cancel order moved INTO the More menu as a danger-tinted item under a separator — destructive action stays discoverable but doesn't carry a giant red pill on the page by default. CancelOrderDialog gained an optional controlled-mode (open + onOpenChange props) so the More menu can drive it.

Order detail header — mobile redesign

  • Action row on the order detail page was the most cramped surface on mobile — "Mark packed", "Status", "Mark invoiced", "More", and "Cancel order" all sat in a single flex-wrap row with different intrinsic widths, jostling each other into an uneven 5-tile grid.
  • Now grouped into three semantic clusters: primary (the suggested next step, e.g. "Mark packed") sits full-width at the top with a 44px tap target. Secondary cluster (Status / Mark invoiced / More) drops into a 3-up equal-width grid below, with truncation on long status labels. Destructive (Cancel order) lives in its own full-width red row at the bottom so it's visually separated from the everyday actions. Desktop still sees the original inline flex-wrap row — only the mobile layout changed.
  • Tracking row compacted: the visible `/track/` prefix is hidden on mobile, leaving just the tracking code (e.g. `U6Y7RKPHP`). The link still points at the full path and the Copy button still copies the full URL — only the visible label is shorter, so the row stops sprawling onto two lines.
  • Duplicate order ref hidden on mobile: the big `BO-00059` h1 used to render right below the same ref in the MobilePageHeader. Removed the h1 on mobile (desktop unchanged) so the status pills sit cleanly on their own row without competing for attention.

Home-screen icon + bolder wordmark

  • Add to Home Screen now lands the Odro mark — green-tile / white-ring / rotated pill — instead of a generic "O" silhouette. Previous icon had hairline strokes that blended into the outer ring at home-screen sizes (60-180px), so the pill detail was invisible and the mark just read as a circle. The apple-icon, the favicon, and the inline Logo SVG all picked up thicker strokes; the mark inside the apple-icon tile also grew from 80px to 130px so it actually fills its space.
  • Wordmark moved from Instrument Serif 400 (the only weight that exists for that family) to Fraunces 600 — a variable display serif that gives "Odro" real bold weight without losing the elegant serif feel. The mark-to-wordmark size ratio tightened from 1.6× to 1.3× so the icon now reads as an equal partner next to the bolder text, not a small flourish.
  • Added a dedicated `/icon-maskable.svg` for Android adaptive icons. The OS applies a circle / squircle / rounded-square mask over launcher icons, cropping the outer 20% — the maskable variant lives in the centre 60% safe zone so the brand survives every mask shape on every launcher.

Money + date labels never split across lines anymore

  • Fixed at the source: `formatMoney` now uses a non-breaking space between the currency symbol and the amount. Same for `formatDate` and `formatDateTime`. "R 50.00" and "10 Jun 2026" stay atomic everywhere — payment panels, order summaries, line items, totalbars, the lot — instead of wrapping into "R" + "50.00" or "10" + "Jun" + "2026" when they land in a tight flex column.
  • Payment panel header now wraps its action buttons ("Send reminder", "Record payment") to a second row on narrow widths instead of squeezing the summary text. The screenshot of "Paid R / 0.00 of R / 50.00 · R / 50.00 due / · due 10 / Jun 2026" was the trigger.
  • Items card header on the order detail page got the same flex-wrap treatment as a defensive measure — long delivered/pending counts can no longer push the order total off the row.

Installable as an app + mobile improvement round

  • Odro is now installable as a Progressive Web App on iOS and Android. iOS: Add to Home Screen launches fullscreen (no Safari URL bar) with a translucent status bar; the home-screen label reads "Odro". Android Chrome / Samsung Internet: the standard "Install app" prompt shows automatically when the browser sees the manifest + active service worker. Standalone launch starts on /dashboard, with a navigation-only network-first cache so the shell still works on a flaky connection. The driver page's offline behaviour is preserved.
  • Action sheet "Customer" now goes to the customers directory instead of a 404 (there's no standalone /dashboard/customers/new — new customers are captured through the order wizard, so the directory is the right destination).
  • Driver dock icon swapped from the generic users glyph to a truck — reads as "fleet / vehicles" rather than "contacts".
  • Drivers page on mobile now uses the same 14px page padding as the orders + customers pages. Fleet stat cards no longer touch the screen edges.
  • Day-strip on the orders page (mobile): the standalone "May" month label is gone, the date pill is smaller (32px tall, 12.5px font), and the head row has 14px horizontal padding so the pill never butts up against the screen's right edge.
  • Orders search bar placeholder reads "Search" again. The full "ref, tracking, name, phone, email, address" hint was useful documentation but visually noisy — the wider matching is still in effect.

Reschedule delivery — any order, any day

  • New "Reschedule delivery" entry in the More menu on every non-terminal order. Moves the order to a different day (and optionally a different driver) without touching status or attempt count — so a planned-for-tomorrow drop you decide to push to Saturday isn't recorded as a failed attempt. The original tracking code and customer link stay intact; a timeline event tells the customer their delivery was rescheduled.
  • If the order has a planned follow-up (from a previous "Deliver in two parts"), the carryover date gets shifted by the same delta automatically so the split stays coherent — you don't end up with a follow-up date earlier than the parent delivery.
  • Distinct from the existing "Reschedule attempt" CTA that appears on failed/attempted orders — that one bumps `attempt_count` and routes through the retry transition table. This is the everyday "move it to another day" action.
  • The split-order dialog's "all items are being deferred" rejection now points operators directly at the new Reschedule action — previously the error told you what was wrong without telling you what to do next.

Collection orders + wider search + plain-English split + iOS-zoom fix

  • Mobile wizard now supports collection orders. Step 4 leads with a Delivery / Collection toggle; picking Collection drops the per-order address field (since the customer is coming to the depot) and rewords the step's headline. Server contract unchanged — orderType already exists on `createOrderAction` from the desktop flow.
  • Wider orders search. The /dashboard/orders search bar now matches: internal ref, tracking code, barcode value, customer name, raw phone, e164 phone (digits-only fallback so "0821234567" still finds "+27821234567"), email, delivery address, AND the related customer's business name. Placeholder updated to hint at the new capabilities.
  • Renamed the "Split for partial fulfilment" action to "Deliver in two parts" across the More menu, the dialog title, and the description. Plain English — operators don't have to mentally parse "fulfilment" anymore.
  • iOS Safari auto-zoom on wizard inputs is gone. Every text input that was below 16px (item name, price, qty stepper, small variant) is now at the 16px floor that stops the focus-zoom, so tapping a field no longer punches the page into a zoomed-in state.
  • Defensive overflow guards on the time-window picker and native `<input type="date">` / `<input type="time">` so the wizard body can't pick up a one-pixel horizontal overflow from a long locale-formatted date.
  • Status-change error toast now surfaces the actual reason as the toast TITLE (e.g. "Can't move fulfillment status from packed to delivered") instead of a generic "Failed". The attempted status moves to the toast's description as "Tried: …" so the operator always sees both pieces.

Status change: SYSTEM-derive gap + diagnostics

  • Found a second status-change failure path beyond the carryover fix earlier today. When an operator picks "Delivered" on a `packed` or `assigned` row that has at least one driver-reported real issue, `updateOrderStatus` fills in the remaining `items[].deliveries[]` entries and re-derives the final status via `deriveOrderStatus`. That derive resolves to `delivered_with_issues`, which then has to pass an inner SYSTEM-source re-assert. The SYSTEM_TRANSITIONS table didn't include `delivered_with_issues` as a target from `packed` / `assigned`, so the re-assert threw — surfacing to the operator as a generic "Failed" toast.
  • Fixed by extending SYSTEM_TRANSITIONS so the derive path can land on `delivered`, `delivered_with_issues`, or `partially_delivered` from `packed` / `assigned`. The MANUAL transition table still rejects those targets when an operator picks them directly from the dropdown (system-only statuses stay system-only), so the existing guardrails are intact. Five new regression tests lock the fix in (59/59 passing, up from 54).
  • Added server-side diagnostic logging to `updateStatusAction` — every failed status change now writes `[updateStatusAction] failed` with `orderId`, `requestedStatus`, error message, and stack to the Vercel function logs. Previously a Prisma error in the transaction surfaced as just "Failed" with no breadcrumb.
  • Improved the user-facing toast for Prisma errors: instead of a bare "Failed" it now reads, e.g., "Failed — database rejected the request (code P2002). Try again or contact support." Schema-specific details (column names, constraint identifiers) still stay server-side.

Sharper scan camera — admin + driver

  • The scan sheet on both the admin dock and the driver app was starting the camera with `{ facingMode: "environment" }` and nothing else. Under that bare constraint the browser defaults to whatever resolution it likes — usually about 640×480 — and our `object-fit: cover` on the video element then scales it up to fill the screen, producing the soft pixelated image users were calling out.
  • Now requesting an `ideal` 1920×1080 stream at 24 fps with continuous autofocus. `ideal` constraints are soft preferences, so devices that can't hit those numbers gracefully degrade rather than throw, and the non-standard `focusMode: continuous` entry sits inside `advanced[]` where browsers without support just ignore it. Detection rate bumped from 12 → 15 fps to keep pace with the sharper frame.

Industries panel: equal-height columns + photo slot

  • The vertical industries list on the left now stretches to the same height as the right detail panel. Wrapped the list + a new photo placeholder in a single sidebar container; the placeholder is `flex: 1` so it absorbs whatever vertical space the detail copy implies, keeping both columns aligned to the same bottom edge regardless of how tall the right panel grows. The slot is styled as an empty photo frame (soft hatched gradient + a small image-mark) so it reads as "intended for a photo" rather than empty space — drop an <img> into `ind-sidebar-photo` when a real shot is ready.
  • On tablet/mobile the sidebar wrapper drops to `display: contents` and the photo slot hides, so the existing pills-above-detail layout keeps working unchanged.

Landing: scroll-tracker rail removed

  • The right-edge order-status icon chain that advanced through the landing-page sections has been removed — visually it was competing with the cosmos hero and pricing layout without earning its place, and the alignment between the dots and connecting lines wasn't reading cleanly. Pricing card stack + flip animation and the proximity scroll-snap on sections are preserved.

End-of-day audit + payment stale-value patch

  • Full pre-deploy audit on today's mobile + landing work. Status change end-to-end verified: operator-initiated "Delivered" on an order with a planned carryover now clears `next_attempt_date`, lets the auto-derive run, and records "Planned carryover cancelled" in the audit comment trail. Driver-side `submitPOD` path is untouched. Manual transitions table still allows pending → confirmed → packed → dispatched → delivered the way it always did.
  • Mobile wizard payload audited field-by-field against the `createOrderAction` zod schema — no missing or stale fields. The ScrollTracker client component has zero SSR risk (every `document` access lives inside `useEffect`, and all five section selectors resolve on the landing page). Dock restructure is clean: scan renders as a button (action, not navigation), the action sheet's old `onOpenScan` prop is fully removed, and notifications are wired in the new location.
  • Patched a small payment-mode flag from the audit: the mobile wizard's `payDueDate` field now clears whenever the operator switches to Fully paid or Partial. Previously a due-date typed in "Due later" mode would persist in state and ride along in the `createOrderAction` payload if the operator changed their mind. Service-side validation already ignored it in the wrong mode, so no actual orders were affected.
  • Production `next build` exits with zero warnings and zero errors. `tsc --noEmit` clean. 54 of 54 lifecycle tests still green.

Landing: scroll-tracker rail + tighter pricing + flipping cards

  • New right-rail scroll tracker on desktop. A vertical chain of order-status icons (Order received → Packed → Dispatched → Out for delivery → Delivered) pinned to the right edge of the viewport; as the visitor scrolls through the landing-page sections, past stages turn into solid green ticks, the current stage pops with the section's icon, and future stages stay dim. IntersectionObserver watches a 20% band 30% from the top of the viewport — whatever section is crossing the band is "current". Hidden on phones (< 980px).
  • Proximity scroll-snap on the landing-page sections (desktop only). The page now "kind of locks" into each section as you scroll past — but only when you're already near a snap boundary, so smooth fast scrolls still flow through naturally. No slideshow / auto-advance behaviour. Honours `prefers-reduced-motion`.
  • Pricing card stack is visibly stacked now. The inactive tier peeks past the active card's lower-right corner with a small tilt — both cards stay in the DOM, and clicking the tab switcher animates the two cards trading places (transform + opacity transition rather than the old crossfade). Reads as "flip between two options" rather than "swap card slot".
  • Pricing section padding trimmed ~25% (vertical) and the column gap reduced — the previous spacing left a sparse blank gap between the card stack and the right column. The deck still has room to breathe but the section now feels more focused.

Problems-first dashboard + dock pivot + calendar badge

  • Mobile homepage now leads with the attention panel — overdue orders, on-hold production, failed attempts, payment-overdue and flagged orders surface above the activity feed. The activity card's 560-pixel inner scroll container has been removed (it was trapping thumb-swipes on phones, so dragging inside it scrolled events instead of the page). Desktop layout is unchanged: activity stays on the wider left column, attention on the right rail.
  • Dock pivot: Scan replaces Notifications on the main glass pill — operators hit scan dozens of times a day for POD/label lookup, so it deserves the single-tap home. Notifications moves into the + action sheet alongside Customer / Payments / Settings. The action sheet dropped Production / Directory / Labels (operators reach those from their respective detail pages or search; they didn't earn a slot in the dock's quick menu). Fresh `scan_line` icon — corner-brackets with a horizontal scanning line — is distinct from the QR-pattern icon used elsewhere.
  • Calendar attention badge moved from the top-right corner to in-flow BELOW the date number, so it no longer overlaps the day digit on tightly-spaced cells. Dots row hides when a badge is shown to avoid stacking two redundant signals.
  • Day-strip overdue chips no longer pulse. The static red boxshadow + the count badge in the corner is enough — the previous animation made the strip feel jumpy and jittered the eye even when nothing was wrong. The "Past window" pill on order rows also lost its pulse for the same reason.
  • Wizard step-4 day pills now use a 7-column CSS grid (`minmax(0, 1fr)`) instead of fixed 56-pixel flex basis, so all seven days fit cleanly on iPhone SE width — no more right-edge cut-off when the operator opens delivery scheduling.
  • Document-level horizontal pan lock for every mobile dashboard page (`html, body { overflow-x: hidden }` + `touch-action: pan-y` on `.admin-shell` and `.owm-shell`). The whole app should now feel locked to vertical scrolling on phones — components that need horizontal scrolling opt back in explicitly.

Mobile wizard lock-in + status-change regression

  • Status change failure resolved. Marking an order delivered after a split had been planned (next_attempt_date populated) was throwing a hard server error, which surfaced as a generic "Failed" toast with no recovery path. The operator-initiated path now honours the override: the carryover is cleared, the auto-derive runs, and the audit trail records that the planned re-attempt was cancelled. Driver-side and item-derive flows are untouched.
  • Mobile wizard no longer pans sideways. `.owm-shell` gets `touch-action: pan-y` + `overflow-x: clip` plus an `html/body:has(.owm-shell) { overflow-x: hidden }` at the document level — the wizard is now locked vertically, with the day-pill scroll row on step 4 explicitly opting back into horizontal gestures so it still scrubs.
  • Review-screen tile bleed fixed. Summary rows pick up `min-width: 0` + `overflow-wrap: anywhere` + a non-shrinking label/price column, so long addresses and item names wrap mid-word instead of pushing the Edit icon off the right edge.
  • New-order entry point consolidated on the floating dock. Removed the duplicate inline `+ New` action from the mobile page header on the dashboard home and the orders list — the dock's full-width "New order" hero row now owns that target.

Mobile order wizard — full desktop parity

  • Time of day: the 3-pill Morning/Afternoon/Evening picker has been replaced with the desktop's 4-mode window contract (Any / Before / After / Between) plus inline HH:MM inputs. The legacy slot value was never even being sent to the server — orders captured on mobile now carry the same `timeWindowStart` / `timeWindowEnd` pair as desktop, so the overdue flag and dispatch planner work identically across both flows.
  • Priority pills (Normal / High / Urgent) added to step 4 — same 0/1/2 value contract the queue ordering, day-strip attention badges, and downstream services already rely on. Coloured dots so urgency reads at a glance.
  • Payment expanded to match desktop: Due later / Fully paid / Partial, with amount + method (Cash / EFT / Card / Other) fields revealed when paid or partial. Amount auto-fills to the order total on Fully paid; Due later gets an optional due-date input. Previously mobile shipped a simplified unpaid/paid/on_account toggle that lost amount and method.
  • Customer email field added to the New customer form (was missing entirely — operators capturing a new customer on phone lost the email).
  • Per-order delivery address now appears as a dedicated field on step 4, pre-filled from the customer's stored default via a `useEffect`. Mirrors desktop semantics so a one-off drop doesn't have to overwrite the customer profile.
  • Picked customer feedback: when an existing customer is attached to the draft, the search input + hits list swap out for a green-accented confirmation card showing name, business tag, phone, email, default address, and an explicit Change button. The previous flow filled the search box with the customer's name and quietly cleared the list — operators couldn't tell if they'd actually selected someone.
  • Step 4 right-edge bleed fixed (`.owm-body` gets `overflow-x: clip` + `min-width: 0` on its children, so the day-pill scroll-row's negative margin can't push the page past the viewport). Item-row gets `flex-wrap`; segmented buttons get `min-width: 0` + ellipsis truncation so the 3-mode payment row fits on iPhone SE-width screens.

Time-window violation flag — surfaces orders past their cutoff

  • Orders with a `time_window_end` set get a pulsing red "Past window" pill on both the desktop table row and the mobile card the moment the cutoff passes without delivery (and the order isn't already terminal). Cutoff is anchored on the *effective* day (`next_attempt_date` if the operator has rolled the order forward, otherwise `delivery_date`), so yesterday's misses land in yesterday's bucket and today's in today's — no false positives on orders that have already been rescheduled.
  • Day-strip + month-calendar attention rollup now includes overdue alongside issues, partials, and carryovers. A day with even one overdue order pulses red in the strip; the month-calendar's corner badge picks the right tone (issue red vs. soft amber) from the worst signal of the bunch.
  • Single SQL pass: `COUNT(*) FILTER (WHERE effective_day::timestamp + time_window_end::time < NOW() AND status NOT IN ...)` over the same 17-day window the strip already queries. Per-row `is_overdue` computed in JS using the same effective-day so the count and the pill agree exactly.

Mobile dock + day-strip + tracking timeline improvements

  • Mobile dock redesign: replaced the old 5-tab inline bar with a floating dark glass pill (Today / Orders / Notifications / Drivers) plus a separate circular + button. Tapping + opens a frosted action sheet — a full-width "New order" hero row at the top, then a 4-up grid of secondary actions (Scan, Customer, Directory, Production, Labels, Payments, Settings). Liquid-glass entry animation: scale-from-button + opacity fade + backdrop-blur ramp.
  • Orders day-strip slims down dramatically on phones: at ≤720px the 7-chip row hides entirely. The month-calendar trigger becomes a labelled "current date" pill (Today / Tomorrow / Mon 11 May) that spans the right half of the head row — tap to open the same picker desktop uses. Reclaims a chunk of vertical space without removing functionality.
  • Customer tracking timeline now renders a final failure / with-notes step when the order ends in attempted / failed / returned / cancelled / partially_delivered / delivered_with_issues — with the appropriate icon (alert / warning / rotate / close / clock / check-double) and tone (amber for soft outcomes, desaturated red-amber for hard failures). The rail leading into the failure step fades into the failure tone. No raw red on the customer view — the hero block sets urgency, the step clarifies what happened.
  • Added the missing `alert` / `rotate_left` / `card` icons to the Icon component (the new timeline + dock action grid were silently rendering empty `<span>`s before).
  • Wizard suppression now hides both the legacy `.bottom-nav` and the new `.dock` when the mobile order wizard is mounted (`body:has(.owm-shell)`), so the wizard owns the viewport.

Landing: pricing switcher, attention badges, mobile sign-in

  • New Jitter-style pricing switcher on the landing page — card stack on the left (active tier on top, the other peeks scaled-down behind), pill tab switcher + line-item summary + CTA on the right. Card flip animates each time you toggle tiers.
  • Day-strip + month calendar gained attention badges. Days with real issues get a red numbered pill in the corner + tinted background; days with carryovers or partials get the same in amber. Lets management scan a strip / month grid and immediately spot problem days.
  • Added a new `splits` count to the per-day query — orders that rolled INTO a day from a prior split (next_attempt_date set, distinct from delivery_date). Powers the new carryover signal.
  • Pricing section dropped the "Your workspace" decorative pill from the Jitter reference (irrelevant on a marketing page where the visitor doesn't have a workspace yet).
  • "Book an intro" CTAs route to a placeholder `/book-an-intro` page until the real booking flow is wired up. One-line swap when Calendly/HubSpot is decided.
  • Mobile nav restored the "Sign in" text link alongside "Book an intro" — returning visitors can hop into the dashboard without scrolling to the footer.
2 changes

Orders: day-strip + month calendar + toast restyle

  • Horizontal 7-day strip at the top of /dashboard/orders — every chip shows day-of-week, big date number, and an order count with a tone-coloured dot. Selected day fills, today rings. Click any chip to jump-filter to that day.
  • Month-grid calendar dropdown opens from the calendar icon at the end of the strip. Up to 3 coloured dots per day (red issue / amber partial / green delivered / grey pending). Today + prev/next month + count pill in the header. Click any date to navigate.
  • Single raw-SQL `groupBy` over a ±17-day window coalesces split-deferred orders into their `next_attempt_date`, so a carryover surfaces on the day it's planned for. One round-trip feeds both strip and calendar.
  • URL gets a single-day shortcut: `?date=YYYY-MM-DD` works directly now (no more `?date=custom&from=...&to=...`).
  • Toast restyle: circular tone-coloured icon disk on the left, title + description stacked, explicit X dismiss button on the right. Click-anywhere-to-dismiss was undiscoverable; now the close is a clear affordance.
  • Calendar dropdown z-index bumped to sit above the orders filter wrapper — was rendering behind the Filter pill.

Order lifecycle hardening + transition tests

  • New `src/lib/order-lifecycle.ts` — four transition tables keyed by source (manual / driver / system / retry). SYSTEM_ONLY statuses (`assigned`, `partially_delivered`, `delivered_with_issues`) can't be set from the manual dropdown; manual can fast-forward but not rewind a terminal state.
  • `updateOrderStatus` + `submitPOD` now read + assert + write all inside a single Prisma `$transaction`. Previously the read was outside the tx and a concurrent commit could let an invalid transition slip past the guard.
  • Auto-derive path got a belt-and-suspenders re-assert: when an operator's `delivered` request promotes to `delivered_with_issues`, the derived status is re-checked with `source: "system"` so the manual table's restrictions can't be silently bypassed.
  • Production status got its own transition graph + guard (`assertCanTransitionProductionStatus`). `ready → pending` is now forbidden — no walking work backwards from the kitchen.
  • Operator's "Mark delivered" now writes `items[].deliveries[]` for everything still owed and re-derives, so analytics that reduce over items always agree with the status field. Refuses when there's a planned carryover (must deliver or cancel the split first).
  • Driver app: `Mark as failed attempt` button only shows for valid statuses; carryover (`partially_delivered`) orders can be re-dispatched.
  • New `rescheduleAttemptAction` + dialog so operators can move an attempted order to a new date + driver, bumping `attempt_count` and resetting status to `packed`.
  • Lifecycle test suite — 54 tests via Node's built-in test runner (no new deps). Locks down transition rules, terminal invariants, retry-table narrowing, production-axis guards.
4 changes

Landing rework — cosmos hero + syspro-style industries

  • New cosmos.so-style hero — three concentric rings of order tiles orbit around the headline, with depth-of-field blur (outer sharp, middle blurred, inner ghosted). Single-parent rotation + per-tile counter-rotation keeps the orientation locked while the constellation spins.
  • Floating pill nav (ada.cx style) replacing the full-width fixed bar — sits above content with margin from the edges, fully rounded ends, soft drop shadow.
  • New Industries section with an interactive list-on-left, hero-on-right panel — six verticals (bakeries, butchers, grocers, meal kits, lifestyle, florists). Each has its own gradient and operator-targeted copy. Modelled on syspro.com.
  • "Why Odro" section replaces the old 9-card features grid with three confident claims: proof chain, native stack, flat fee.
  • Mobile redesign — short-and-sweet flow, 3-column footer, bigger pricing card, horizontal-scroll industry pills.
  • Hero metrics rewritten for SaaS positioning: 100+ daily deliveries, 4× scans per drop, <60s order to driver, 0 app installs.
  • Single "Book an intro" CTA across nav, hero, pricing, and footer — replaces the old free-trial CTA.

Multi-attempt orders

  • Orders that fail on the first run can now be rescheduled — pick a new date, optionally swap drivers, and the original attempt's history (scans, photos, dispatcher notes) is preserved on the new slot.
  • Driver app shows attempt history on the deliver screen — what was tried, when, and why each previous run failed.
  • Future-attempt callout pinned to the top of the order detail page so dispatch knows to expect the second run.
  • Tightened the multi-attempt callout trigger so it doesn't fire on fresh orders that just happen to have a future date.
  • New /dashboard/drivers/[id]/stops route — printable manifest of today's planned stops for each driver.

Legal pages overhaul + changelog grouping

  • Privacy + Terms pages rebuilt with a sticky table-of-contents on desktop, anchor-linkable section IDs, section-number badges, and a print stylesheet that strips chrome and reveals every link's URL for hard-copy review.
  • New "Data Processing Addendum" section in Privacy and a dedicated "Uptime & availability", "Publicity & references" section in Terms.
  • Changelog days now collapse — the most recent two expand by default, older days collapse so the page stays scannable.

Rate limiting + geocoding + map improvements

  • 60-requests-per-IP-per-minute rate limit on /track/* and authentication endpoints — closes the brute-force / scraping vector flagged in the security audit.
  • Server-side geocoding helper (src/lib/geocode.ts) replacing per-page client-side calls — same Cape Town suburb gazetteer, now hit once per address rather than once per page load.
  • POD PDF + label route fixes for edge cases that were emitting blank pages on multi-attempt orders.
  • Map view improvements on the orders page — cluster handling, stale-data dismissal, smoother re-pan when the manifest changes.
11 changes

Docs, changelog, and bulletproof legal pages

  • Public Docs portal at /docs — Notion-style sidebar nav with 25+ guides covering onboarding, orders, customers, drivers, the mobile app, the customer tracking page, settings, and billing. Cmd-K to search.
  • Public Changelog at /changelog (you're reading it).
  • Comprehensive Terms of Use + Privacy Policy at /legal/terms and /legal/privacy — drafted to be defensible in every jurisdiction we operate in (POPIA, GDPR, UK GDPR, CCPA/CPRA).
  • All four pages are linked from the landing-page footer.

Sign-up: live password ticks, confirm field, no data loss on error

  • Live password requirements checklist appears below the password field — each rule (length, lowercase, uppercase, number, special character) flips from grey to green as you type.
  • New "Confirm password" field with a matching tick. Submit is disabled until everything passes.
  • If the server rejects the password, your name / business / email stay on screen — no more retyping the whole form.
  • Server-side rules mirror the client checks so a hand-rolled POST hits the same friendly errors.

Onboarding hardening

  • Race-safe slug allocation — two concurrent sign-ups with the same business name no longer collide.
  • Email lower-cased + trimmed before being stored, so case-mismatch can't break the sign-in lookup.
  • Email-confirmation flow handled — when Supabase Auth requires email confirmation, sign-up returns "Account created — check your inbox" instead of silently failing.
  • Orphan recovery — a Supabase auth user with no Odro workspace is now signed out cleanly instead of being stuck in a redirect loop.
  • Default order ref prefix is now OD (was DF, leftover from the old DeliverFlow brand).

Customer ratings + flag-for-issue

  • After an order is delivered, the public tracking page now invites the recipient to rate their delivery 1–5 stars and leave written feedback. Ratings are one-shot — once submitted, they can't silently change.
  • New "Flag for issue" option in the More menu of the order detail page. Pick a reason (wrong cut, customer complaint, payment dispute, etc.), add a note, and the order surfaces in the dashboard's Needs attention panel for management to resolve.
  • Mark invoiced moved out of the More menu and back into a visible pill — it's a daily action.

Driver detail page with stats

  • New /dashboard/drivers/[id] route with weekly / monthly / custom stat cards (delivered count, success rate, per-day average, value delivered + lifetime).
  • Driver records gained name, phone, email, work-schedule (Mon-Sun grid editor), and notes fields.
  • Drivers list now shows the person's name first, with vehicle reg as the secondary line.

Customer tracking page redesign

  • Hero is now solid forest green with white text — replaces the previous brand-colour (sometimes red) treatment that felt off.
  • "Driver assigned" step removed from the customer-visible timeline; the internal status maps onto the existing Dispatched step.
  • Smoother single-ring pulse animation on the current step (was double-stacked, looked janky).

Sign-in / sign-up redesign

  • Mobbin-style pill inputs with floating labels — clean black-on-white, no more Chrome blue autofill tint.
  • Show / hide password eye toggle.
  • Forgot-password link → /auth/forgot, which sends a Supabase password-reset email (response is the same regardless of whether the email exists, so attackers can't enumerate accounts).

Scalability & security hardening

  • Dropped two duplicate orders indexes (orders_org_paymentstatus_idx, orders_org_status_idx) — wasted disk + slower writes for no benefit.
  • Added 5 missing FK indexes (orders.connector_id, customer_id, depot_id, proof_of_delivery.driver_id, vehicle_checks.org_id) plus a partial index for the open-flags filter.
  • Pinned `search_path = public, pg_temp` on 8 SQL functions including the JWT access-token hook — closes a search-path-injection vector flagged by Supabase advisor.
  • Collapsed the two users SELECT policies into a single OR'd policy — halves the per-row policy cost.

Settings: workspaces, products, users

  • Workspaces tab removed from settings; multi-org switcher relocated to Settings → Organisation.
  • Logo URL field removed from organisation settings.
  • Role pills colour-coded throughout (owner violet, admin blue, manager green, dispatcher neutral, production amber, driver blue, viewer grey).
  • Trash-user icon bumped from 12px to 16px in a 36px hit target with danger-tinted hover.
  • Invite dialog rebuilt as a 6-card role picker with permission previews per role.
  • Products: CSV bulk import (paste or file), per-row validation, SKU upsert, downloadable template.

Customer notes + line-item notes on driver app

  • Persistent customer-level notes (gate codes, dog warnings) now appear at the top of every order on the driver app.
  • Per-line-item notes ("vac-pack", "skin-on", "no bone") appear in-line with each item.
  • Today's list shows a Note badge on cards that have notes — the driver sees at a glance there's something extra to read.
  • Wizard surfaces a discoverable "+ Add note" chip on each line — no more hunting in a 3-dot menu.

Odro rebrand + new landing page

  • DeliverFlow is now Odro — new logo, new wordmark (Instrument Serif), new domain (odro.ai).
  • First version of the new landing page (since reworked, see 8 May).
  • Tab title is just "Odro" — no marketing tagline.
  • Favicon + apple-touch-icon match the brand mark.
7 changes

Intercom-flavoured design pass (then reverted)

  • Tried a warm-palette / illustrated direction inspired by Intercom — got close but landed too consumer-feeling for a B2B operations tool. Reverted same-day.
  • Lessons absorbed into the subsequent rebrand: forest green is louder, the serif headline carries more brand than the warm tints did.

Layout bugs surfaced post-design-pass

  • Sidebar collapsed-state restored its width on hover instead of staying collapsed.
  • Topbar centring drifted when org names exceeded the truncation width.
  • Order-detail timeline lost its line connector on the last step.

Onfleet-pattern dashboard surfaces

  • Sidebar got a collapsible chevron + remembers its state across sessions.
  • Topbar centred on the active workspace, with a quick-switcher when you have more than one.
  • Surface tokens (surface-1 / surface-sunken / surface-raised) standardised across dashboard pages.

4-step new-order wizard for desktop

  • Customer search → item picker → delivery scheduling → review. Replaces the cramped single-page form.
  • Each step has its own URL hash so a refresh doesn't lose state.
  • Item picker handles SKU lookup, custom items, and per-line notes in one screen.

Driver app improvements

  • Scan sheet uses a square frame — no more letterboxing on phones with non-standard aspect ratios.
  • Cleaner top hint copy on the scan screen (was over-explaining).
  • Floating mobile nav, black-pill order CTA, removed flashing live badges that were causing visual fatigue mid-shift.

Vercel performance pass

  • Pinned the deployment region to fra1 — closer to the SA majority of users than the default us-east.
  • Enabled router-cache stale-while-revalidate times so back-navigation is instant.
  • Sidebar links now prefetch on hover, smoothing dashboard navigation.

DeliverFlow MVP — initial commit

  • 4-step desktop new-order wizard with customer search, item picker, delivery scheduling, and review.
  • Map view on the orders page (Leaflet + Cape Town suburb gazetteer).
  • Time-window editor on the order detail page.
  • Cancel order with reason capture, split for partial fulfilment, customer timeline.
  • Driver mobile web app — today's stop list, scan-to-deliver flow, attempt-failed flow, vehicle check, history.
  • Customer tracking page with realtime updates.
  • Notifications engine with policy-driven email + SMS triggers.
  • Multi-tenant isolation: row-level security on every table, JWT-claim-based org scoping, Prisma middleware as a second line of defence.
  • Postinstall hook generates Prisma client on Vercel.