P247 Feature Spec: Activity Images

Version: 1.1 Date: 27 March 2026 Author: James (AI EA) Status: In development Depends on: Strava activity sync (existing), Agent image support (existing)


0. Summary

Athletes want to attach images to their activities for two distinct purposes:

  1. Program capture — photograph the gym whiteboard/board to record the day’s programming. The AI coach reads the image (OCR/vision) and extracts exercises, sets, reps into structured data. Also useful for recording form videos (frame captures).
  2. Moments — scenic run photos, gym selfies, training milestones. Memory/motivation content tied to a specific activity and location.

Both types attach to a Strava activity (by strava_id). Multiple images per activity (up to 5). Viewable on the activity detail screen with horizontal scroll.


1. Image Types

Type Tag Purpose AI Processing
Program program Whiteboard, workout plan, exercise list Vision model extracts exercises, sets, reps, notes
Form form Selfie, form check, progress photo Stored only (future: form analysis)
Moment moment Scenic, milestone, social Stored only, displayed in activity detail

2. Design Decisions (locked 27 March 2026)

  1. Upload format: Multipart/form-data only. Base64 adds ~33% payload bloat; iOS URLSession handles multipart natively.
  2. strava_id vs activity_date: Enforced at model level via CHECK constraint. Exactly one must be non-null.
  3. Image limit (5 per activity): Only active (non-deleted) images count toward the limit.
  4. Delete behaviour: Soft delete via deleted_at timestamp. Cleanup job later (30 days). No accidental permanent loss.
  5. Image serving auth: Public paths (no x-api-key). URLs contain user_id + unique filename so not guessable. Signed URLs can be added later.
  6. URL format: /images/serve/{user_id}/activities/{filename} — namespaced by user, grouped by content type.
  7. Ordering: Newest first on GET /activities/{strava_id}/images. Most recent photo shows first.
  8. Format normalisation: All outputs normalised to JPEG (including PNG/WebP input). One code path, consistent format.
  9. Thumbnail spec: Longest edge = 400px, aspect ratio preserved.
  10. Extraction status: Three states: pending, succeeded, failed. Clients can show spinner/data/error accordingly.
  11. Vision failure: Upload always succeeds. Failed extraction sets extraction_status = failed and populates extraction_error. Never block an upload because AI couldn’t parse.
  12. Auto-linking by date: When a Strava activity arrives and unlinked images exist within ±2 hours of activity start, auto-attach with auto_linked = true. Surface for user confirmation.
  13. Activity GET images: Compact by default (thumbnail URL, type, caption, extraction_status). Full detail via ?include=images.full or per-image endpoint.
  14. Caption limit: 500 chars, UTF-8. Emojis allowed. Control chars stripped.
  15. Location fields: location_lat and location_lng stored on upload (from EXIF). Display later.

3. Backend Changes

3.1 Database Model: ActivityImage

class ActivityImage(Base):
    __tablename__ = "activity_images"

    id: str              # uuid hex
    user_id: int         # FK users.id
    strava_id: int|None  # FK strava_activities.strava_id (nullable)
    activity_date: str|None  # YYYY-MM-DD (nullable)
    # CHECK: exactly one of strava_id or activity_date must be non-null
    image_type: str      # 'program' | 'form' | 'moment'
    file_path: str       # images/{user_id}/activities/{filename}
    thumbnail_path: str|None
    caption: str|None    # max 500 chars
    extracted_data: dict|None       # JSON for program type
    extraction_status: str|None     # 'pending' | 'succeeded' | 'failed'
    extraction_error: str|None      # error message if extraction failed
    auto_linked: bool    # True if auto-attached by date matching
    location_lat: float|None
    location_lng: float|None
    taken_at: datetime|None
    width: int|None
    height: int|None
    file_size_bytes: int|None
    created_at: datetime
    deleted_at: datetime|None       # soft delete

3.2 Endpoints

POST /activities/{strava_id}/images (multipart/form-data)

  • Fields: image (file), image_type (program/form/moment), caption (optional, max 500 chars)
  • Accepts JPEG, PNG, WebP, HEIC. All normalised to JPEG output.
  • Resizes to max 2048px for storage, generates 400px thumbnail (longest edge, aspect preserved)
  • For program type: vision extraction runs synchronously. Returns extraction_status.
  • Enforces max 5 active images per activity.
  • Returns: full image response dict

GET /activities/{strava_id}/images

  • Returns all active images for an activity, ordered newest first
  • Each image includes: id, image_type, caption, thumbnail_url, full_url, extracted_data, extraction_status, location, taken_at

DELETE /activities/{strava_id}/images/{image_id}

  • Soft delete (sets deleted_at). Files remain on disk for 30 days.

POST /activities/images/upload (multipart/form-data)

  • For non-Strava activities (manual uploads by date)
  • Fields: date (YYYY-MM-DD), image (file), image_type, caption
  • When a matching Strava activity later syncs, auto-links with auto_linked = true

GET /images/serve/{path:path}

  • Static file serving, no auth. Catch-all path route.

3.3 Program Image Vision Extraction

When a program type image is uploaded, Claude vision extracts structured workout data synchronously. On success: extraction_status = succeeded, data stored in extracted_data. On failure: extraction_status = failed, error in extraction_error, upload still succeeds.

3.4 Storage Layout

data/images/{user_id}/activities/
  {uuid12}.jpg          # full size (max 2048px, always JPEG)
  {uuid12}_thumb.jpg    # thumbnail (400px longest edge, always JPEG)

3.5 Activity Detail Integration

GET /activities/{strava_id} includes compact images by default:

{
  "images": [
    {
      "id": "abc123",
      "type": "program",
      "caption": null,
      "thumbnail_url": "/images/serve/1/activities/abc123_thumb.jpg",
      "extraction_status": "succeeded"
    }
  ]
}

With ?include=images.full, each image includes full_url, extracted_data, location, dimensions, file_size.


4. Implementation Checklist

Backend

  • Create ActivityImage model (with deleted_at, extraction_status, extraction_error, auto_linked)
  • CHECK constraint: exactly one of strava_id or activity_date
  • POST /activities/{strava_id}/images endpoint (multipart upload)
  • GET /activities/{strava_id}/images endpoint (newest first, active only)
  • DELETE /activities/{strava_id}/images/{image_id} endpoint (soft delete)
  • POST /activities/images/upload (manual/date-based, multipart)
  • Static image serving (no auth)
  • All formats normalised to JPEG
  • Thumbnail: 400px longest edge, aspect preserved
  • Vision extraction with status tracking (pending/succeeded/failed)
  • Upload never fails on vision error
  • Caption validation (500 chars max)
  • Only active images count toward 5-image limit
  • Compact images in GET /activities/{strava_id}, full with query param
  • Register model in __init__.py
  • Auto-link date-uploaded images when Strava activity syncs (future iteration)

iOS (future brief)

  • Camera/gallery button on activity detail screen
  • Image type picker (Program / Form / Moment)
  • Horizontal scroll strip showing activity images
  • Extracted program data display card
  • Upload from camera roll (multipart/form-data)

5. Constraints

  • Max 5 active images per activity (soft-deleted don’t count)
  • Max 10MB per image (before resize)
  • Accepted input formats: JPEG, PNG, WebP, HEIC
  • Output format: always JPEG
  • Caption: max 500 chars UTF-8, control chars stripped
  • Storage: local disk (future: S3/CDN)
  • Vision extraction: synchronous, non-blocking on failure
  • Auth: same x-api-key pattern as all endpoints (except image serving which is public)
  • Soft delete with 30-day retention before cleanup