Inspiration

Public health surveillance has a dirty secret: by the time you see an outbreak on a health agency dashboard, it's already 1–2 weeks old. That data came from hospitals, which got it from labs, which got it from doctors who saw patients days earlier. The system is built for retrospective analysis, not real-time awareness. A week's lag isn't a technical inconvenience — it's the difference between a school warning parents early and a parent finding out after half the class is already sick.

The research already exists to justify this approach. Harvard's Flu Near You project proved that crowdsourced symptom data predicted flu peaks 1–2 weeks earlier than CDC surveillance. It worked. It shut down due to funding, not because people stopped getting sick. That gap never got filled.

We wanted to build the thing that should already exist: a real-time, anonymous, hyperlocal illness map for our city.


What it does

Sympto is an anonymous crowdsourced illness reporting map for Montreal. Reporting takes under 30 seconds. You either select a known illness by name (Cold, Flu, COVID, Stomach bug, Strep, or type something custom) or pick from grouped symptom chips across three categories — Respiratory, GI, and General. You then rate your severity (Mild / Moderate / Severe), indicate how long you've been sick, confirm your approximate location on a draggable map, and submit. No account. No email. No name. Your report is live on the map in seconds.

The map shows you what's going around. Reports appear as a live heatmap across Montreal with age-decay baked in — recent reports glow brighter, older ones fade. You can filter by illness type, timeframe (last 24 hours, 3 days, or 7 days), and report type. A pulsing blue GPS dot shows your current location on both the main map and the location confirmation step. A locate-me button flies the map to your position instantly. Montreal hospital markers are overlaid on the map with a show/hide toggle so you always know where care is available.

A slide-out stats sidebar shows total reports, today's count, average intensity, and the top circulating illness — with daily, monthly, and by-illness chart breakdowns. Throughout the app the framing is clear: this is community data, not a diagnosis. Sympto helps you understand context. It doesn't tell you what to do.


How we built it

Frontend: Next.js + Tailwind CSS. The reporting flow runs inside a centered modal that works across all screen sizes — we moved away from a bottom drawer to improve usability on both mobile and desktop. The form branches based on the user's first choice: known illness name or symptom selection, each leading through severity and duration steps before location confirmation.

Map layer: MapLibre GL (open-source) rendered on Stadia Maps tiles. We chose MapLibre for full control over heatmap layer styling, age-decay weighting logic, and marker customization. The GPS "you are here" pulsing blue dot uses the browser Geolocation API with watchPosition for live tracking. A custom draggable pin icon handles location confirmation — if GPS is denied, the pin defaults to Montreal center and remains fully repositionable by tapping anywhere on the map. Hospital markers are loaded as a separate toggleable layer.

Database: Supabase (Postgres + PostGIS + Realtime). PostGIS handles all geospatial logic — radius queries, spatial indexing, borough-level aggregations. Supabase Realtime pushes changes to all connected clients the moment they hit the database. Critically, we set the reports table to REPLICA IDENTITY FULL — this ensures DELETE and UPDATE events carry the full row payload through the Realtime channel, so heatmap removals and recovery updates propagate instantly to every connected client, not just the device that triggered the change. The Realtime subscription handles INSERT, UPDATE, and DELETE events, keeping every client's map in perfect sync.

Privacy architecture: Reports store only symptom data, severity, duration, timestamp, and a fuzzed coordinate (±200m random noise applied before storage). No user ID, session ID, device fingerprint, or IP address is stored. A recovery code is generated at submission time and surfaced in the sidebar — users can mark themselves recovered later without any account, maintaining full anonymity throughout.

Hosting: Vercel. Sub-minute deploys, zero config.

The core loop — user submits report → written to Supabase → PostGIS indexes it spatially → Realtime pushes to all connected clients → heatmap updates — happens in under two seconds end-to-end.


Challenges we ran into

