Files
geniusrun/backend/internal/store/activities.go
Christophe Vila 8ff3d62b2f Add Race workout kind with Garmin eventType auto-detection, sync-time running filter, and pill-based review queue filter
Race is the 8th fixed workout kind, seeded with a real (not placeholder)
rule since Garmin Connect's eventType.typeKey reports "race" for
manually-tagged race activities. Non-running activity types (padel,
cycling, strength training, ...) are now dropped at sync time instead of
being stored. The Review Queue's type filter is now clickable exclusive
pill buttons instead of a dropdown.
2026-07-19 10:52:09 +02:00

263 lines
9.7 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
)
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
// from the Garmin/MCP response happens in internal/sync, not here, so this
// package stays independent of internal/garmin.
type Activity struct {
ID int64
GarminActivityID int64
ActivityName string
ActivityType string
EventTypeKey string
StartTimeUTC string
BeginTimestampMs int64
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
Calories *float64
LapCount int
AerobicTrainingEffect *float64
AnaerobicTrainingEffect *float64
TrainingEffectLabel string
VO2MaxValue *float64
HrTimeInZone1 *float64
HrTimeInZone2 *float64
HrTimeInZone3 *float64
HrTimeInZone4 *float64
HrTimeInZone5 *float64
RawJSON string
DetailsFetchedAt *string
DetailsRawJSON *string
SplitsFetchedAt *string
CreatedAt string
UpdatedAt string
}
// UpsertActivity inserts a new activity or updates the existing row for the
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
// returns its internal id.
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
activity_name=excluded.activity_name,
activity_type=excluded.activity_type,
event_type_key=excluded.event_type_key,
start_time_utc=excluded.start_time_utc,
begin_timestamp_ms=excluded.begin_timestamp_ms,
duration_seconds=excluded.duration_seconds,
distance_meters=excluded.distance_meters,
avg_hr=excluded.avg_hr,
max_hr=excluded.max_hr,
avg_speed_mps=excluded.avg_speed_mps,
max_speed_mps=excluded.max_speed_mps,
elevation_gain_m=excluded.elevation_gain_m,
elevation_loss_m=excluded.elevation_loss_m,
calories=excluded.calories,
lap_count=excluded.lap_count,
aerobic_training_effect=excluded.aerobic_training_effect,
anaerobic_training_effect=excluded.anaerobic_training_effect,
training_effect_label=excluded.training_effect_label,
vo2max_value=excluded.vo2max_value,
hr_time_in_zone_1=excluded.hr_time_in_zone_1,
hr_time_in_zone_2=excluded.hr_time_in_zone_2,
hr_time_in_zone_3=excluded.hr_time_in_zone_3,
hr_time_in_zone_4=excluded.hr_time_in_zone_4,
hr_time_in_zone_5=excluded.hr_time_in_zone_5,
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.StartTimeUTC,
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
a.TrainingEffectLabel, a.VO2MaxValue,
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
a.RawJSON,
)
if err != nil {
return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err)
}
var id int64
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
}
return id, nil
}
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.StartTimeUTC,
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
&a.TrainingEffectLabel, &a.VO2MaxValue,
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at,
created_at, updated_at
`
// GetActivity fetches one activity by its internal id.
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
a, err := scanActivity(row)
if err == sql.ErrNoRows {
return Activity{}, false, nil
}
if err != nil {
return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err)
}
return a, true, nil
}
// ActivityFilter narrows ListActivities results. Zero values mean "no filter".
type ActivityFilter struct {
FromDate string // inclusive, "YYYY-MM-DD"
ToDate string // inclusive, "YYYY-MM-DD"
Limit int
Offset int
}
// ListActivities returns activities newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
var args []any
if f.FromDate != "" {
query += ` AND start_time_utc >= ?`
args = append(args, f.FromDate)
}
if f.ToDate != "" {
query += ` AND start_time_utc <= ?`
args = append(args, f.ToDate+" 23:59:59")
}
query += ` ORDER BY start_time_utc DESC`
if f.Limit > 0 {
query += ` LIMIT ? OFFSET ?`
args = append(args, f.Limit, f.Offset)
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list activities: %w", err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
var t string
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("latest activity start time: %w", err)
}
return t, true, nil
}
// SetActivityDetails records that get_activity_details has been fetched for
// this activity, storing the raw response for future reprocessing.
func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
WHERE id = ?`, rawJSON, activityID)
if err != nil {
return fmt.Errorf("set activity %d details: %w", activityID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?`, activityID)
if err != nil {
return fmt.Errorf("set activity %d splits fetched: %w", activityID, err)
}
return nil
}
// ActivitiesMissingDetails returns activities that haven't had
// get_activity_details/get_activity_splits fetched yet, for the lazy
// background detail-fill pass.
func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list activities missing details: %w", err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// CountActivitiesMissingDetails returns how many activities still need
// get_activity_details/get_activity_splits fetched, regardless of any
// per-call batch limit -- used to report overall remaining work.
func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count activities missing details: %w", err)
}
return n, nil
}