I built this project as my submission for the H0 Hackathon — Hack the Zero Stack with Vercel v0 and AWS Databases. This post covers how I designed and built ChowkChakra using Amazon DynamoDB and Vercel. #H0Hackathon
The Problem I Was Trying to Solve
Pune has a traffic problem, and it's not just congestion — it's a communication gap.
Citizens spot issues all the time: a signal that's been stuck on red for 20 minutes, a pothole causing lane merges at a busy junction, waterlogging after rain that backs up an entire ward. But they have nowhere to formally report it. WhatsApp groups, Twitter complaints that go nowhere, maybe a helpline call that logs nothing.
On the other side, traffic officers are managing dozens of junctions with zero live signal about what's actually failing right now. They're reactive by default — they find out about problems when someone calls, or when they drive past.
I wanted to close that loop. ChowkChakra is the result: a two-interface system where citizens report problems in real time, officers act on them through a command center, and citizens get notified when the issue is actually resolved — with a verification mechanism to confirm it.
The name: Chowk = intersection in Marathi/Hindi. Chakra = cycle. The cycle from report → officer action → citizen verification → resolution.
The Stack
- Frontend/backend: Next.js 15 (App Router), deployed on Vercel
- Database: Amazon DynamoDB — the only data store, 7 tables
- Storage: AWS S3 for photos/videos from citizens
- AI: Google Gemini 1.5 Pro (function calling, vision) + Gemini Live API (streaming audio)
- Maps: Mapbox GL JS
- Live data: HERE Traffic API (jam factor), OpenWeatherMap (weather penalty)
- Email: SendGrid
- Push notifications: Web Push API / VAPID
Why DynamoDB?
This was a deliberate call, not a default.
ChowkChakra is fundamentally a high-throughput write system with mostly key-based reads. Citizens submitting reports need instant acknowledgment — no joins, no query planning. Officers querying a junction's tags need single-digit millisecond reads, not a SELECT ... JOIN that can degrade under concurrent load.
The access patterns are well-defined and mostly PK-based. That's the DynamoDB sweet spot.
Here's what I actually did with it:
7 tables, each with a purpose
cc-junctions → junction registry + risk scores
cc-tags → active incident tags (the hot table)
cc-tags-history → resolved/archived tags (separate cold table)
cc-push-subs → citizen push subscriptions
cc-verifications → citizen verification votes
cc-sessions → bot config + WebSocket sessions
cc-media-queue → async media processing log
Separating cc-tags from cc-tags-history was the most important schema decision. The active tags table stays lean. Historical queries only touch the archive table. No single-table design that would force a full-table scan to separate resolved from active records.
Read-time SLA penalty (no background jobs)
Different incident types carry different SLA windows — accidents: 1 hour, signal failure: 2 hours, congestion: 4 hours.
Instead of running a cron that writes updated penalty scores to the database every few minutes, I calculate the penalty at read-time:
const isOverdue = tag.slaDeadline
? Date.now() > new Date(tag.slaDeadline).getTime()
: false;
const effectiveRiskScore = isOverdue
? junction.chainRiskScore + 15
: junction.chainRiskScore;
Zero write overhead. The DB stays clean. The overdue status is always accurate because it's computed from the actual current time, not a batch job's last run.
Atomic tag resolution with TransactWriteCommand
When an officer approves a resolve, three things need to happen together or not at all:
- Update the tag status in
cc-tags - Write the archive record to
cc-tags-history - Increment
resolvedCounton the junction incc-junctions
await dynamoClient.send(new TransactWriteCommand({
TransactItems: [
{
Update: {
TableName: "cc-tags",
Key: { junctionId, tagId },
UpdateExpression: "SET #status = :resolved, resolvedAt = :now",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: {
":resolved": "RESOLVED",
":now": new Date().toISOString()
}
}
},
{
Put: {
TableName: "cc-tags-history",
Item: { ...tagRecord, archivedAt: new Date().toISOString() }
}
},
{
Update: {
TableName: "cc-junctions",
Key: { junctionId },
UpdateExpression: "ADD resolvedCount :one",
ExpressionAttributeValues: { ":one": 1 }
}
}
]
}));
If any one of these fails, the whole transaction rolls back. Officers can't accidentally create a state where a tag is marked resolved but the junction counter is wrong, or the archive record is missing.
GSI for ward-level analytics
The analytics dashboard needs to show congestion grades by ward — a grouped query. Instead of scanning the entire cc-junctions table and filtering client-side, I set up a GSI with wardId as the partition key and chainRiskScore as the sort key:
const result = await dynamoClient.send(new QueryCommand({
TableName: "cc-junctions",
IndexName: "wardId-chainRiskScore-index",
KeyConditionExpression: "wardId = :ward",
ExpressionAttributeValues: { ":ward": wardId }
}));
Each ward gets its own query, sorted by risk score descending. No table scan.
DynamoDB TTL for automatic cleanup
Instead of writing cron jobs to expire stale data, I set TTL on three tables:
-
cc-push-subs: 90 days (push subscriptions go stale fast) -
cc-sessions: 24 hours (WebSocket sessions and bot config) -
cc-media-queue: 7 days (async processing logs)
DynamoDB purges these automatically. No Lambda, no scheduled job, no ops overhead.
The Citizen PWA
The citizen-facing interface is a mobile-first PWA (installable, offline-capable via service worker). Four ways to report:
1. Voice tap-to-speak
Tap the mic, describe the issue. Gemini AI parses the transcript and extracts junction name, issue type, and severity. GPS auto-tags the location.
2. Commute Mode (Gemini Live API)
Always-listening hands-free mode for reporting while driving. Audio streams to Gemini Live over a WebSocket in real time. No tapping required — you say "There's waterlogging at Kothrud junction" and it's logged.
This was the hardest feature to build. Keeping a stable WebSocket connection in a mobile PWA while handling voice activity detection, audio buffering, and reconnect logic — without draining battery — took multiple iterations. The final implementation uses server-sent events to confirm each report back to the UI.
3. Photo + description
Upload a photo, type a description. Gemini Vision classifies the issue type.
4. Video with audio
Record a clip at the scene. Gemini processes both the visual and audio track.
Every report lands in cc-tags with full metadata: junction ID, contributor user ID, SLA deadline, upvote count, and a list of contributor IDs for verification later.
The verification loop
When an officer resolves a tag, the app pushes a notification to every citizen who originally reported or upvoted it. They see a verification card in their Alerts tab: "Fixed?" → vote Fixed or Still Broken.
If more than 50% of at least 3 responses say "Still Broken," the tag automatically reopens and the officer gets notified.
This required careful DynamoDB design:
- Contributor IDs are stored on the tag record at write time (so only real contributors get the verification card)
- A separate
cc-verificationstable with composite keytagId#userIdprevents double-voting - A 50-meter radius dedup check on report submission prevents 30 people creating 30 records for the same broken signal
The Officer Command Center
Officers use a desktop web app with five sections:
Live Heatmap — Mapbox GL map of all junctions, color-coded by Gridlock Risk Score. Click any marker to see active tags.
Reports Review — Two-panel workspace. Filter by ward, tag type, status (All / Open / Resolved / SLA Overdue). The detail panel shows live jam factor (HERE API), weather penalty (OpenWeatherMap), and the full incident tag list. Officers can generate PDF dispatch reports, assign tags to a specific officer, or approve and resolve.
Analytics Dashboard — Daily report volume, ward congestion grades (A–F), root cause breakdown donut chart, top 5 recurring junctions.
Chakra AI Assistant — Natural language chat backed by Gemini with function calling on DynamoDB data. Ask "Which junctions have overdue SLAs?" and it queries the database directly.
Social Bot Controls — Automated X (Twitter) posts when a junction exceeds a risk threshold. Officers configure the threshold and can post manually too.
What I Learned About DynamoDB
The biggest shift: design for access patterns, not for storage.
In a relational database, you design tables to represent entities and add indexes later when queries are slow. In DynamoDB, you start with the question "how will this data be queried?" and design the key schema around the answer.
Every design decision I made — the hot/cold table split, the GSI on wardId, the tagId#userId composite key — came from mapping out the real access patterns first.
The TTL feature in particular is underrated. For any data with a natural expiry (sessions, push subscriptions, processing logs), TTL is just free cleanup. The only cost is setting the attribute at write time.
What's Next
- Multi-city support: The junction registry and ward system are parameterized. Adding Nashik or Mumbai means importing junction data, not rewriting code.
- Predictive risk scoring: Right now the Gridlock Risk Score is computed from active tag counts and types. Historical pattern analysis would allow predictive alerts.
- PMC SCOOT/ATCS integration: Pune's Smart City project has signal controller data. Feeding that in would make the risk score much more precise.
If you have questions about the DynamoDB schema design, the Gemini Live API streaming implementation, or the citizen verification loop — drop them in the comments. Happy to go deeper on any of it.
This post was created as part of my submission to the H0 Hackathon — #H0Hackathon
Top comments (0)