P247 Feature Spec: Activity Images
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:
- 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).
- 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)
- Upload format: Multipart/form-data only. Base64 adds ~33% payload bloat; iOS URLSession handles multipart natively.
- strava_id vs activity_date: Enforced at model level via CHECK constraint. Exactly one must be non-null.
- Image limit (5 per activity): Only active (non-deleted) images count toward the limit.
- Delete behaviour: Soft delete via
deleted_attimestamp. Cleanup job later (30 days). No accidental permanent loss. - Image serving auth: Public paths (no x-api-key). URLs contain user_id + unique filename so not guessable. Signed URLs can be added later.
- URL format:
/images/serve/{user_id}/activities/{filename}— namespaced by user, grouped by content type. - Ordering: Newest first on
GET /activities/{strava_id}/images. Most recent photo shows first. - Format normalisation: All outputs normalised to JPEG (including PNG/WebP input). One code path, consistent format.
- Thumbnail spec: Longest edge = 400px, aspect ratio preserved.
- Extraction status: Three states:
pending,succeeded,failed. Clients can show spinner/data/error accordingly. - Vision failure: Upload always succeeds. Failed extraction sets
extraction_status = failedand populatesextraction_error. Never block an upload because AI couldn’t parse. - 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. - Activity GET images: Compact by default (thumbnail URL, type, caption, extraction_status). Full detail via
?include=images.fullor per-image endpoint. - Caption limit: 500 chars, UTF-8. Emojis allowed. Control chars stripped.
- Location fields:
location_latandlocation_lngstored 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
programtype: vision extraction runs synchronously. Returnsextraction_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
ActivityImagemodel (with deleted_at, extraction_status, extraction_error, auto_linked) - CHECK constraint: exactly one of strava_id or activity_date
POST /activities/{strava_id}/imagesendpoint (multipart upload)GET /activities/{strava_id}/imagesendpoint (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-keypattern as all endpoints (except image serving which is public) - Soft delete with 30-day retention before cleanup