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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user