The Problem
The existing Elysium Homewatch incident tracker was functional but built informally. The goal was a clean rebuild — same features, documented architecture, full MD Works brand — that could serve as a genuine portfolio piece and a better tool for the community.
The additional requirement was to expand beyond crime reporting to cover the kinds of incidents a neighbourhood watch actually deals with day to day: fire and smoke, water leaks, unattended animals, road hazards, and civil disruptions.
Architecture
The stack follows the MD Works zero-cost pattern:
- Frontend — Vanilla JS, HTML, CSS on Cloudflare Pages
- Backend — Google Apps Script Web App as a REST API
- Database — Google Sheets with three tabs: Pending, Approved, Rejected
- Photos — uploaded client-side to ImgBB, only URLs stored in Sheets
- Map — Leaflet.js with CartoDB dark tiles
The moderation workflow is the core of the data model. Every public report lands
in Pending. Admin approval moves the row to Approved (and makes it visible on the
public map). Rejection moves it to Rejected. The findRowById and findReportSheet
helpers locate any row across all three tabs by UUID.
The CORS Problem
GAS POST requests fail with a CORS preflight error if Content-Type: application/json
is set. The browser treats that header as a non-simple request and sends an OPTIONS
preflight — which GAS cannot respond to.
The fix is to omit the header entirely:
await fetch(CONFIG.GAS_URL, {
method: 'POST',
body: JSON.stringify(payload), // no Content-Type header
});
Without it the browser treats the request as simple, skips the preflight, and GAS
receives the JSON body correctly via e.postData.contents.
The Photo Blur Tool
The admin dashboard includes a canvas-based tool for redacting faces and licence
plates before a report goes live. The non-obvious problem: drawing a cross-origin
image directly onto a canvas taints it, causing toDataURL() to throw a
SecurityError silently. The blur appears to work, but the output is never saved.
The fix is to fetch the image as a blob first:
const res = await fetch(src);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
img.src = blobUrl; // same-origin — canvas stays clean
The resulting data URL can then be re-uploaded to ImgBB and the new URL saved to the sheet, replacing the original.
Comments and Status
Each report has two additional fields added beyond the basic CRUD:
- Comments — a JSON array stored in column I. Admin notes added via the dashboard are visible publicly in the map popup as “Updates”. This gives the community visibility into whether an incident is being acted on.
- Report status — a string in column J: Active, Under Investigation, Resolved, False Alarm, or Duplicate. Shown as a colour-coded badge in both the admin queue and the public popup.
Address Search
The public map includes an address search bar powered by Nominatim (OpenStreetMap’s
geocoder). No API key required. The query is biased to South Africa with
countrycodes=za. On a result the map pans and zooms to the location, dropping a
brief pulse marker that fades after five seconds.
What This Project Demonstrates
- Full CRUD with a multi-tab Google Sheets workflow
- GAS as a zero-cost REST API with a POST dispatcher pattern
- Canvas API with CORS blob workaround for image editing
- Leaflet.js — custom markers, coordinate picker, Nominatim geocoding
- ImgBB for client-side photo uploads — no backend file handling required
- Client-side filtering (category + time) without re-fetching
- PIN auth with GAS Script Properties for secure key storage
Live: sa-incident-tracker.pages.dev
GitHub: SA-incident-tracker
Next: Project 04 — SA Fuel Price API. Moving off the GAS stack for the first time into classical Node.js + PostgreSQL backend architecture.