12 Dec 2024

My Train Notifications

Three years ago, I started commuting to a startup in Palo Alto and discovered the joys and frustrations of Caltrain. Back then, @caltrainalerts on Twitter was run by a human and only during working hours, which made train notifications unreliable.

Determined to solve this, I built a simple Node.js app on an EC2 instance to poll the 511.org API. By logging train data to DynamoDB every minute, I could identify late trains based on historical patterns. From this, https://caltrain.live and @bettercaltrain (twitter API key long gone) were born—offering notifications that were more timely and accurate than the alternatives. I did a write-up earlier in 2024.

Recently, I wanted to learn Swift/SwiftUI, so I wrote the Caltrain Companion app. (Note: I’m not affiliated with the app called “Caltrain Live”)

I was able to get the first version of Caltrain Companion out without any new infrastructure beyond the train list already published for https://caltrain.live:

  • Live train status
  • Train schedules/trip planner with some nice features:
    • I default to the station nearest to your location
    • I allow you to share the train you’re taking with a link for the recipient to track it
    • I allow you to add a train to your calendar, including a reminder to tag on/off
  • ‘Next Stop’ notifications, so you can keep your AirPods in and not miss your stop:
    • Set date and time restrictions, and the app creates a geofence around the previous stop. Once you enter the region, it checks the restrictions and sends a local notification.
  • Monthly tag on/off reminders for monthly pass holders:
    • The app sets a geofence around your origin and destination stops and triggers a notification if you haven’t tagged on/off that month. The month is stored in UserDefaults to avoid duplicate reminders.

There was one additional feature that I wanted to help solve a few of my own problems: “My Train Notifications.”

  • I set alarms to leave for the train in the morning and evening, but I wanted push notifications to replace these alarms.
  • I wanted notifications if the train was late—but only before I left to catch it.
  • The train I take home varies, so I needed notifications for multiple trains, with conditions for later trains to notify me only if I was still at work.
  • I wanted to check Caltrain’s own alerts for cancelation or annulment notifications.

Could I do this all locally?

Introducing BGAppRefreshTask or BGProcessingTask!
iOS lets you schedule background tasks to run at a specific time. I added a feature to check train status X minutes before it was scheduled to arrive at Y station and scheduled a task at that time. The task fetched status from Caltrain Alerts and Caltrain Live.

It worked great during development and testing while driving.

Unfortunately, it didn’t work when the phone wasn’t plugged in. D’oh.

Introducing Local Notifications!
You can schedule a local notification for a specific time.

Unfortunately, you can’t change the title or body of the notification after scheduling it. D’oh again.


No, I could not do it all locally.

To keep as much logic as possible local, I used push notifications to wake the phone, letting it check train status. However, this required changing the notification body after receiving it, which called for a Notification Service Extension (NES). In order to make this work:

  • I moved all my structs to a shared library included by both the main app and the NES.
  • I moved the main app and the NES into an app group so that UserDefaults would share the same scope. Luckily, $LLM helped me write UserDefaults migration code.

This allowed me to change the notification body. However, I couldn’t suppress a notification entirely if the criteria weren’t met. Sending a blank notification into the completion handler substituted the original message. Skipping the completion handler triggered the original after a timeout.

Back to square one. I came to the realization that notification criteria processing had to happen server-side. I sent a content-available (silent) push notification to wake the app, which then sent a local notification—only if you were in the specified location (the one thing I couldn’t do server-side).

Requesting precise background location in a timely manner wasn’t feasible, so I rely on geofences, storing isEntered and isLeft states in UserDefaults and checking these when the notification arrives.


Alerts

Caltrain’s own alerts are clearly humans typing things, and the data is far from structured. Prior, I used a pretty complex regex, checking for common mispellings found in the alerts. I’ve since started feeding them into to OpenAI, which does a pretty good job of taking in an array of human-typed alerts and returning a JSON schema of categorized alerts with affected train numbers.


Infrastructure

  1. API Lambda: Provides an API into DynamoDB tables.
    • Device Table: Stores device_id, push_token, and ttl (active devices should update their push token every 7 days, the ttl will auto-delete that haven’t been updated in a few months).
    • Notification Table: Stores device_id, notification_uuid, train_id, selected_days, and last_notified.
  2. Notification Lambda: Triggered every 5 minutes by a CloudWatch event to:
    • Check for notifications scheduled at the current time.
    • Send notifications for trains mentioned in Caltrain alerts, provided the user hasn’t already been notified and their notification_time is in the future.
  3. Stream Lambda (via DynamoDB streams): Cascades deletes from the Device Table to the Notification Table. NoSQL has its downsides.

If you’re a commuter or someone who takes Caltrain regularly, give it a try — I’d love to hear your feedback!