Skip to content

Mota — Solution Design

SDLC phase: Design (Chapter 4.1–4.5)
Purpose: Architecture, container boundaries, component responsibilities, and the one algorithm that determines whether the product works.


1. System context (C4 Level 1)

Who and what the system talks to.

graph TB
    owner["👤 Vehicle Owner<br/><i>Harare driver. Logs services,<br/>receives reminders, finds garages.</i>"]
    garage["👤 Garage Owner<br/><i>Independent workshop. Receives leads,<br/>submits prices, pays invoice.</i>"]
    admin["👤 Operator (us)<br/><i>Verifies garages, seeds reference data,<br/>handles data requests.</i>"]

    mota["<b>Mota</b><br/><i>Vehicle record, deadline engine,<br/>garage directory, price index.</i>"]

    push["Expo Push / FCM<br/><i>Delivers reminders</i>"]
    wa["WhatsApp<br/><i>Deep-link fallback,<br/>lead alerts</i>"]
    paynow["Paynow<br/><i>EcoCash, OneMoney,<br/>card payments</i>"]
    play["Google Play<br/><i>App distribution</i>"]

    owner -->|"Logs services, checks<br/>due dates, requests bookings"| mota
    garage -->|"Manages leads, submits<br/>prices, pays invoices"| mota
    admin -->|"Administers"| mota
    mota -->|"Sends reminders"| push
    mota -->|"Deep links, lead alerts"| wa
    mota -->|"Creates payments,<br/>receives webhooks"| paynow
    owner -->|"Installs"| play

Explicitly external: we do not integrate with ZINARA (no public API), with vehicle registration databases, or with insurer systems. Those are user-entered data in v1. Stating this prevents a design that quietly assumes an integration exists.


2. Container diagram (C4 Level 2)

graph TB
    owner["👤 Vehicle Owner"]
    garage["👤 Garage Owner"]

    subgraph mota["Mota"]
        app["<b>Mobile App</b><br/>React Native (Expo)<br/><i>Offline-first client.<br/>Owns local state + write queue.</i>"]
        console["<b>Garage Console</b><br/>Next.js (web + mobile web)<br/><i>Leads, price submission, billing.</i>"]
        api["<b>API / Data Layer</b><br/>Supabase<br/><i>Postgres + Auth + RLS + Edge Fns.<br/>System of record.</i>"]
        jobs["<b>Scheduler</b><br/>Supabase Cron<br/><i>Computes due reminders, dispatches.</i>"]
        db[("<b>Postgres</b><br/>Vehicles, records, deadlines,<br/>garages, leads, prices, billing")]
        storage[("<b>Object Storage</b><br/>Receipt photos, PDFs")]
    end

    push["Expo Push / FCM"]
    paynow["Paynow"]

    owner -->|"Uses"| app
    garage -->|"Uses"| console
    app -->|"Syncs, HTTPS/JSON"| api
    console -->|"Reads/writes, HTTPS"| api
    jobs -->|"Queries due items"| db
    jobs -->|"Dispatches"| push
    jobs -->|"Creates lead records"| api
    console -->|"Initiates payment"| paynow
    paynow -->|"Webhook: confirmed"| api
    app -->|"Uploads receipts"| storage

Why this shape

  • One backend container. Supabase bundles auth, database, RLS, storage, and scheduled functions. For a 1–2 person team, every additional container is a permanent tax.
  • Business logic lives in the database where possible. Postgres functions and RLS policies are harder to bypass than application code, and they enforce isolation even if the client is compromised.
  • The mobile client owns the write queue. Offline-first is a client concern; the server accepts idempotent writes.
  • The console is separate. Different users, different auth, different release cadence. Sharing a codebase would couple their deploy cycles for no benefit.

3. Component design (C4 Level 3) — the app

graph TD
    subgraph App["Mobile App"]
        UI[Screen Layer<br/>react-native screens]
        State[State & Cache<br/>TanStack Query + Zustand]
        Queue[Write Queue<br/>persistent, idempotent]
        LocalDB[(SQLite<br/>vehicles, records, due cache)]
        Sync[Sync Engine<br/>push/pull, conflict resolution]
        Engine[Interval Engine<br/>pure functions, unit-tested]
        Notif[Notification Handler<br/>push + local]
        PDF[PDF Generator<br/>expo-print]
    end

    UI --> State
    State --> LocalDB
    State --> Engine
    UI --> Queue
    Queue --> Sync
    Sync --> LocalDB
    Sync -->|HTTPS| API[(Supabase)]
    Notif --> UI
    State --> PDF

The critical rule: the Interval Engine is pure. Given (vehicle, reference data, records, today) it returns due items. No I/O, no side effects, no React. This makes it unit-testable, which matters because it's the part users will lose trust over.


4. The interval engine

This is the algorithm the product lives or dies on. It is specified here because a wrong reminder is worse than no reminder.

