twenty-four: calendar service
The calendar service is the orchestrator. It syncs workouts between Intervals.icu and Google Calendar, creates ICU workouts from gym reservations, and sends notifications when things change.
Written in Go. Runs at calendar.twenty-four.home
. Does way too much, but does it well.
The Problem
I track workouts in Intervals.icu. My life lives in Google Calendar. These two systems need to stay in sync, but they don’t talk to each other.
What I needed:
- ICU workouts → Google Calendar events (with transit time buffers)
- Google Calendar edits → ICU workout time updates (bi-directional)
- Gym reservations → ICU workouts → Google Calendar
- Notifications when waitlists clear or reservations fail
- Delete from calendar → delete from ICU (and vice versa)
Manual sync was tedious.
The Solution
A sync service that runs on-demand (triggered by CronJob every 15 minutes or via POST /sync
).
Core flow:
|
|
Transit Time Buffers
Workouts happen at specific times in ICU. Calendar events need buffer time to get there.
The service adds configurable pre/post buffers based on workout type:
|
|
ICU says: Workout at 6:00 AM, 40 minutes Calendar shows: Event named @ 0600 ranging from 5:45 AM - 6:45 AM (15 min before, 5 min after)
This way I know when to leave, not just when the workout starts.
Bi-Directional Sync
Move a calendar event in Google Calendar → service detects the change and updates ICU.
Gym Integration
The gym service (gym.twenty-four.home
) scrapes the gym website and returns reserved/waitlisted classes via JSON.
Calendar service polls it every sync.
Each gym class gets a stable GymID
(MD5 hash of type+date+time), stored in the ICU workout description as [gym_abc123]
. This prevents duplicate creation on subsequent syncs.
Waitlist handling:
If a class is waitlisted, the workout title shows position:
💪 HIIT (Waitlisted 5/4)
- you’re 5th, class holds 4- Position updates every sync
- When waitlist clears → title changes to
❤️🔥
, Pushover notification sent
Auto-Reservation
If an ICU workout has the Gym tag but no gym reservation exists, the service attempts to auto-reserve.
This runs every sync, so if a spot opens up in a full class, it auto-reserves within 15 minutes.
Failure modes:
- Class full → mark workout as
(FULL)
, retry next sync - Temporary conflict (another operation in progress) → mark as
(Tentative)
, retry - Unknown error → mark as
(Tentative)
, send notification
Deletion Sync
Delete from Google Calendar → service detects missing event → deletes ICU workout.
Delete from ICU → service detects missing workout → deletes calendar event.
Emoji Mapping
Workout names get emoji prefixes based on type:
|
|
Calendar event titles include the time: 🏃 @ 0600 Gym
Makes scanning my calendar way easier.
Notifications
Pushover notifications for important events:
- ✅ Waitlist cleared (you’re now reserved)
- 🚫 Class full (auto-reserve failed, will retry)
- ⚠️ Missing gym reservation (ICU workout exists but no reservation)
- ✅ Auto-reserved gym class
- 📋 Auto-waitlisted gym class
Deduplication: Notifications tracked in S3 cache with 24-hour TTL. Won’t spam you about the same missing reservation multiple times.
The Code
~800 lines of Go across 10 files:
main.go
- HTTP server, handlers, statscalendar.go
- Google Calendar sync logicintervals.go
- Intervals.icu API clientgym_sync.go
- Gym reservation sync (the big one, ~850 lines)pushover.go
- Notificationss3_storage.go
- Cache persistenceconstants.go
- Transit buffers, config
Most complex part: gym_sync.go
. Handles reservation detection, auto-reservation, waitlist updates, tentative status, full class detection. Every edge case I’ve hit is in there.
See also: Part 1: Building with Claude
🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com