A demo guest-incident reporting dashboard for a zoo Security & EMT team, modeled on the Nashville Zoo at Grassmere guest map. Officers and EMTs pick their role, log an incident against a real location on the park map, attach timestamped photos or video from the device camera, and supervisors review, follow up, and print reports.
Built as a single-page app with no framework and no backend: plain HTML, CSS, and JavaScript, with the browser's IndexedDB as the database. Open index.html and it works.
Demo status. This is a portfolio / proof-of-concept build. It is not the zoo's system of record, and sample incidents are clearly tagged as samples.
Incident reports at attractions are usually paper forms or a generic ticketing tool. Neither is fast on a phone in the field, neither knows the park's geography, and neither ties the photo you took to the exact moment you took it. This project shows what a purpose-built tool looks like:
- Two reporter roles (Security, EMT) with fields that match how each team actually writes a report
- Tap-the-map location picker using the park's own guest map, so "where" is a pin, not free text
- Camera capture with the incident ID, timestamp, and badge burned into every photo
- A local database that stores every record and capture with time and date
- Fast enough to use on a phone mid-incident (big targets, autosaving drafts, sensible defaults)
- Complete enough that a supervisor could act on the report without calling the officer
- Structured data from day one (typed fields, controlled vocabularies, IDs) so it can feed a real backend later
- Zero setup for a demo: one file, one link, no accounts
| Area | What it does |
|---|---|
| Sign-on gate | Choose Security Officer or EMT, enter name and badge. Every record and capture carries the author. |
| Dashboard | Open / high-critical / logged-today / awaiting-follow-up counts, recent incidents, open incidents pinned on the map, "needs attention" list, top categories for your team. |
| Location picker | ~75 locations from the guest map (exhibits, dining, attractions, event spaces, restrooms, first aid, ATM, parking, front gate), grouped by zone, searchable, and clickable as pins on the map image. |
| Security report | Incident type, severity, narrative, people involved, outcome (warning → ejection → trespass ban → MNPD), officer / report #, CCTV camera IDs, statements, evidence, property, vehicles, supervisor. |
| Medical report | Patient details, chief complaint, mechanism / HPI, SAMPLE history, multi-set vitals (BP, HR, RR, SpO₂, temp, BGL, pain, AVPU, skin), treatment checklist, disposition, EMS handoff (911 time, agency, unit, arrival, departure, destination), refusal-of-care attestation. |
| Evidence | Live camera (photo + video, front/back flip) or native device camera. Photos get INCIDENT-ID YYYY-MM-DD HH:MM:SS BADGE stamped in the corner. Video gets a poster frame and duration. |
| Incident detail | Full report, evidence gallery with lightbox, status changes, timestamped follow-up log, print / save-as-PDF, JSON export. |
| Map view | Filter open / today / all-time; click any pin to list its incidents. |
| Drafts | The form autosaves per role; a discarded draft also deletes its captures. |
| Themes | Light and dark, following the OS or an explicit data-theme. |
Sign on (role, name, badge)
└─▶ Dashboard
├─▶ New report ─▶ When & where ─▶ What ─▶ People ─▶ Role section ─▶ Evidence ─▶ Actions ─▶ Submit
│ (map picker) (camera / file)
├─▶ Incident log ─▶ Incident detail ─▶ status · follow-up notes · add evidence · print · export
└─▶ Map ─▶ pin ─▶ incidents at that location
Incident IDs are minted per team per day: SEC-260905-003, MED-260905-001 (YYMMDD + sequence).
- Reporter identity (name, badge, role) — kept in
localStorage - Form fields per section (see
blankDraft()insrc/app.jsfor the full schema) - Photos / video from
getUserMedia+MediaRecorder, or<input type="file" capture>on phones - The park map (
assets/zoo-guest-map.webp) with pin coordinates inLOC_RAW
- Incident records and media in IndexedDB (
grassmere-incident-deskdatabase:incidents,mediastores) - Printable report (browser print / save as PDF)
- JSON export of one incident or the whole log (when the host provides a download hook)
dist/index.html— the single-file bundle for sharing
index.html # dev entry — loads src/ and assets/ directly, no build needed
src/
app.js # all application code (data, storage adapter, views, camera, map)
styles.css # design tokens (light + dark), layout, components
assets/
zoo-guest-map.webp # guest map used for the location picker and dashboards
scripts/
build.py # bundles everything into dist/index.html (map inlined as data URI)
dist/
index.html # generated single-file build (committed for convenience)
docs/
data-model.md # incident and media record shapes
No install. Either:
- Open
index.htmlin a browser (Chrome/Edge/Safari), or - Serve the folder (
python3 -m http.server 8000) and openhttp://localhost:8000— required for the live camera on some browsers, sincegetUserMedianeeds a secure or localhost origin
Rebuild the single-file bundle after changes:
python3 scripts/build.py- Google Fonts (Barlow, Barlow Condensed, JetBrains Mono) — falls back to system fonts offline
- Browser APIs: IndexedDB,
getUserMedia,MediaRecorder, Canvas,IntersectionObserver - No npm packages, no framework, no server
- Storage adapter. All persistence goes through one object (
Store) with a small interface:listIncidents,putIncident,deleteIncident,listMedia,addMedia,getMedia,deleteMedia. Swap it for a REST client (e.g. Django + DRF + Postgres, S3 for media) without touching the views. AMemStorefallback kicks in when IndexedDB is unavailable (private mode, restricted iframes). - Rendering. Hash-based router (
#/dashboard,#/new,#/incidents,#/incident/<id>,#/map) and template-string views. Deliberately framework-free so the whole thing reads top to bottom. - Locations.
LOC_RAWholds[zone, name, x, y, kind]withx, yin pixels on the 2000×1250 map; they're converted to percentages so pins stay put at any size. Add a location by adding a row. - Evidence integrity. Photos are re-encoded through a canvas with a burned-in stamp; the record also stores
takenAt,by,mime,size, and an optional caption. Videos keep the original blob plus a poster frame.
- Vocabularies (
SEC_CATS,EMT_CATS,TREATMENTS,DISPOSITIONS,NOTIFY, …) live at the top ofsrc/app.js. Change wording there, not in the templates. - Sample records are seeded once per browser (
gid.seededflag). Delete them from the detail page, or clear site data to reseed. - Design tokens are CSS custom properties on
:root; dark mode redefines only the tokens. - The IndexedDB schema is version 1. Bump the version in
Store.open()and add an upgrade path before changing store shapes.
- Shared backend (Django + DRF + Postgres) behind the same adapter, with per-user auth and supervisor sign-off
- Media upload to object storage with server-side timestamps and hashes for chain of custody
- Shift handoff summary and daily PDF digest
- Offline queue: capture in the field with no signal, sync when back on Wi-Fi
- Role-based permissions (officer / EMT / supervisor / read-only)
- Body-cam and radio-log references
- An officer can complete a lost-child report on a phone, with a photo and location, in under three minutes
- A supervisor can read any report and know what happened, who was involved, what was done, and what is still owed — without a phone call
- Every photo and video carries a trustworthy capture time
- The code stays readable enough that a new developer can add a field or a location in one sitting
MIT — see LICENSE.
The guest map is the property of the Nashville Zoo at Grassmere and is included here only as reference material for a demo.