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.
263 lines
9.7 KiB
Go
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
|
|
}
|