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.
This commit is contained in:
@@ -58,7 +58,7 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) {
|
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -68,8 +68,8 @@ func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
if len(kinds) != 7 {
|
if len(kinds) != 8 {
|
||||||
t.Fatalf("expected 7 seeded kinds, got %d", len(kinds))
|
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
|
||||||
}
|
}
|
||||||
for _, k := range kinds {
|
for _, k := range kinds {
|
||||||
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil {
|
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil {
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ type ActivityType struct {
|
|||||||
TypeKey string `json:"typeKey"`
|
TypeKey string `json:"typeKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventType mirrors the nested "eventType" object in get_activities(). Garmin
|
||||||
|
// Connect lets a user manually tag an activity's event type (Race, Training,
|
||||||
|
// Fitness, Recreation, ...); TypeKey is "race" for activities marked as races.
|
||||||
|
type EventType struct {
|
||||||
|
TypeKey string `json:"typeKey"`
|
||||||
|
}
|
||||||
|
|
||||||
// Activity mirrors the fields of interest from get_activities(); the full
|
// Activity mirrors the fields of interest from get_activities(); the full
|
||||||
// original object is kept in Raw for fields not modeled here.
|
// original object is kept in Raw for fields not modeled here.
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ActivityID int64 `json:"activityId"`
|
ActivityID int64 `json:"activityId"`
|
||||||
ActivityName string `json:"activityName"`
|
ActivityName string `json:"activityName"`
|
||||||
ActivityType ActivityType `json:"activityType"`
|
ActivityType ActivityType `json:"activityType"`
|
||||||
|
EventType EventType `json:"eventType"`
|
||||||
BeginTimestamp int64 `json:"beginTimestamp"`
|
BeginTimestamp int64 `json:"beginTimestamp"`
|
||||||
StartTimeGMT string `json:"startTimeGMT"`
|
StartTimeGMT string `json:"startTimeGMT"`
|
||||||
StartTimeLocal string `json:"startTimeLocal"`
|
StartTimeLocal string `json:"startTimeLocal"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Activity struct {
|
|||||||
GarminActivityID int64
|
GarminActivityID int64
|
||||||
ActivityName string
|
ActivityName string
|
||||||
ActivityType string
|
ActivityType string
|
||||||
|
EventTypeKey string
|
||||||
StartTimeUTC string
|
StartTimeUTC string
|
||||||
BeginTimestampMs int64
|
BeginTimestampMs int64
|
||||||
DurationSeconds float64
|
DurationSeconds float64
|
||||||
@@ -49,17 +50,18 @@ type Activity struct {
|
|||||||
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO activities (
|
INSERT INTO activities (
|
||||||
garmin_activity_id, activity_name, activity_type, start_time_utc,
|
garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc,
|
||||||
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
||||||
training_effect_label, vo2max_value,
|
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,
|
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
|
raw_json, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
||||||
activity_name=excluded.activity_name,
|
activity_name=excluded.activity_name,
|
||||||
activity_type=excluded.activity_type,
|
activity_type=excluded.activity_type,
|
||||||
|
event_type_key=excluded.event_type_key,
|
||||||
start_time_utc=excluded.start_time_utc,
|
start_time_utc=excluded.start_time_utc,
|
||||||
begin_timestamp_ms=excluded.begin_timestamp_ms,
|
begin_timestamp_ms=excluded.begin_timestamp_ms,
|
||||||
duration_seconds=excluded.duration_seconds,
|
duration_seconds=excluded.duration_seconds,
|
||||||
@@ -84,7 +86,7 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
|||||||
raw_json=excluded.raw_json,
|
raw_json=excluded.raw_json,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
`,
|
`,
|
||||||
a.GarminActivityID, a.ActivityName, a.ActivityType, a.StartTimeUTC,
|
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.StartTimeUTC,
|
||||||
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||||
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
|
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
|
||||||
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
|
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
|
||||||
@@ -106,7 +108,7 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
|||||||
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||||
var a Activity
|
var a Activity
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.StartTimeUTC,
|
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.StartTimeUTC,
|
||||||
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
||||||
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
|
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
|
||||||
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
|
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
|
||||||
@@ -119,7 +121,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activityColumns = `
|
const activityColumns = `
|
||||||
id, garmin_activity_id, activity_name, activity_type, start_time_utc,
|
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,
|
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
||||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
||||||
|
|||||||
12
backend/internal/store/migrations/0007_race_kind.sql
Normal file
12
backend/internal/store/migrations/0007_race_kind.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- "Race" is an 8th fixed workout kind. Unlike the other 7 (seeded with a
|
||||||
|
-- never-matching placeholder rule pending manual tuning), it gets a real
|
||||||
|
-- rule from day one: Garmin Connect lets a user manually tag an activity's
|
||||||
|
-- event type as "Race", and that value round-trips through get_activities()
|
||||||
|
-- as eventType.typeKey -- a genuine, deterministic signal, not a guess.
|
||||||
|
ALTER TABLE activities ADD COLUMN event_type_key TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
|
||||||
|
('Race', '', '#dc2626', '{"match":"all","conditions":[{"metric":"is_race","op":"==","value":true}]}', 0, 1);
|
||||||
|
|
||||||
|
INSERT INTO workout_type_paces (workout_kind_id)
|
||||||
|
SELECT id FROM workout_kinds WHERE name = 'Race';
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) {
|
func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -13,13 +13,13 @@ func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutKinds: %v", err)
|
t.Fatalf("ListWorkoutKinds: %v", err)
|
||||||
}
|
}
|
||||||
if len(kinds) != 7 {
|
if len(kinds) != 8 {
|
||||||
t.Fatalf("expected 7 seeded workout kinds, got %d: %+v", len(kinds), kinds)
|
t.Fatalf("expected 8 seeded workout kinds, got %d: %+v", len(kinds), kinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantNames := map[string]bool{
|
wantNames := map[string]bool{
|
||||||
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
|
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
|
||||||
"Tempo": false, "Interval": false, "MAS Test": false,
|
"Tempo": false, "Interval": false, "MAS Test": false, "Race": false,
|
||||||
}
|
}
|
||||||
for _, k := range kinds {
|
for _, k := range kinds {
|
||||||
if _, ok := wantNames[k.Name]; !ok {
|
if _, ok := wantNames[k.Name]; !ok {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
||||||
}
|
}
|
||||||
if len(all) != 7 {
|
if len(all) != 8 {
|
||||||
t.Fatalf("expected 7 seeded pace rows (one per taxonomy kind), got %d", len(all))
|
t.Fatalf("expected 8 seeded pace rows (one per taxonomy kind), got %d", len(all))
|
||||||
}
|
}
|
||||||
for _, p := range all {
|
for _, p := range all {
|
||||||
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil {
|
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"smartrun/backend/internal/classify"
|
"smartrun/backend/internal/classify"
|
||||||
@@ -9,11 +10,21 @@ import (
|
|||||||
"smartrun/backend/internal/store"
|
"smartrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isRunningActivityType reports whether a Garmin activityType.typeKey
|
||||||
|
// represents a running activity (running, trail_running, treadmill_running,
|
||||||
|
// track_running, indoor_running, virtual_run, ...) as opposed to other
|
||||||
|
// sports (padel, cycling, strength training, ...) that also show up in
|
||||||
|
// get_activities().
|
||||||
|
func isRunningActivityType(typeKey string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(typeKey), "run")
|
||||||
|
}
|
||||||
|
|
||||||
func toActivityRow(a garmin.Activity) store.Activity {
|
func toActivityRow(a garmin.Activity) store.Activity {
|
||||||
return store.Activity{
|
return store.Activity{
|
||||||
GarminActivityID: a.ActivityID,
|
GarminActivityID: a.ActivityID,
|
||||||
ActivityName: a.ActivityName,
|
ActivityName: a.ActivityName,
|
||||||
ActivityType: a.ActivityType.TypeKey,
|
ActivityType: a.ActivityType.TypeKey,
|
||||||
|
EventTypeKey: a.EventType.TypeKey,
|
||||||
StartTimeUTC: a.StartTimeGMT,
|
StartTimeUTC: a.StartTimeGMT,
|
||||||
BeginTimestampMs: a.BeginTimestamp,
|
BeginTimestampMs: a.BeginTimestamp,
|
||||||
DurationSeconds: a.Duration,
|
DurationSeconds: a.Duration,
|
||||||
@@ -125,9 +136,14 @@ func toSampleRows(samples []garmin.Sample) []store.Sample {
|
|||||||
// rules. maxHR is the user's configured max heart rate, used only to derive
|
// rules. maxHR is the user's configured max heart rate, used only to derive
|
||||||
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
||||||
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
||||||
|
isRace := 0.0
|
||||||
|
if a.EventTypeKey == "race" {
|
||||||
|
isRace = 1.0
|
||||||
|
}
|
||||||
ctx := classify.MetricContext{
|
ctx := classify.MetricContext{
|
||||||
"duration_seconds": a.DurationSeconds,
|
"duration_seconds": a.DurationSeconds,
|
||||||
"distance_meters": a.DistanceMeters,
|
"distance_meters": a.DistanceMeters,
|
||||||
|
"is_race": isRace,
|
||||||
}
|
}
|
||||||
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
||||||
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
||||||
|
|||||||
@@ -208,6 +208,15 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
|
|||||||
return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||||
}
|
}
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
|
// Only running activities are of interest here; other sports (padel,
|
||||||
|
// cycling, strength training, ...) also come back from
|
||||||
|
// get_activities() but are dropped rather than stored. len(activities)
|
||||||
|
// below stays the unfiltered count, since it drives the backfill
|
||||||
|
// watermark's "reached start of history" check -- a page containing
|
||||||
|
// only non-running activities must not look like an empty page.
|
||||||
|
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
|
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
|
||||||
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,40 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
|
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
|
||||||
|
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
|
||||||
|
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
|
||||||
|
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
|
||||||
|
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
||||||
|
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
||||||
|
}}
|
||||||
|
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("Backfill: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 2 {
|
||||||
|
t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities)
|
||||||
|
}
|
||||||
|
for _, a := range activities {
|
||||||
|
if a.GarminActivityID != 1 && a.GarminActivityID != 2 {
|
||||||
|
t.Errorf("unexpected non-running activity stored: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -162,6 +196,21 @@ func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
|||||||
if got := ctx["lap_interval_pattern"]; got != 0 {
|
if got := ctx["lap_interval_pattern"]; got != 0 {
|
||||||
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
||||||
}
|
}
|
||||||
|
if got := ctx["is_race"]; got != 0 {
|
||||||
|
t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
|
||||||
|
ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0)
|
||||||
|
if got := ctx["is_race"]; got != 1 {
|
||||||
|
t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0)
|
||||||
|
if got := ctx["is_race"]; got != 0 {
|
||||||
|
t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||||
|
|||||||
@@ -137,6 +137,25 @@ button:disabled {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-pill.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,25 +59,41 @@ export function ReviewQueue() {
|
|||||||
return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
|
return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
|
||||||
}, [items, filterKindId]);
|
}, [items, filterKindId]);
|
||||||
|
|
||||||
|
function toggleFilter(id: string) {
|
||||||
|
setFilterKindId((prev) => (prev === id ? "" : id));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Review Queue</h2>
|
<h2>Review Queue</h2>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="controls">
|
<div className="filter-pills">
|
||||||
<label>
|
<button
|
||||||
Filter
|
type="button"
|
||||||
<select value={filterKindId} onChange={(e) => setFilterKindId(e.target.value)}>
|
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
|
||||||
<option value="">All ({items.length})</option>
|
onClick={() => toggleFilter("")}
|
||||||
<option value={UNSORTED}>Unsorted (no candidates)</option>
|
>
|
||||||
|
All ({items.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`filter-pill${filterKindId === UNSORTED ? " active" : ""}`}
|
||||||
|
onClick={() => toggleFilter(UNSORTED)}
|
||||||
|
>
|
||||||
|
Unsorted (no candidates)
|
||||||
|
</button>
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
<option key={k.ID} value={k.ID}>
|
<button
|
||||||
|
key={k.ID}
|
||||||
|
type="button"
|
||||||
|
className={`filter-pill${filterKindId === String(k.ID) ? " active" : ""}`}
|
||||||
|
onClick={() => toggleFilter(String(k.ID))}
|
||||||
|
>
|
||||||
{k.Name}
|
{k.Name}
|
||||||
</option>
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user