4.1 Inputs

Vehicle:
  make, model, year, fuel_type, transmission
  current_odometer, odometer_updated_at
  first_registration_date      (optional)

ReferenceData[make, model, year_range]:
  intervals: [ { id, label, km_interval, month_interval, severity } ]

Records[]:
  { id, type, date, odometer, service_keys[] }   // service_keys link a record to a reference interval

Today

4.2 Algorithm

FUNCTION computeDueItems(vehicle, reference, records, today):

  due_items = []

  # 1. Establish the odometer baseline
  latest_record_odo = max(odometer) across records.type in {SERVICE, FUEL}
  baseline_odo = max(vehicle.current_odometer, latest_record_odo)
  odo_is_stale = (today - vehicle.odometer_updated_at) > 30 days

  # 2. Estimate current odometer from usage rate if stale
  IF odo_is_stale AND records has >= 2 odometer readings:
      avg_km_per_day = (latest_odo - earliest_odo) / days_between(earliest_date, latest_date)
      estimated_odo = latest_odo + (avg_km_per_day * days_since_latest)
  ELSE:
      estimated_odo = baseline_odo

  # 3. For each interval defined for this model
  FOR EACH interval IN reference.intervals:

      # 3a. Find the most recent record that satisfies this interval
      last_done = most recent record r WHERE interval.id IN r.service_keys
      IF none:
          last_done = NULL
          reference_point = vehicle_acquisition_or_first_record
      ELSE:
          reference_point = last_done

      # 3b. Compute both due conditions — whichever comes first
      IF last_done IS NULL:
          due_odo   = interval.km_interval           # from zero / acquisition
          due_date  = reference_point.date + interval.month_interval
      ELSE:
          due_odo   = last_done.odometer + interval.km_interval
          due_date  = last_done.date      + interval.month_interval

      km_remaining   = due_odo  - estimated_odo
      days_remaining = due_date - today

      # 3c. Classify
      IF km_remaining <= 0 OR days_remaining <= 0:
          status = OVERDUE
      ELSE IF km_remaining <= DUE_SOON_KM (500) OR days_remaining <= DUE_SOON_DAYS (30):
          status = DUE_SOON
      ELSE:
          status = UPCOMING

      # 3d. Build an explanation — never return a bare "service due"
      reason = format(
          "{label}: {format_km(estimated_odo)} / due at {format_km(due_odo)}"
          "  •  {days_remaining} days  •  based on {basis}"
      )
      # basis = "your records" | "manufacturer interval" | "your override"

      due_items.append({ interval, status, km_remaining, days_remaining, reason, confidence })

  # 4. Confidence is downgraded when inputs are weak
  IF odo_is_stale:          confidence = min(confidence, MEDIUM)
  IF avg_usage_unavailable: confidence = min(confidence, LOW)
  IF interval.overridden:   confidence = HIGH   # user knows their car

  RETURN sort(due_items, severity desc, status desc, due_date asc)

4.3 Design decisions embedded here

Decision Rationale
Both km and time triggers A car driven 3,000km/year still needs annual oil. Whichever comes first is correct.
service_keys link records to intervals Free-text "oil change" can't reliably satisfy "engine oil interval". Explicit keys make completion detection deterministic.
Odometer estimation from usage rate Most users won't update the odometer. Refusing to compute anything is worse than computing with a flagged estimate.
Confidence is surfaced, never hidden A low-confidence reminder says so. This is the difference between advisory and untrustworthy.
User override wins The owner knows their car. Overrides are stored with a note and never silently overwritten.
Never a bare "service due" Every reminder explains its basis (FR-DUE-06). This is the trust mechanism.

4.4 Worked example

Vehicle: 2014 Honda Fit, current odometer 78,400km, updated 5 days ago
Interval: CVT fluid, 40,000km / 48 months
Last record: 2023-03-10 @ 41,200km, service_keys: ["cvt_fluid"]

due_odo  = 41,200 + 40,000 = 81,200km
km_remaining = 81,200 - 78,400 = 2,800km   → UPCOMING (not due soon)
due_date = 2023-03-10 + 48 months = 2027-03-10
days_remaining = ~485 days                → UPCOMING

reason: "CVT fluid: 78,400km / due at 81,200km • 2,800km to go • based on your records"
confidence: HIGH

— After the user logs a 1,900km road trip in 6 weeks —
estimated_odo ≈ 80,300km
km_remaining = 900km → DUE_SOON
reason: "CVT fluid: 80,300km / due at 81,200km • 900km to go • based on your records"

4.5 Test cases (traceable to NFR-MAINT-02)