Location fuzzing without clustering artifacts. Adding ±200m noise protects privacy but naive random noise creates visible clustering artifacts on the heatmap. We tuned the heatmap radius and blur parameters in MapLibre to smooth this naturally without losing geographic signal.

Age-decay weighting in a live heatmap. MapLibre's heatmap layer accepts a weight property per point. We compute a decay coefficient based on report age and pass it as a weight field — getting the decay curve to feel intuitive took several iterations.

Supabase Realtime and REPLICA IDENTITY. By default Supabase only sends the primary key on DELETE events — not the full row. This meant deletions and recovery updates were invisible to other clients. Setting REPLICA IDENTITY FULL on the reports table was the fix, but diagnosing why only the submitting device saw real-time recovery updates took significant debugging.

Heatmap re-render performance. Naively re-rendering the entire heatmap layer on every Realtime event caused visible flicker on mobile. We moved to an incremental data strategy — appending, updating, or removing individual points from the local GeoJSON source without a full layer reload — which eliminated flicker across all three event types.

Scoping responsibly. We deliberately chose not to build illness trend alerts or hotspot flagging even though the data supports it. Features that tell users an area is dangerous cross into medical advice territory and could cause harm. The map shows data. Users draw their own conclusions.


Accomplishments we're proud of

The privacy model is structural, not policy-based. We didn't write a privacy policy promising not to misuse data — we built a system where misuse is architecturally impossible. You cannot de-anonymize a report containing only a fuzzed coordinate, a symptom category, and a timestamp. Recovery is handled through a generated code, not an account — full anonymity is preserved end-to-end.

The reporting flow takes under 30 seconds. On mobile, from opening the app to a submitted report with illness type, severity, duration, and location confirmed: under 30 seconds. Friction is the enemy of crowdsourced data quality. We killed the friction.

Real-time sync across all clients for all event types. INSERT, UPDATE, and DELETE all propagate instantly to every connected browser. The fix required understanding Postgres replication identity at the database level — not just the application layer.

We built this in 1.5 days. Two developers, one city, zero compromises on privacy.


What we learned

Realtime correctness is harder than realtime speed. Getting data to appear fast was straightforward. Getting deletions and updates to propagate correctly to every client required going deeper into Postgres replication mechanics than we expected — REPLICA IDENTITY FULL was not an obvious solution and took real debugging to find.

Structural privacy beats policy privacy. Designing the schema to be incapable of storing identity — rather than just promising not to — is a fundamentally different and stronger approach. It also simplifies every downstream decision: no GDPR considerations for data that can't be attributed.

Crowdsourced tools live or die on friction. Every step we added to the reporting flow had to justify its existence in terms of data value. Severity and duration made the cut because they make reports meaningfully more useful. Anything else we considered got cut.


What's next for Sympto

Scaling beyond Montreal. The architecture is city-agnostic. Borough polygons and hospital markers are the only Montreal-specific data. Adding a new city is a configuration change, not a rebuild.

Temporal trend modeling. With enough historical data, week-over-week trend lines per borough become meaningful. Respiratory reports in Rosemont up 40% from last week is actionable for parents and schools without crossing into medical advice.

Public health partnerships. The aggregated anonymized dataset Sympto generates is exactly what municipal health units want but can't collect quickly. A data-sharing agreement with the CIUSSS or Direction régionale de santé publique de Montréal could let Sympto data complement official surveillance — not replace it, but fill the 1–2 week gap.

Accessibility and language. Montreal is bilingual by law and multilingual in practice. A full French-language UI plus simplified reporting flows for lower-literacy users would meaningfully expand who can contribute.

Sustainability model. Flu Near You proved the demand is real. It shut down because grants ran out. A sustainable path includes anonymized aggregate data licensing to public health agencies, academic research partnerships, or a freemium model for institutional users — schools and employers — who want borough-level digests. Individual reporting stays free, always.

Built With

Share this project:

Updates