Skip to content

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) and profile/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:

  1. Date must match (required gate)
  2. Types must be compatible (required gate) - some types are equivalent:
  3. climbing / bouldering
  4. yoga / stretching
  5. sauna / recovery
  6. Duration within 10% (required gate)
  7. Distance within 5% (optional, +0.2 confidence)
  8. 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:

  1. Already logged: Builds a set of rwgps_trip_id values from existing occurrences
  2. Nearby manual occurrence: Checks if a manual occurrence exists within 4 hours of the RWGPS trip start time for the same component
  3. Engine-level dedup: DuplicateError catch 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 /log command doc still references the legacy per-day data/activities/YYYY-MM-DD.json files and the old zone_2_cardio/max_effort_interval goal components; the live system uses the unified store and the merged bike component.

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.5 hours (7-day average from data/garmin/).
  • Only components in HIGH_INTENSITY_COMPONENTS are gated: max_effort_interval (legacy), bike_intervals, intervals, hiit, sprints, ftp, max_effort. Anything else returns is_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.