# Scenario Expected
T1 No records at all Due dates computed from acquisition date; confidence LOW
T2 Records satisfy interval exactly at boundary Status DUE_SOON, not OVERDUE
T3 Odometer stale 60 days, 3 prior records Estimated odometer applied; confidence MEDIUM
T4 User override of interval Override takes precedence; confidence HIGH; reason cites override
T5 Two records for same interval Most recent wins
T6 Soft-deleted record Excluded from due calculation, recoverable for 30 days
T7 Odometer decreases (data error) Reject or flag; never compute negative remaining
T8 Time trigger passed but km far away OVERDUE by date — time wins
T9 Interval with no month component km-only calculation
T10 Record with no odometer Satisfies interval for date basis only; confidence reduced

5. Critical sequence diagrams

5.1 Logging a service offline, then syncing

The most important flow. If this is fragile, nothing else matters.

sequenceDiagram
    participant U as User
    participant A as App UI
    participant Q as Write Queue
    participant L as SQLite
    participant S as Sync Engine
    participant API as Supabase

    U->>A: Completes service form
    A->>A: Generate client_id (UUID)
    A->>Q: Enqueue write (client_id, payload)
    Q->>L: Write optimistically
    A-->>U: Record appears instantly (no spinner)

    Note over S: Connectivity returns / app foregrounds
    S->>Q: Drain queue (ordered)
    S->>API: POST record (client_id, payload)
    alt first time
        API->>API: Insert, store client_id
        API-->>S: 201 Created
    else replay already applied
        API->>API: client_id exists → no-op
        API-->>S: 200 OK (idempotent)
    end
    S->>L: Mark queue entry synced
    S->>API: Pull server state (updated_since watermark)
    S->>L: Reconcile, recompute due items

Idempotency is non-negotiable. A duplicated service record corrupts the interval calculation and the cost totals. client_id uniqueness is enforced at the database level, not in application code.

5.2 Reminder → lead → booking → billing

The revenue flow.

sequenceDiagram
    participant Cron as Scheduler
    participant DB as Postgres
    participant Push as Expo Push
    participant U as User
    participant A as App
    participant G as Garage Console
    participant P as Paynow

    Cron->>DB: Daily: select due items crossing threshold
    DB->>DB: Insert notification (stage, interval, vehicle)
    Cron->>Push: Dispatch push
    Push-->>U: "CVT fluid due in 900km"

    U->>A: Opens reminder
    A->>A: Show reasoning + 3 nearby CVT garages
    U->>A: Requests booking from Garage A
    A->>DB: Create lead (vehicle, service, user contact released)
    DB->>G: Lead appears + WhatsApp alert

    G->>G: Marks lead "booked"
    G->>DB: Lead status = booked, timestamp
    DB->>DB: Lead billable at US$3
    DB->>DB: Add to garage's monthly invoice

    Note over G,P: Month end
    G->>P: Initiate payment for invoice
    P-->>DB: Webhook: paid
    DB->>DB: Invoice status = paid, record transaction ref

Attribution rule: a lead is billable only when the garage marks it booked. Marking lost or no-response is free. This is deliberate — it makes the garage's risk zero, which is the only way a micro-business will try the product.

5.3 Garage price submission → price index

sequenceDiagram
    participant G as Garage
    participant C as Console
    participant DB as Postgres
    participant U as User

    G->>C: Submits price (part category, price, unit)
    C->>DB: Insert price (garage_id, category, amount, as_of = today)
    DB->>DB: Upsert into current-price view per (garage, category)

    U->>DB: Searches "CVT fluid NS-3"
    DB-->>U: Partner prices with as_of dates, sorted by freshness then price
    Note over U,DB: Prices >90 days show a stale badge<br/>Sponsored entries carry a "Sponsored" label

6. State model

The whole app is a function of a small number of states. Keeping this explicit prevents the "why is it showing that?" class of bug.

State Meaning UI behaviour
EMPTY No vehicles Onboarding
ACTIVE Vehicle present, no due items Dashboard, calm
DUE_SOON ≥1 item due soon Dashboard, amber banner
OVERDUE ≥1 item overdue Dashboard, red banner, garage CTA
STALE_DATA Odometer >30 days old Prompt to update, confidence downgraded
OFFLINE No connectivity Banner, all writes still accepted
SYNC_CONFLICT Server rejected a write Surface to user with a resolution choice — never silent

7. Deliberate non-designs

Things a reviewer might expect and won't find, with reasons.

Not designed Why
Event sourcing for records Full audit history is overkill for a vehicle log; soft-delete + timestamps is sufficient and 90% cheaper
A recommendation ML model Deterministic intervals are more accurate, explainable, and legally defensible at this stage
Microservices One team, one deploy, one failure mode
Real-time sync (websockets) Writes are infrequent and user-initiated; polling on foreground is sufficient and cheaper on data
Custom auth Supabase phone OTP is adequate and removes an entire class of security bugs
Offline conflict UI for most fields Last-write-wins is acceptable for notes and costs; only odometer conflicts surface to the user