Activities System
The activities system provides a unified view of physical activities across three data sources: Garmin Connect, Ride With GPS (RWGPS), and manual logs. Activities from all sources are normalized to a common format, deduplicated across sources, and stored in yearly JSONL files. Precomputed aggregates power the overview, heatmap, HR zones, streaks, and year-over-year comparison endpoints without scanning raw data on every request.
System Architecture
+-------------------+ +-------------------+ +-------------------+
| Garmin Connect | | Ride With GPS | | Manual Logs |
| (garmin-sync.py | | (RWGPS API sync) | | (Quick-Log API) |
| --sync-activities)| | | | |
+--------+----------+ +--------+----------+ +--------+----------+
| | |
v v v
+------------------------------------------------------------------+
| Deduplication Layer |
| scripts/activities/dedup.py |
| Cross-source matching: date + type + duration + distance + time |
| Confidence: >=0.6 auto-link, 0.4-0.6 log for review, <0.4 skip |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Unified Store (data/activities/unified/) |
| One JSONL file per year |
| 2007.jsonl ... 2026.jsonl |
+------------------------------------------------------------------+
|
+----+----+
| |
v v
+--------+ +--------+ +--------+ +--------+
| Year | | Streaks| | Records| |ID Index|
| Aggs | | .json | | .json | | .json |
| YYYY | +--------+ +--------+ +--------+
| .json |
+--------+
|
v
+------------------------------------------------------------------+
| API Layer |
| api/src/routers/activities.py |
| 10 endpoints: overview, heatmap, list, detail, zones, |
| weekly-objectives, quick-log, delete, sync, sync-status |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Web UI |
| Activity Showcase Page |
+------------------------------------------------------------------+
Data Sources
Garmin Connect
Garmin activities are synced via scripts/garmin-sync.py --sync-activities. Each Garmin activity is mapped to a unified type via GARMIN_TYPE_MAP (defined at scripts/garmin-sync.py:51; cycling, running, walking, strength, yoga, swimming, bouldering, skating, etc., with other as the fallback) and appended to the year's JSONL file. Sync status is tracked in data/activities/sync_status.json. See Garmin for the broader Garmin sync pipeline.
Ride With GPS
RWGPS trip metadata is synced via scripts/rwgps_sync.py (also exposed as /sync-rwgps), which pulls from the RideWithGPS personal API incrementally by updated_at (or --full for a complete resync) into data/rwgps/trips.json, then normalizes each trip into the unified store via rwgps_to_unified() in scripts/activities/migrate.py. Credentials live in ~/.config/vita/rwgps.json.
/sync-rwgps [--full]
uv run python scripts/rwgps_sync.py [--full]
Each trip is classified by the RWGPS analyzer (scripts/rwgps/analyzer.py) as zone_2_cardio, max_effort_interval, too_short, or unclassified with a confidence score. Trips shorter than the minimum countable duration (min_countable_duration_seconds, default 900s = 15 min) are classified too_short. Classifications are stored in data/rwgps/analyses/index.json. Countable activities (classified zone_2_cardio or max_effort_interval) are auto-logged to training goal occurrences via scripts/scheduling/rwgps_auto_logger.py.
Note: The analyzer runs a hierarchical rule chain (excluded/eBike gear -> too-short -> HR -> power -> pace/speed -> duration fallback) configured by
profile/training_zones.json(classification thresholds) andprofile/gear.json(gear types, excluded names,unknown_gear_action). It also flags anomalies (unknown gear, etc.) and notifies a coach topic. CLI:python scripts/rwgps/analyzer.py --pending|--trip ID|--reanalyze-all|--list-anomalies|--rebuild-index.
Full-fidelity track pipeline. Beyond trip metadata, scripts/rwgps_sync.py downloads gzipped per-trip GPS+HR+power track points (sync_trip_track, backfill_tracks, sync_tracks_incremental) into data/rwgps/tracks/*.json.gz, with per-trip processing state in data/rwgps/state/ and a resumable backfill checkpoint at data/rwgps/backfill_checkpoint.json. verify_track_file / cleanup_corrupt_files detect and remove corrupt downloads. These tracks feed the analyzer above.
RWGPS data is stored per-trip:
- data/rwgps/tracks/{trip_id}.json.gz - Raw GPS track data (compressed)
- data/rwgps/analyses/{trip_id}.json - Per-trip analysis details
- data/rwgps/analyses/index.json - Classification index for all trips
- data/rwgps/analyses/anomalies.json - Flagged anomalies (missing gear, etc.)
- data/rwgps/state/{trip_id}.json - Per-trip processing state
Manual Logs
Activities can be created via the POST /api/activities/quick-log endpoint. Manual activities require a type and duration, with optional name, date, and notes. An idempotency key (UUID v4) prevents double-submission.
Validation constraints:
- duration_minutes: 1-1440 (1 min to 24 hours)
- date: not future, max 30 days in the past
- idempotency_key: required, UUID v4
Activity Types
The system recognizes 13 activity types:
| Type | Sources |
|---|---|
cycling |
Garmin, RWGPS |
running |
Garmin |
walking |
Garmin |
climbing |
Garmin |
bouldering |
Garmin |
skating |
Manual, Garmin (name-based) |
swimming |
Garmin |
strength |
Garmin, Manual |
yoga |
Garmin |
stretching |
Garmin |
sauna |
Garmin |
recovery |
Garmin |
other |
Manual (fallback) |
Activity Matching and Deduplication
The same physical activity can be recorded by multiple devices simultaneously (e.g., a ride tracked by both a Garmin watch and the RWGPS phone app). The dedup system detects these and links them rather than showing duplicates.
Location: scripts/activities/dedup.py
Cross-Source Matching Algorithm
Same-source duplicates use source_id only (Garmin activity ID or RWGPS trip ID). Cross-source matching uses a confidence-scored approach:
- Date must match (required gate)
- Types must be compatible (required gate) - some types are equivalent:
- climbing / bouldering
- yoga / stretching
- sauna / recovery
- Duration within 10% (required gate)
- Distance within 5% (optional, +0.2 confidence)
- Start time within 30 minutes (optional, +0.2 confidence)
Confidence Scoring
| Criteria | Score |
|---|---|
| Type match | +0.3 |
| Duration match | +0.3 |
| Distance match | +0.2 |
| Start time match | +0.2 |
| Max possible | 1.0 |
Confidence Thresholds
| Range | Action |
|---|---|
| >= 0.6 | Auto-link (set linked_activity_id) |
| 0.4-0.6 | Log to data/activities/dedup_log.jsonl for manual review |
| < 0.4 | No match |
Unified Activity Schema
Each activity in data/activities/unified/YYYY.jsonl is a JSON line:
{
"id": "8c0a96c3-1114-42df-bb86-de23ba599e73",
"source": "rwgps",
"source_id": "359158408",
"date": "2026-01-02",
"start_time": "2026-01-02T22:58:49Z",
"end_time": "2026-01-03T00:09:27Z",
"type": "cycling",
"name": "64 min Just Ride with JUST RIDE",
"duration_minutes": 64,
"distance_miles": 22.52,
"elevation_ft": null,
"hr_avg": null,
"hr_max": null,
"hr_zones": {"Z1": 10.5, "Z2": 62.3, "Z3": 18.1, "Z4": 7.2, "Z5": 1.9},
"classification": "zone_2_cardio",
"notes": null,
"created_at": "2026-01-06T15:13:34.415162Z",
"updated_at": "2026-01-06T15:13:34.415162Z",
"linked_activity_id": null,
"idempotency_key": null
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
UUID | Yes | Unique identifier |
source |
enum | Yes | garmin, rwgps, or manual |
source_id |
string | No | Source-specific ID (Garmin activity ID, RWGPS trip ID) |
date |
YYYY-MM-DD | Yes | Activity date in user's timezone |
start_time |
ISO8601 | No | Start time with offset |
end_time |
ISO8601 | No | End time with offset |
type |
enum | Yes | One of the 13 activity types |
name |
string | Yes | Display name |
duration_minutes |
float | Yes | Duration (>= 0) |
distance_miles |
float | No | Distance in miles |
elevation_ft |
float | No | Elevation gain in feet |
hr_avg |
int | No | Average heart rate (0-250) |
hr_max |
int | No | Max heart rate (0-250) |
hr_zones |
object | No | HR zone distribution (Z1-Z5 percentages) |
classification |
string | No | Training classification (zone_2_cardio, max_effort_interval) |
notes |
string | No | Free-text notes (max 2000 chars) |
created_at |
ISO8601 | Yes | Record creation time |
updated_at |
ISO8601 | Yes | Last update time |
linked_activity_id |
UUID | No | Cross-source duplicate link |
idempotency_key |
UUID | No | For manual activity dedup |
Aggregation System
Precomputed aggregates avoid scanning raw JSONL files on every API request. All aggregate updates are atomic (all-or-nothing) via atomic_write_multi() which writes to temp files then renames.
Location: scripts/activities/aggregate.py
Per-Year Aggregates (data/activities/aggregates/YYYY.json)
Updated on every activity add/delete:
- total_hours / total_activities - year totals
- by_type - count and hours per activity type
- by_month - count and hours per month (12 entries, all months)
- heatmap - sparse format, only days with activities (minutes, count, types per day)
Streaks (data/activities/aggregates/streaks.json)
Fully recalculated on every change (scans all unified files):
- current - streak ending today or yesterday (days, start_date)
- best - longest streak ever found (days, start_date, end_date)
- last_activity_date
Records (data/activities/aggregates/records.json)
Placeholder for personal records (longest duration per type, longest distance, highest elevation). Structure exists but not yet populated.
ID Index (data/activities/id_index.json)
Maps activity UUID to year and line number for O(1) lookup:
{
"schema_version": 1,
"entries": {
"8c0a96c3-1114-42df-bb86-de23ba599e73": {"year": 2026, "line": 0}
}
}
Without this, looking up a single activity by ID would require scanning all year files.
HR Zones Aggregation
The zones endpoint computes a duration-weighted average of HR zone distributions across all activities with HR data for a given year (optionally filtered by month).
Each activity stores HR zones as percentages (Z1-Z5 summing to 100%). The aggregate weights each activity's zone distribution by its duration:
weighted_zone = sum(activity.zones[Z] * activity.duration) / total_duration
This gives a representative picture of training intensity distribution.
Heatmap Data
The heatmap uses a sparse format -- only days with activities have entries. Each entry contains:
- minutes - total activity minutes that day
- count - number of activities
- types - list of unique activity types
This powers the year-view activity calendar in the web UI.
Training Goal Occurrence Logging
Activities are automatically matched to training goal components via two systems:
Garmin Activity Matcher
Location: scripts/scheduling/activity_matcher.py
Matches Garmin activities to weekly training structure components. First match wins (order matters).
| Garmin Type | Component | Min Duration | Auto-Confirm |
|---|---|---|---|
strength_training |
strength_days |
20 min | Yes |
indoor_cycling |
bike |
20 min | Yes |
cycling |
bike |
20 min | Yes |
running |
bike |
20 min | Yes |
hiit |
bike |
10 min | Yes |
elliptical |
bike |
20 min | Yes |
cardio |
bike |
20 min | Yes |
Name-based matching (requires confirmation):
- Names containing "skate", "skating", "skateboard", "bowl", "park session" -> skate_sessions (30 min)
RWGPS Auto Logger
Location: scripts/scheduling/rwgps_auto_logger.py
Automatically logs countable RWGPS trips as training goal occurrences. Deduplication checks:
- Already logged: Builds a set of
rwgps_trip_idvalues from existing occurrences - Nearby manual occurrence: Checks if a manual occurrence exists within 4 hours of the RWGPS trip start time for the same component
- Engine-level dedup:
DuplicateErrorcatch as final safety net
Classification-to-component mapping (map_to_component): both zone_2_cardio and max_effort_interval now map to the single bike component (the two legacy intensity components were merged in Apr 2026; see Training goals). Any other analyzer classification is unmappable and skipped.
Supports --all-time for initial backfill or --since-days N (default 30) for incremental runs.
Manual Logging via /log
The /log slash command (.claude/commands/log.md) is the natural-language entry point for manual activities. Free text ("45 min strength, felt strong") is parsed into a structured entry (type, name, duration, intensity, details) and routed through the same dedup + aggregate pipeline as the quick-log API, then logged as a training-goal occurrence. Duration is validated warn-only via validate_duration() in scripts/validate.py (a duration over 5 hours warns but does not block; a negative duration returns an error string).
Note: The
/logcommand doc still references the legacy per-daydata/activities/YYYY-MM-DD.jsonfiles and the oldzone_2_cardio/max_effort_intervalgoal components; the live system uses the unified store and the mergedbikecomponent.
Training Goal Structure
Training goals track weekly training structure against a single goal stored in goals/active/*.json with three components, each driven by activities from /log, /sync-rwgps, and Precedent habits:
| Component | Weekly target |
|---|---|
skate_sessions |
2 / week |
bike |
3 / week |
strength_days |
2 / week |
The bike component absorbed the old zone_2_cardio + max_effort_interval components in Apr 2026 (historical occurrences migrated). Occurrences are written through log_occurrence() in scripts/scheduling/engine.py; weekly progress surfaces via the /api/activities/weekly-objectives endpoint. See the training-goals skill (.claude/skills/training-goals/SKILL.md) for mapping rules.
Sleep Gate for High-Intensity Training
Per a board resolution (tracked in data/board/resolution_tracking.json), high-intensity training is suspended when the 7-day Garmin sleep average falls below the threshold. The gate is checked at /checkin, when training is mentioned, and before logging interval work.
Location: scripts/scheduling/sleep_gate.py
from scripts.scheduling.sleep_gate import check_sleep_gate
result = check_sleep_gate("bike_intervals")
# result.is_blocked, result.avg_sleep_hours, result.message
SLEEP_THRESHOLD = 6.5hours (7-day average fromdata/garmin/).- Only components in
HIGH_INTENSITY_COMPONENTSare gated:max_effort_interval(legacy),bike_intervals,intervals,hiit,sprints,ftp,max_effort. Anything else returnsis_blocked=False. - If sleep data is unavailable, the gate fails open (
is_blocked=False).
See Sleep Guardian for the predictive counterpart that tries to prevent the gate from tripping.
Adjacent Sync Sources
These pipelines feed activities into the store or into Garmin (which then flows back through Garmin sync). They are health/fitness-flavored β the engine is documented here; contents are private.
CGM / FreeStyle Libre
scripts/libre-sync.py pulls continuous glucose data from the LibreLinkUp follower API β a 12-hour graph window (readings every ~15 min) plus a ~14-day logbook β into per-day data/cgm/YYYY-MM-DD.json files with computed daily stats. The scheduler (api/src/scheduler.py, _run_cgm_sync) runs it every 2 hours (CGM_SYNC_INTERVAL = 2 * 60 * 60) to overlap the 12h API window. When no sensor is worn, syncs return an empty_success state (zero readings, pipeline healthy) rather than an error.
/sync-libre
uv run python scripts/libre-sync.py [--status|--dump]
| File | Purpose |
|---|---|
scripts/libre-sync.py |
LibreLinkUp fetch + per-day write |
data/cgm/YYYY-MM-DD.json |
Per-day glucose readings + stats |
data/cgm/sync_status.json |
Last-sync state |
.claude/skills/sync-libre/SKILL.md |
Skill doc |
Peloton -> Garmin (P2G)
The sync-peloton skill drives a locally-running peloton-to-garmin deployment (two Docker containers in ~/work/p2g/, p2g-api on port 8001): it fetches recent Peloton workouts, converts them to FIT, and uploads to Garmin Connect β from which they flow back into the unified store via the normal Garmin sync. The container also polls on its own ~24h schedule. The P2G project is external to the vita repo; only the skill lives here, and there is no vita-side data/ dir for it.
/sync-peloton
# (in ~/work/p2g) docker compose up -d
curl -s "http://localhost:8001/api/sync"
Calisthenics Rep Counter
A lightweight daily pushup/pullup counter (scripts/calisthenics/__init__.py), independent of the unified activity store. Per-day totals plus an append-only action log live in data/calisthenics/YYYY-MM-DD.json:
{"date": "2026-01-15", "pushups": 50, "pullups": 20,
"log": [{"ts": "2026-01-15T08:00:00Z", "kind": "pushups", "delta": 25}]}
API: add_count(kind, delta) (valid kinds: pushups, pullups; totals floored at 0) and reset_today(kind=None). It has no slash command β reps are entered and displayed via hardware peripherals: the Ajazz stream dock (scripts/streamdock/manager.py, api/src/routers/streamdock.py) and the EPD47 e-ink coach display (scripts/eink/epd47_coach.py).
Soft Deletes
Activities are never physically removed from JSONL files. Instead, deletions are appended to data/activities/deleted.jsonl:
{"id": "uuid", "deleted_at": "2026-01-15T10:00:00Z", "reason": "duplicate"}
All read operations filter against the deleted IDs set. Aggregate counters are decremented (never below 0), and heatmap entries are removed when count reaches 0.
API Endpoints
All endpoints are prefixed with /api/activities.
| Endpoint | Method | Description | Key Parameters |
|---|---|---|---|
/overview |
GET | YTD stats, streaks, adherence, records, year comparison | ?year= (default: current) |
/heatmap |
GET | Sparse heatmap data for year calendar view | ?year= |
/ |
GET | Paginated activity list with filters | ?start=&end=&type=&source=&q=&cursor=&limit= |
/{id} |
GET | Single activity detail | Path: activity UUID |
/zones |
GET | HR zone duration-weighted aggregate | ?year=&month= |
/weekly-objectives |
GET | Weekly training structure progress | ?year=&week= |
/quick-log |
POST | Create manual activity (201) | Body: type, duration_minutes, idempotency_key |
/{id} |
DELETE | Soft-delete activity (204) | Body: optional reason |
/sync |
POST | Trigger Garmin/RWGPS sync (202) | Body: source ("garmin" or "rwgps") |
/sync/{job_id} |
GET | Sync job status | Path: job UUID |
List Endpoint Details
- Date range: defaults to last 30 days
- Type filter: accepts multiple values (e.g.,
?type=cycling&type=running) - Source filter: single value (
garmin,rwgps,manual) - Search:
?q=does case-insensitive substring match on activity name - Pagination: cursor-based (base64-encoded date|id),
?limit=1-100 (default 50)
Weekly Objectives Endpoint
Returns progress against the weekly training structure goal. For each component (skating, zone 2, intervals, strength), reports current count, target, completion status, and occurrence dates for the requested ISO week.
Data Migration
Location: scripts/activities/migrate.py
One-time migration script that converts historical Garmin, RWGPS, and manual activity data to the unified format. Also runs cross-source deduplication and rebuilds all aggregates and the ID index from scratch.
Location: scripts/activities/backfill_occurrences.py
Backfills unified activity records from historical training goal occurrences (JSONL in data/scheduling/). COMPONENT_TYPE_MAP keys are the three current training-goal component keys (not RWGPS classifications), each with a default duration used when the occurrence note has no parseable duration:
| Component | Activity type | Default duration |
|---|---|---|
skate_sessions |
skating |
60 min |
bike |
cycling |
45 min |
strength_days |
strength |
45 min |
File Locations
| File | Purpose |
|---|---|
api/src/routers/activities.py |
API route definitions (10 endpoints) |
api/src/services/activities.py |
Business logic, file I/O, aggregation |
api/src/schemas/activities.py |
Pydantic request/response models |
scripts/activities/dedup.py |
Cross-source deduplication with confidence scoring |
scripts/activities/aggregate.py |
Aggregate computation and atomic multi-file writes |
scripts/activities/index.py |
ID index for O(1) activity lookup |
scripts/activities/migrate.py |
One-time migration from legacy format |
scripts/activities/backfill_occurrences.py |
Backfill unified records from scheduling occurrences |
scripts/scheduling/activity_matcher.py |
Garmin activity to training goal matching |
scripts/scheduling/rwgps_auto_logger.py |
RWGPS trip auto-logging to training goals |
scripts/scheduling/engine.py |
log_occurrence() / occurrence engine + DuplicateError |
scripts/scheduling/sleep_gate.py |
7-day sleep gate for high-intensity training |
scripts/rwgps_sync.py |
RWGPS trip + full-fidelity track sync |
scripts/rwgps/analyzer.py |
RWGPS track analysis + activity classification |
scripts/garmin-sync.py |
Garmin activity sync (GARMIN_TYPE_MAP at :51) |
scripts/libre-sync.py |
FreeStyle Libre CGM sync |
scripts/calisthenics/__init__.py |
Daily pushup/pullup counter |
.claude/commands/log.md |
/log natural-language manual logging |
data/activities/unified/YYYY.jsonl |
Unified activity store (one file per year, 2007-2026) |
data/activities/aggregates/YYYY.json |
Per-year precomputed aggregates |
data/activities/aggregates/streaks.json |
Current and best activity streaks |
data/activities/aggregates/records.json |
Personal records (placeholder) |
data/activities/id_index.json |
UUID -> year/line lookup index |
data/activities/deleted.jsonl |
Soft-deleted activity IDs with timestamps |
data/activities/idempotency.json |
Quick-log idempotency key cache |
data/activities/dedup_log.jsonl |
Medium-confidence dedup matches for review |
data/activities/sync_status.json |
Per-source last sync status |
data/rwgps/tracks/{id}.json.gz |
Raw RWGPS GPS track data (compressed) |
data/rwgps/analyses/index.json |
RWGPS trip classification index |
data/rwgps/analyses/{id}.json |
Per-trip analysis details |
data/rwgps/analyses/anomalies.json |
Flagged trip anomalies |
data/rwgps/state/{id}.json |
Per-trip processing state |
data/rwgps/backfill_checkpoint.json |
Resumable track-backfill checkpoint |
data/cgm/YYYY-MM-DD.json |
Per-day CGM glucose readings + stats |
data/calisthenics/YYYY-MM-DD.json |
Per-day calisthenics totals + log |
data/board/resolution_tracking.json |
Board resolutions (incl. sleep gate) |
goals/active/*.json |
Training goal definitions |
Where to go next
- Garmin β the full Garmin sync pipeline that feeds activities.
- Sleep Guardian β predictive counterpart to the sleep gate.
- Goals β how training goals fit the wider goal system.
- Peripherals β stream dock and e-ink surfaces for the rep counter.
- Health Center β the health dashboard activities surface within.