Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
260
backend/internal/store/activities.go
Normal file
260
backend/internal/store/activities.go
Normal file
@@ -0,0 +1,260 @@
|
||||
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
|
||||
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, 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,
|
||||
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.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.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, 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
|
||||
}
|
||||
108
backend/internal/store/assignments.go
Normal file
108
backend/internal/store/assignments.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
AssignmentSourceRuleEngine = "rule_engine"
|
||||
AssignmentSourceManual = "manual"
|
||||
|
||||
AssignmentStatusAssigned = "assigned"
|
||||
AssignmentStatusNeedsReview = "needs_review"
|
||||
)
|
||||
|
||||
// KindAssignment is one append-only classification decision for an
|
||||
// activity. New assignments (rule re-run or manual override) are always
|
||||
// inserted, never updated, so the full history survives.
|
||||
type KindAssignment struct {
|
||||
ID int64
|
||||
ActivityID int64
|
||||
WorkoutKindID *int64
|
||||
AssignmentSource string
|
||||
Status string
|
||||
Confidence *float64
|
||||
CandidateKindsJSON string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// InsertKindAssignment appends a new assignment row for an activity.
|
||||
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) {
|
||||
var a KindAssignment
|
||||
err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
|
||||
|
||||
// CurrentAssignment returns the latest assignment for an activity, if any.
|
||||
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
|
||||
a, err := scanKindAssignment(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return KindAssignment{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err)
|
||||
}
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
// ReviewQueue returns activities whose current assignment status is
|
||||
// needs_review, newest first.
|
||||
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
||||
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review queue: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assignments := []KindAssignment{}
|
||||
for rows.Next() {
|
||||
a, err := scanKindAssignment(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||
}
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
return assignments, rows.Err()
|
||||
}
|
||||
|
||||
// AssignmentsForKind returns every historical assignment where the given
|
||||
// workout kind was the resolved kind (regardless of source), oldest first --
|
||||
// the basis for progression-over-time charts.
|
||||
func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
||||
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
|
||||
workoutKindID, AssignmentStatusAssigned)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
assignments := []KindAssignment{}
|
||||
for rows.Next() {
|
||||
a, err := scanKindAssignment(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||
}
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
return assignments, rows.Err()
|
||||
}
|
||||
98
backend/internal/store/db.go
Normal file
98
backend/internal/store/db.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Package store is smartrun's SQLite persistence layer: activities, laps,
|
||||
// per-second samples, workout kind rule config, and classification history.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// DB wraps a *sql.DB opened against a smartrun SQLite database file, with
|
||||
// migrations already applied.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite database at path and applies
|
||||
// any migrations that haven't run yet.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
// SQLite only supports one writer at a time; a single connection avoids
|
||||
// "database is locked" errors under any concurrent access from the app.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
db := &DB{DB: sqlDB}
|
||||
if err := db.migrate(); err != nil {
|
||||
sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||
}
|
||||
|
||||
applied := make(map[string]bool)
|
||||
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query applied migrations: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan applied migration: %w", err)
|
||||
}
|
||||
applied[name] = true
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("glob migrations: %w", err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry[len("migrations/"):]
|
||||
if applied[name] {
|
||||
continue
|
||||
}
|
||||
content, err := migrationsFS.ReadFile(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
backend/internal/store/laps.go
Normal file
85
backend/internal/store/laps.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Lap is one lap/split of an activity, from get_activity_splits, plus
|
||||
// derived HR drift/recovery metrics computed from activity_samples.
|
||||
type Lap struct {
|
||||
ID int64
|
||||
ActivityID int64
|
||||
LapIndex int
|
||||
StartTimeUTC string
|
||||
DurationSeconds float64
|
||||
DistanceMeters float64
|
||||
AvgHR *float64
|
||||
MaxHR *float64
|
||||
AvgSpeedMps *float64
|
||||
MaxSpeedMps *float64
|
||||
ElevationGainM *float64
|
||||
ElevationLossM *float64
|
||||
IntensityType string
|
||||
HRDriftBpmPerMin *float64
|
||||
HRRecoveryBpmPerMin *float64
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// ReplaceLaps deletes any existing laps for activityID and inserts the given
|
||||
// set, so re-syncing an activity's splits is idempotent.
|
||||
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin replace laps tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
|
||||
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
|
||||
}
|
||||
|
||||
for _, l := range laps {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO laps (
|
||||
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
|
||||
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
|
||||
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, l.RawJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
||||
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
|
||||
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
laps := []Lap{}
|
||||
for rows.Next() {
|
||||
var l Lap
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
|
||||
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
|
||||
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, &l.RawJSON,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lap row: %w", err)
|
||||
}
|
||||
laps = append(laps, l)
|
||||
}
|
||||
return laps, rows.Err()
|
||||
}
|
||||
109
backend/internal/store/migrations/0001_init.sql
Normal file
109
backend/internal/store/migrations/0001_init.sql
Normal file
@@ -0,0 +1,109 @@
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
garmin_activity_id INTEGER NOT NULL UNIQUE,
|
||||
activity_name TEXT NOT NULL DEFAULT '',
|
||||
activity_type TEXT NOT NULL,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
begin_timestamp_ms INTEGER NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
max_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
elevation_loss_m REAL,
|
||||
calories REAL,
|
||||
lap_count INTEGER NOT NULL DEFAULT 0,
|
||||
aerobic_training_effect REAL,
|
||||
anaerobic_training_effect REAL,
|
||||
training_effect_label TEXT NOT NULL DEFAULT '',
|
||||
vo2max_value REAL,
|
||||
hr_time_in_zone_1 REAL,
|
||||
hr_time_in_zone_2 REAL,
|
||||
hr_time_in_zone_3 REAL,
|
||||
hr_time_in_zone_4 REAL,
|
||||
hr_time_in_zone_5 REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
details_fetched_at TEXT,
|
||||
details_raw_json TEXT,
|
||||
splits_fetched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||
CREATE INDEX idx_activities_activity_type ON activities(activity_type);
|
||||
|
||||
CREATE TABLE laps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
lap_index INTEGER NOT NULL,
|
||||
start_time_utc TEXT NOT NULL,
|
||||
duration_seconds REAL NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
avg_hr REAL,
|
||||
max_hr REAL,
|
||||
avg_speed_mps REAL,
|
||||
max_speed_mps REAL,
|
||||
elevation_gain_m REAL,
|
||||
elevation_loss_m REAL,
|
||||
intensity_type TEXT NOT NULL DEFAULT '',
|
||||
hr_drift_bpm_per_min REAL,
|
||||
hr_recovery_bpm_per_min REAL,
|
||||
raw_json TEXT NOT NULL,
|
||||
UNIQUE(activity_id, lap_index)
|
||||
);
|
||||
|
||||
CREATE TABLE activity_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
elapsed_seconds REAL NOT NULL,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
heart_rate REAL,
|
||||
speed_mps REAL,
|
||||
distance_m REAL,
|
||||
elevation_m REAL
|
||||
);
|
||||
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
|
||||
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
rule_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE kind_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
workout_kind_id INTEGER REFERENCES workout_kinds(id),
|
||||
assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
|
||||
status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
|
||||
confidence REAL,
|
||||
candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
|
||||
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||
|
||||
CREATE VIEW current_kind_assignment AS
|
||||
SELECT a.* FROM kind_assignments a
|
||||
JOIN (
|
||||
SELECT activity_id, MAX(id) AS max_id
|
||||
FROM kind_assignments GROUP BY activity_id
|
||||
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;
|
||||
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
activities_fetched INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||
error_message TEXT
|
||||
);
|
||||
11
backend/internal/store/migrations/0002_sync_state.sql
Normal file
11
backend/internal/store/migrations/0002_sync_state.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Tracks how far back a full backfill has already reached. Garmin activity
|
||||
-- history is immutable once recorded, so once we've backfilled a historical
|
||||
-- window there is no need to ever re-fetch get_activities() for it again --
|
||||
-- this is the "cache" that lets repeated backfills be cheap/fast instead of
|
||||
-- re-walking years of history against Garmin's API every time.
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0);
|
||||
66
backend/internal/store/samples.go
Normal file
66
backend/internal/store/samples.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sample is one ~1-second telemetry reading for an activity.
|
||||
type Sample struct {
|
||||
ElapsedSeconds float64
|
||||
TimestampMs int64
|
||||
HeartRate *float64
|
||||
SpeedMps *float64
|
||||
DistanceM *float64
|
||||
ElevationM *float64
|
||||
}
|
||||
|
||||
// ReplaceActivitySamples deletes any existing samples for activityID and
|
||||
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
|
||||
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin replace samples tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_samples WHERE activity_id = ?`, activityID); err != nil {
|
||||
return fmt.Errorf("delete existing samples for activity %d: %w", activityID, err)
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m)
|
||||
VALUES (?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare insert sample: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, s := range samples {
|
||||
if _, err := stmt.ExecContext(ctx, activityID, s.ElapsedSeconds, s.TimestampMs, s.HeartRate, s.SpeedMps, s.DistanceM, s.ElevationM); err != nil {
|
||||
return fmt.Errorf("insert sample for activity %d: %w", activityID, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
|
||||
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
|
||||
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("samples for activity %d: %w", activityID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
samples := []Sample{}
|
||||
for rows.Next() {
|
||||
var s Sample
|
||||
if err := rows.Scan(&s.ElapsedSeconds, &s.TimestampMs, &s.HeartRate, &s.SpeedMps, &s.DistanceM, &s.ElevationM); err != nil {
|
||||
return nil, fmt.Errorf("scan sample row: %w", err)
|
||||
}
|
||||
samples = append(samples, s)
|
||||
}
|
||||
return samples, rows.Err()
|
||||
}
|
||||
202
backend/internal/store/store_test.go
Normal file
202
backend/internal/store/store_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "smartrun_test.db")
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "smartrun_test.db")
|
||||
db1, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first Open: %v", err)
|
||||
}
|
||||
db1.Close()
|
||||
|
||||
db2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open (re-applying migrations): %v", err)
|
||||
}
|
||||
db2.Close()
|
||||
}
|
||||
|
||||
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a := Activity{
|
||||
GarminActivityID: 23554066504,
|
||||
ActivityName: "Auriol - W2-5-Base Endurance",
|
||||
ActivityType: "running",
|
||||
StartTimeUTC: "2026-07-11 05:02:35",
|
||||
BeginTimestampMs: 1783746155000,
|
||||
DurationSeconds: 1800,
|
||||
DistanceMeters: 6858,
|
||||
AvgHR: f(148),
|
||||
RawJSON: `{"activityId":23554066504}`,
|
||||
}
|
||||
|
||||
id, err := db.UpsertActivity(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (insert): %v", err)
|
||||
}
|
||||
|
||||
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
||||
id2, err := db.UpsertActivity(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity (update): %v", err)
|
||||
}
|
||||
if id != id2 {
|
||||
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
||||
}
|
||||
|
||||
got, ok, err := db.GetActivity(ctx, id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if *got.AvgHR != 150 {
|
||||
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
||||
}
|
||||
|
||||
all, err := db.ListActivities(ctx, ActivityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||
GarminActivityID: 1,
|
||||
ActivityType: "running",
|
||||
StartTimeUTC: "2026-07-11 05:00:00",
|
||||
RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
|
||||
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
|
||||
Name: "Tempo",
|
||||
RuleJSON: `{"match":"all","conditions":[]}`,
|
||||
IsActive: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
|
||||
// First: rule engine says needs_review (ambiguous).
|
||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
||||
ActivityID: activityID,
|
||||
AssignmentSource: AssignmentSourceRuleEngine,
|
||||
Status: AssignmentStatusNeedsReview,
|
||||
CandidateKindsJSON: `[{"kind_id":1,"score":0.5}]`,
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||
}
|
||||
|
||||
queue, err := db.ReviewQueue(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue: %v", err)
|
||||
}
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
||||
}
|
||||
|
||||
// Then: user manually resolves it.
|
||||
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: AssignmentSourceManual,
|
||||
Status: AssignmentStatusAssigned,
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||
}
|
||||
|
||||
queue, err = db.ReviewQueue(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||
}
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||
}
|
||||
|
||||
current, ok, err := db.CurrentAssignment(ctx, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if current.AssignmentSource != AssignmentSourceManual || current.Status != AssignmentStatusAssigned {
|
||||
t.Errorf("current assignment = %+v, want manual/assigned", current)
|
||||
}
|
||||
|
||||
// The original rule-engine assignment must still exist (append-only history).
|
||||
var historyCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID).Scan(&historyCount); err != nil {
|
||||
t.Fatalf("count history: %v", err)
|
||||
}
|
||||
if historyCount != 2 {
|
||||
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
||||
}
|
||||
|
||||
forKind, err := db.AssignmentsForKind(ctx, kindID)
|
||||
if err != nil {
|
||||
t.Fatalf("AssignmentsForKind: %v", err)
|
||||
}
|
||||
if len(forKind) != 1 {
|
||||
t.Fatalf("expected 1 assigned activity for kind %d, got %d", kindID, len(forKind))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||
GarminActivityID: 2,
|
||||
ActivityType: "running",
|
||||
StartTimeUTC: "2026-07-11 05:00:00",
|
||||
RawJSON: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
|
||||
laps := []Lap{
|
||||
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
|
||||
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
|
||||
}
|
||||
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
||||
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||
}
|
||||
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
||||
if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil {
|
||||
t.Fatalf("ReplaceLaps (second): %v", err)
|
||||
}
|
||||
|
||||
got, err := db.LapsForActivity(ctx, activityID)
|
||||
if err != nil {
|
||||
t.Fatalf("LapsForActivity: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 lap after replace, got %d", len(got))
|
||||
}
|
||||
}
|
||||
91
backend/internal/store/syncruns.go
Normal file
91
backend/internal/store/syncruns.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
SyncKindBackfill = "backfill"
|
||||
SyncKindIncremental = "incremental"
|
||||
|
||||
SyncStatusRunning = "running"
|
||||
SyncStatusSuccess = "success"
|
||||
SyncStatusError = "error"
|
||||
)
|
||||
|
||||
// SyncRun records one backfill or incremental sync attempt.
|
||||
type SyncRun struct {
|
||||
ID int64
|
||||
Kind string
|
||||
StartedAt string
|
||||
FinishedAt *string
|
||||
ActivitiesFetched int
|
||||
Status string
|
||||
ErrorMessage *string
|
||||
}
|
||||
|
||||
// StartSyncRun records a new in-progress sync run and returns its id.
|
||||
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
|
||||
kind, SyncStatusRunning)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("start sync run: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// FinishSyncRun marks a sync run as finished, recording how many activities
|
||||
// were fetched and whether it succeeded.
|
||||
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
|
||||
status := SyncStatusSuccess
|
||||
if errMsg != nil {
|
||||
status = SyncStatusError
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
||||
WHERE id = ?`, activitiesFetched, status, errMsg, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finish sync run %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LatestSyncRun returns the most recent sync run, if any.
|
||||
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
|
||||
row := db.QueryRowContext(ctx, `
|
||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||
FROM sync_runs ORDER BY id DESC LIMIT 1`)
|
||||
var r SyncRun
|
||||
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
||||
if err == sql.ErrNoRows {
|
||||
return SyncRun{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SyncRun{}, false, fmt.Errorf("latest sync run: %w", err)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
// ListSyncRuns returns recent sync runs, newest first.
|
||||
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||
FROM sync_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sync runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
runs := []SyncRun{}
|
||||
for rows.Next() {
|
||||
var r SyncRun
|
||||
if err := rows.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage); err != nil {
|
||||
return nil, fmt.Errorf("scan sync run row: %w", err)
|
||||
}
|
||||
runs = append(runs, r)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
37
backend/internal/store/syncstate.go
Normal file
37
backend/internal/store/syncstate.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SyncState tracks how far back into Garmin history a backfill has already
|
||||
// reached. Since activities are immutable once recorded, this lets a repeat
|
||||
// backfill skip everything already covered instead of re-fetching it.
|
||||
type SyncState struct {
|
||||
EarliestSyncedDate *string // "YYYY-MM-DD", nil if backfill has never run
|
||||
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
||||
}
|
||||
|
||||
// GetSyncState returns the current backfill watermark (the singleton row,
|
||||
// created by migration 0002).
|
||||
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
|
||||
var s SyncState
|
||||
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`).
|
||||
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||||
if err != nil {
|
||||
return SyncState{}, fmt.Errorf("get sync state: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// UpdateSyncState records progress of a backfill run.
|
||||
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
|
||||
earliestSyncedDate, complete)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update sync state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
backend/internal/store/syncstate_test.go
Normal file
31
backend/internal/store/syncstate_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
state, err := db.GetSyncState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSyncState: %v", err)
|
||||
}
|
||||
if state.EarliestSyncedDate != nil || state.BackfillComplete {
|
||||
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
|
||||
}
|
||||
|
||||
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
|
||||
t.Fatalf("UpdateSyncState: %v", err)
|
||||
}
|
||||
|
||||
state, err = db.GetSyncState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSyncState after update: %v", err)
|
||||
}
|
||||
if state.EarliestSyncedDate == nil || *state.EarliestSyncedDate != "2023-01-01" || !state.BackfillComplete {
|
||||
t.Fatalf("state after update = %+v, want earliest=2023-01-01 complete=true", state)
|
||||
}
|
||||
}
|
||||
102
backend/internal/store/workoutkinds.go
Normal file
102
backend/internal/store/workoutkinds.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...).
|
||||
// RuleJSON holds the condition tree evaluated by internal/classify.
|
||||
type WorkoutKind struct {
|
||||
ID int64
|
||||
Name string
|
||||
Description string
|
||||
Color string
|
||||
RuleJSON string
|
||||
Priority int
|
||||
IsActive bool
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
|
||||
var k WorkoutKind
|
||||
err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt)
|
||||
return k, err
|
||||
}
|
||||
|
||||
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
|
||||
|
||||
// CreateWorkoutKind inserts a new workout kind and returns its id.
|
||||
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateWorkoutKind updates an existing workout kind's editable fields.
|
||||
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||
WHERE id=?`,
|
||||
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update workout kind %d: %w", k.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkoutKind fetches one workout kind by id.
|
||||
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
|
||||
k, err := scanWorkoutKind(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return WorkoutKind{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d: %w", id, err)
|
||||
}
|
||||
return k, true, nil
|
||||
}
|
||||
|
||||
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
|
||||
// (is_active=0) kinds are excluded.
|
||||
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
||||
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
|
||||
if activeOnly {
|
||||
query += ` WHERE is_active = 1`
|
||||
}
|
||||
query += ` ORDER BY priority DESC, name`
|
||||
|
||||
rows, err := db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workout kinds: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
kinds := []WorkoutKind{}
|
||||
for rows.Next() {
|
||||
k, err := scanWorkoutKind(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan workout kind row: %w", err)
|
||||
}
|
||||
kinds = append(kinds, k)
|
||||
}
|
||||
return kinds, rows.Err()
|
||||
}
|
||||
|
||||
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||||
// referencing it) intact.
|
||||
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
|
||||
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft delete workout kind %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user