What I Built
TimelyCal is a Telegram bot that helps Caltrain commuters get schedule information and real-time service alerts in natural language. Instead of navigating a PDF timetable or a clunky official app, you ask it a question: "When is the next train from Lawrence to San Francisco tomorrow morning?" — and it answers.
I built it from scratch, end-to-end, as a side project. It's live and deployed on Google Cloud Run.
Why I Built It
I commute on Caltrain. The official schedule experience is poor — PDFs, a slow app, and alert emails that fire for every station regardless of whether it's relevant to you. I wanted something that could answer in plain English and only tell me things I actually cared about.
It was also a deliberate technical learning exercise: I wanted to build a real RAG pipeline, deploy a production API on Cloud Run, and ship something with proper CI/CD — not just a demo.
How It Works
Natural Language Queries (/ask)
The query pipeline uses two sequential Gemini 2.5 Flash calls. The first extracts structured intent from the user's question — origin, destination, time, date. The second retrieves relevant schedule chunks from a pgvector similarity search (Supabase) and synthesises a final answer. I disabled Gemini's thinking mode to keep latency under 5 seconds.
Guided Commands
For users who prefer structured menus: /schedule (next trains from a station), /traveltime (journey duration), and /fare (zone-based pricing). Built with ConversationHandler in python-telegram-bot v22, with timeout and cancel support.
Real-Time Alert Subscriptions (/subscribe)
This was the most complex part. Users subscribe with two filters: alert type (delays, planned changes, or both) and stations (one or more, with fuzzy matching and alias support, or all stations).
The challenge: Caltrain alerts often reference train numbers without mentioning station names — "Train 420 Southbound delayed 35 minutes". To route these correctly, I built a resolver that maps train numbers to their stop lists by querying the schedule data. Each subscriber only receives alerts relevant to their stations, even when no station name appears in the alert text.
Personalised Alert Enrichment
Alerts are personalised at send time. For a delay, the bot computes the subscriber's scheduled departure time from the schedule data and appends it:
ℹ️ Affects your station: Mountain View — sched. 6:46am, now ~6:58am (+12 min)
For range delays ("35–40 minutes late"), I use the midpoint for the estimate and show the full range in the label.
PDF Ingestion Pipeline
Caltrain timetables are published as PDFs. I built an ingestion pipeline using pdfplumber for table-aware extraction, chunked per station row, embedded via Vertex AI text-embedding-004 (768-dim), and stored in Supabase pgvector. An admin endpoint handles re-upload without downtime.
Technical Stack
| Layer | Technology |
|---|---|
| Bot | python-telegram-bot v22, ConversationHandler |
| API | FastAPI on Google Cloud Run |
| AI | Gemini 2.5 Flash, Vertex AI text-embedding-004 |
| Database | Supabase (PostgreSQL + pgvector), Row-Level Security |
| Alert sources | 511 SF Bay GTFS-RT API, Caltrain RSS feed |
| CI/CD | GitHub Actions → Cloud Run (auto-deploy on merge to main) |
| Testing | pytest, 29+ unit tests with mocked Supabase |
What This Demonstrates as a PM
I built this the way I'd run a product team. Every feature followed a branch → PR → review → merge workflow. I wrote structured test plans before shipping. When alert false-positives emerged — the station filter was being bypassed for train-number-only alerts — I diagnosed the root cause, fixed it, and iterated on the alert message format based on real received alerts. That loop of ship → observe → fix is the same one I run at work.
The design decisions also reflect product thinking: RLS on Supabase tables for data isolation, rate limiting on API endpoints, alert deduplication via a seen_alerts table, and min-instances=0 on Cloud Run to keep costs at zero when idle. I wasn't building a demo. I was building something I'd actually use.
What I'd Do Differently
The alert personalisation logic grew more complex than I anticipated. I embedded the station-to-train-number resolution directly into the broadcast pipeline, which made it harder to test in isolation. I'd separate that into a dedicated resolver service with its own test coverage — cleaner boundaries, easier to iterate.