---
title: "CollectKit"
slug: collectkit
date: 2026-06-01
url: https://mohammadhameed.dev/projects/collectkit
status: published
tags: ["fullstack", "geospatial", "systems"]
technologies: ["Expo", "React Native", "FastAPI", "PostgreSQL", "PostGIS"]
---

{/* Sample case study for the design refresh — verify the numbers and swap
    real app screenshots in for the covers before this is final copy. */}

CollectKit is the other product I build at NuronsAI. The pitch is simple:
field teams should be able to capture anything — an inspection, a delivery
proof, a site survey — from a phone, with or without a network, and have it
land in a structured table by the time the crew gets back to the truck.

## The problem

Most form tools fail the moment you take them outside a city. Signal drops,
the submit spins, the data half-saves, and the crew learns to keep a paper
backup. Once people keep a paper backup, the software is dead — you just
haven't archived the funeral yet.

<Callout type="note">
The insight that shaped the architecture: treat connectivity as an
*optimization*, not a requirement. The happy path is the offline one.
</Callout>

## Architecture

One schema definition drives everything. The mobile client generates its
form from it, the FastAPI service validates against it, and the dashboard
renders tables from it. Write the data model once; four surfaces stay in
agreement forever.

```python
class Inspection(Schema):
    site: GeoPoint            # lat/lon, captured even indoors
    severity: Enum["ok", "watch", "act"]
    photos: MediaList(max=20) # queued, compressed, resumable
    notes: Text | None
```

Sync is a queue, not a moment. Photos compress locally, enter a resumable
upload queue, and the sync worker drains it opportunistically — a two-second
blip of LTE in a parking lot is enough to make progress.

<Figure src="/images/projects/collectkit-alt.svg" alt="The capture-to-warehouse pipeline" caption="One schema feeds the form, the queue, the API, and the warehouse table." />

## Where the hard problems were

- **Conflict resolution.** Field data arrives out of order — a record
  created at 9:14 may sync at 11:40, after a newer edit to the same site.
  We resolve with last-writer-wins per *field*, not per record, because
  severity changes more often than photos.
- **Geo without GPS lock.** Indoors, the lat/lon comes from the last known
  tower with a confidence ring attached. We'd rather show "somewhere near
  bay 3, uncertain" than a fake precision pin.

<StatStrip items={[
  { value: '0', label: 'records lost since launch' },
  { value: '412 MB', label: 'largest single media queue' },
]} />

## What I'd do differently

The first version made sync invisible. That was correct for reliability and
wrong for trust — crews could not tell whether the app had forgotten them.
Now there's a quiet status line: *3 uploads this session*. Same architecture,
one honest signal.

## Status

CollectKit is in active development alongside ServiceMenu — same design
system, shared component kit, two very different shapes of product. The
[hiring conversation](/contact) usually starts with a field pain someone
describes badly; that's my favorite kind of brief.
