twenty-four: strava service

The Strava service processes activities automatically. Adds emojis, tags commutes, mutes short walks, assigns gear, and syncs to Intervals.icu.

The Problem

Every Strava activity needs housekeeping:

  • Add emoji to activity name (🏃 Run, 🚴 Ride, 💪 Strength, etc.)
  • Tag commutes (home ↔ work, home ↔ dojo)
  • Assign the correct gear (roadie, gravel, trainer, running shoes)
  • Mute short walks from the feed (< 2km)
  • Hide activities that shouldn’t be public

This is tedious when you upload 15+ activities a week.

The Solution

Automated processing via Strava API:

1
2
3
4
5
6
7
8
1. Fetch recent activities (last 30 days)
2. For each activity:
   - Add emoji to name if missing
   - Detect commute routes (home ↔ work, home ↔ dojo)
   - Assign gear based on activity type + tags
   - Mute short walks
   - Hide specific activity types
3. Sync updated activities to Intervals.icu

Runs via CronJob every 15 minutes. Processes new activities, ignores already-processed ones.

Emoji Injection

Activity names get emoji prefixes based on type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
EMOJI_MAP = {
    "Run": "🏃",
    "Ride": "🚴",
    "VirtualRide": "🚴",
    "Swim": "🏊",
    "Walk": "🚶",
    "WeightTraining": "💪",
    "Workout": "💪",
}

def add_emoji(activity):
    activity_type = activity.type
    emoji = EMOJI_MAP.get(activity_type, "")

    if emoji and not activity.name.startswith(emoji):
        new_name = f"{emoji} {activity.name}"
        client.update_activity(activity.id, name=new_name)

Special cases:

Metabolic conditioning classes (from gym) get ❤️‍🔥:

1
2
if "HIIT" in activity.description:
    emoji = "❤️‍🔥"

This matches the gym service emoji mapping, so activities created manually vs. synced from ICU look consistent.

Commute Detection

Strava has commute tagging, but it’s manual. Automate it based on start/end location.

Coordinates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
HOME = (47.xxxx, -122.xxxx)
WORK = (47.xxxx, -122.xxxx)

def is_near(coord1, coord2, threshold_km=0.5):
    # Haversine distance
    lat1, lon1 = coord1
    lat2, lon2 = coord2

    R = 6371  # Earth radius in km
    dlat = radians(lat2 - lat1)
    dlon = radians(lon2 - lon1)

    a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
    c = 2 * atan2(sqrt(a), sqrt(1-a))

    return R * c < threshold_km

Commute logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def detect_commute(activity):
    if activity.type not in ["Ride", "Run"]:
        return False

    start = (activity.start_latlng.lat, activity.start_latlng.lon)
    end = (activity.end_latlng.lat, activity.end_latlng.lon)

    # Home → Work
    if is_near(start, HOME) and is_near(end, WORK):
        return True

    # Work → Home
    if is_near(start, WORK) and is_near(end, HOME):
        return True

    return False

if detect_commute(activity) and not activity.commute:
    client.update_activity(activity.id, commute=True)

Works great for bike rides. Doesn’t work for runs (I don’t run commute routes). Could add more route detection but haven’t needed it yet.

Gear Assignment

Strava tracks gear (bikes, shoes) but you have to manually assign it to each activity.

Gear IDs (fetched once from Strava API):

1
2
3
4
5
GEAR = {
    "roadie": "b12345",      # Road bike
    "gravel": "b67890",      # Gravel bike
    "trainer": "b11111",     # Indoor trainer
}

Assignment logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def assign_gear(activity):
    if activity.type == "Ride":
        # Check if indoor (trainer)
        if activity.trainer:
            return GEAR["trainer"]

        # Check if commute (always gravel bike)
        if activity.commute:
            return GEAR["gravel"]

        # Default: road bike
        return GEAR["roadie"]

    return None

gear_id = assign_gear(activity)
if gear_id and activity.gear_id != gear_id:
    client.update_activity(activity.id, gear_id=gear_id)

Walk Muting

Short walks (< 2km) clutter the feed. Mute them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def should_mute_walk(activity):
    if activity.type != "Walk":
        return False

    distance_km = activity.distance / 1000

    if distance_km < 2.0:
        return True

    return False

if should_mute_walk(activity) and not activity.hide_from_home:
    client.update_activity(activity.id, hide_from_home=True)

This hides the activity from followers’ feeds but keeps it in my activity list. Lots of 1:1s.

API Rate Limits

Strava API has rate limits:

  • 100 requests per 15 minutes
  • 1000 requests per day

With 15+ activities per week and processing every 15 minutes, this is tight.

Optimization:

Only process activities modified in the last 30 days:

1
2
after = int(time.time()) - (30 * 24 * 60 * 60)
activities = client.get_activities(after=after)

Batch updates where possible (though Strava API doesn’t support true batching).

Use cache to skip already-processed activities.

Haven’t hit the rate limit yet, but it’s close during high-volume weeks.

Endpoints

1
2
3
4
5
6
GET  /status   - Processing status
GET  /logs     - Recent logs
POST /sync     - Trigger manual sync
GET  /gear     - List configured gear
GET  /health   - Health check
GET  /version  - Git SHA version

The CronJob hits /sync every 15 minutes. Can also trigger manually after uploading a new activity.

What Worked

Emoji automation: Scanning my Strava feed is way easier when activities have visual prefixes.

Commute detection: Tagging commutes automatically keeps my fans from getting bored.

Gear assignment: Forgot what this was like before. Now I just upload activities and the right gear gets assigned. Still need to do shoes…

Walk muting: Lots of short walks.


See also: Part 0: The Platform | Part 1: Building with Claude | Part 2: Calendar Service | Part 3: Gym Service | Part 5: Workout Generator | Part 6: AI Recommendations | Part 7: Service Consolidation | Part 8: What’s Next

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com