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 |