Architecture
DynamoDB single-table design — one table, all access patterns covered by PK/SK + one GSI.
Single Table
Every entity (sightings, users, watches, confirmations) lives in one DynamoDB table, accessed via PK/SK patterns.
TTL as UI Signal
expiresAt is both a DynamoDB TTL for deletion AND the source of confidence %. No separate cron job needed.
One GSI for Trending
TrendingIndex on GSI1PK (CATEGORY#X) + GSI1SK (ISO timestamp) enables the 6-hour trending query across all areas.
Table: blip (single-table)
| Entity | PK (hash) | SK (range) | GSI1PK | GSI1SK | TTL |
|---|---|---|---|---|---|
| Sighting Core item — TTL drives confidence decay in UI | AREA#<areaSlug> | SIGHTING#<reportedAtISO>#<sightingId> | CATEGORY#<category> | <reportedAtISO>#<sightingId> | expiresAt (epoch) |
| User Profile Trust tier, streak, report totals | USER#<userId> | PROFILE | — | — | — |
| Watch Alert User-defined area+category monitors | USER#<userId> | WATCH#<areaSlug>#<category>#<watchId> | — | — | — |
| Confirmation Audit trail + confirms that sighting is still live | SIGHTING#<sightingId> | CONFIRMATION#<ISO>#<nanoId> | — | — | expiresAt (matches parent sighting) |
Access Patterns
Get all active sightings for an area
GET /api/sightings?area=DowntownGet user profile
GET /api/profile?userId=u_abc123Get user watch alerts
GET /api/watch?userId=u_abc123Trending by category (6h window)
GET /api/trendingConfirm a sighting
POST /api/sightings/:id/confirmConfidence Score Formula
(expiresAt - now) / 1800 // time ratio [0-1]
+ min(confirmationCount * 0.1, 0.3) // confirm boost, max 30%
+ trustBoost // bronze 0 / silver 5% / gold 10%
> 66%
Fresh
Bright orange ring, high opacity
33–66%
Fading
Yellow ring, slightly dimmed
< 33%
Stale
Gray ring, reduced opacity
Load demo data
Seed 8 realistic sightings across all areas to see the decay engine live.
TrendingIndex GSI — How It Works
Write path (on sighting creation)
GSI1PK =
"CATEGORY#Sneakers"
GSI1SK =
"2026-06-25T14:30:00Z#abc123"
Read path (trending query)
// For each category:
KeyCondition:
GSI1PK = "CATEGORY#X"
AND GSI1SK >= "6h ago ISO"
Filter: expiresAt > now
// Count = trending score