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() } // AllCurrentAssignments returns the latest assignment for every activity // that has one, regardless of status or source -- the basis for deciding // which activities a global reclassify pass is allowed to touch. func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) { rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`) if err != nil { return nil, fmt.Errorf("all current assignments: %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() }