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:
243
backend/cmd/mcpspike/main.go
Normal file
243
backend/cmd/mcpspike/main.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive
|
||||
// mcp-garmin (spawn, initialize handshake, call tools, parse results).
|
||||
// Not part of the production build — delete once internal/garmin is built.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable")
|
||||
serverPath := flag.String("server", "", "path to mcp-garmin server.py")
|
||||
limit := flag.Int("limit", 5, "activity limit for get_activities")
|
||||
startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)")
|
||||
endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)")
|
||||
flag.Parse()
|
||||
|
||||
if *pythonPath == "" || *serverPath == "" {
|
||||
log.Fatal("usage: mcpspike -python <path to .venv/bin/python> -server <path to server.py>")
|
||||
}
|
||||
|
||||
email := os.Getenv("GARMIN_EMAIL")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
if email == "" || password == "" {
|
||||
log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment")
|
||||
}
|
||||
|
||||
env := []string{
|
||||
"GARMIN_EMAIL=" + email,
|
||||
"GARMIN_PASSWORD=" + password,
|
||||
"PYTHONUNBUFFERED=1",
|
||||
}
|
||||
|
||||
c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath)
|
||||
if err != nil {
|
||||
log.Fatalf("spawn subprocess: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if stdio, ok := c.GetTransport().(*transport.Stdio); ok {
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := stdio.Stderr().Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Fprint(os.Stderr, string(buf[:n]))
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Println("warning: could not get stdio transport to forward subprocess stderr")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
initReq := mcp.InitializeRequest{}
|
||||
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||
initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"}
|
||||
|
||||
initRes, err := c.Initialize(ctx, initReq)
|
||||
if err != nil {
|
||||
log.Fatalf("initialize handshake failed: %v", err)
|
||||
}
|
||||
fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n",
|
||||
initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
|
||||
|
||||
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
log.Fatalf("list tools failed: %v", err)
|
||||
}
|
||||
fmt.Printf("server exposes %d tools:\n", len(tools.Tools))
|
||||
for _, t := range tools.Tools {
|
||||
fmt.Printf(" - %s: %s\n", t.Name, t.Description)
|
||||
}
|
||||
|
||||
fmt.Println("\ncalling authenticate()...")
|
||||
authRes, err := callTool(ctx, c, "authenticate", nil)
|
||||
if err != nil {
|
||||
log.Fatalf("authenticate call failed: %v", err)
|
||||
}
|
||||
fmt.Printf("authenticate() -> %s\n", authRes)
|
||||
|
||||
if containsMFAPrompt(authRes) {
|
||||
fmt.Print("MFA required. Enter code: ")
|
||||
var code string
|
||||
fmt.Scanln(&code)
|
||||
mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code})
|
||||
if err != nil {
|
||||
log.Fatalf("complete_mfa call failed: %v", err)
|
||||
}
|
||||
fmt.Printf("complete_mfa() -> %s\n", mfaRes)
|
||||
}
|
||||
|
||||
start := *startDate
|
||||
if start == "" {
|
||||
start = time.Now().AddDate(0, 0, -365).Format("2006-01-02")
|
||||
}
|
||||
end := *endDate
|
||||
if end == "" {
|
||||
end = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit)
|
||||
actRes, err := callTool(ctx, c, "get_activities", map[string]any{
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"limit": *limit,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activities call failed: %v", err)
|
||||
}
|
||||
|
||||
var activities []map[string]any
|
||||
if err := json.Unmarshal([]byte(actRes), &activities); err != nil {
|
||||
fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes)
|
||||
return
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(activities, "", " ")
|
||||
fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000))
|
||||
|
||||
if len(activities) == 0 {
|
||||
fmt.Println("\nno activities in range, skipping get_activity_details")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer a running activity if one is present, for the most relevant lap/split shape.
|
||||
chosen := activities[0]
|
||||
for _, a := range activities {
|
||||
if at, ok := a["activityType"].(map[string]any); ok {
|
||||
if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" {
|
||||
chosen = a
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var activityID string
|
||||
switch v := chosen["activityId"].(type) {
|
||||
case float64:
|
||||
activityID = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
default:
|
||||
activityID = fmt.Sprint(v)
|
||||
}
|
||||
fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"])
|
||||
|
||||
splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activity_splits call failed: %v", err)
|
||||
}
|
||||
|
||||
var splits map[string]any
|
||||
if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil {
|
||||
fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes)
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(splits))
|
||||
for k := range splits {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
fmt.Printf("get_activity_splits() top-level keys: %v\n", keys)
|
||||
prettySplits, _ := json.MarshalIndent(splits, "", " ")
|
||||
fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000))
|
||||
|
||||
fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...")
|
||||
detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID})
|
||||
if err != nil {
|
||||
log.Fatalf("get_activity_details call failed: %v", err)
|
||||
}
|
||||
var details map[string]any
|
||||
if err := json.Unmarshal([]byte(detailsRes), &details); err != nil {
|
||||
fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes)
|
||||
return
|
||||
}
|
||||
if descriptors, ok := details["metricDescriptors"]; ok {
|
||||
pretty, _ := json.MarshalIndent(descriptors, "", " ")
|
||||
fmt.Printf("metricDescriptors:\n%s\n", pretty)
|
||||
} else {
|
||||
fmt.Println("no metricDescriptors key found")
|
||||
}
|
||||
if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 {
|
||||
sampleN := 5
|
||||
if len(rows) < sampleN {
|
||||
sampleN = len(rows)
|
||||
}
|
||||
pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ")
|
||||
fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty)
|
||||
}
|
||||
}
|
||||
|
||||
func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) {
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Name = name
|
||||
req.Params.Arguments = args
|
||||
|
||||
res, err := c.CallTool(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.IsError {
|
||||
return "", fmt.Errorf("tool %s returned an error result", name)
|
||||
}
|
||||
var out string
|
||||
for _, content := range res.Content {
|
||||
if tc, ok := content.(mcp.TextContent); ok {
|
||||
out += tc.Text
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsMFAPrompt(s string) bool {
|
||||
for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} {
|
||||
if len(s) >= len(needle) {
|
||||
for i := 0; i+len(needle) <= len(s); i++ {
|
||||
if s[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "...(truncated)"
|
||||
}
|
||||
221
backend/cmd/seedsample/main.go
Normal file
221
backend/cmd/seedsample/main.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Command seedsample inserts synthetic activities, laps, and workout kinds
|
||||
// directly into the SQLite database, then runs them through the real
|
||||
// classification engine -- so the frontend (Dashboard/ReviewQueue/
|
||||
// WorkoutKinds) can be visually verified with realistic data without a live
|
||||
// Garmin account.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/classify"
|
||||
"smartrun/backend/internal/garmin/mock"
|
||||
"smartrun/backend/internal/store"
|
||||
appsync "smartrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "smartrun_sample.db", "path to the SQLite database to seed")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Easy and Tempo's pace/HR ranges deliberately overlap a little (330-340
|
||||
// sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap zone
|
||||
// demonstrates the ambiguous-multi-match review path, not just a gap
|
||||
// between disjoint ranges.
|
||||
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||
Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
||||
RuleJSON: `{"match":"all","conditions":[
|
||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
||||
{"metric":"avg_hr_pct_max","op":"<=","value":0.85}
|
||||
]}`,
|
||||
IsActive: true,
|
||||
})
|
||||
must(err)
|
||||
|
||||
tempoID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||
Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
||||
RuleJSON: `{"match":"all","conditions":[
|
||||
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
||||
{"metric":"avg_hr_pct_max","op":">=","value":0.70}
|
||||
]}`,
|
||||
IsActive: true,
|
||||
})
|
||||
must(err)
|
||||
|
||||
_, err = db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||
Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444",
|
||||
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
||||
IsActive: true,
|
||||
})
|
||||
must(err)
|
||||
|
||||
m := &mock.Client{}
|
||||
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil)
|
||||
|
||||
today := time.Now()
|
||||
activityIDs := []int64{}
|
||||
|
||||
// 5 Easy runs, pace/HR trending slightly faster over time (progression).
|
||||
for i := 0; i < 5; i++ {
|
||||
start := today.AddDate(0, 0, -60+i*10)
|
||||
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
||||
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
|
||||
id := seedActivity(ctx, db, seedParams{
|
||||
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
||||
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
||||
aerobicTE: 2.5, anaerobicTE: 0.3,
|
||||
})
|
||||
activityIDs = append(activityIDs, id)
|
||||
}
|
||||
|
||||
// 3 Tempo runs.
|
||||
for i := 0; i < 3; i++ {
|
||||
start := today.AddDate(0, 0, -45+i*15)
|
||||
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
||||
id := seedActivity(ctx, db, seedParams{
|
||||
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
||||
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
||||
aerobicTE: 3.8, anaerobicTE: 1.2,
|
||||
})
|
||||
activityIDs = append(activityIDs, id)
|
||||
}
|
||||
|
||||
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
||||
// showing drift on the work intervals and recovery on the rest ones.
|
||||
{
|
||||
id := seedActivity(ctx, db, seedParams{
|
||||
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
||||
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
||||
aerobicTE: 3.0, anaerobicTE: 3.5,
|
||||
})
|
||||
seedIntervalLapsAndSamples(ctx, db, id)
|
||||
activityIDs = append(activityIDs, id)
|
||||
}
|
||||
|
||||
// 1 ambiguous run: pace 335 sec/km sits inside both Easy's [330,420] and
|
||||
// Tempo's [300,340] ranges, and HR% (0.78) satisfies both Easy's <=0.85
|
||||
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
||||
// candidates, not a clean single match.
|
||||
{
|
||||
id := seedActivity(ctx, db, seedParams{
|
||||
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
||||
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
||||
aerobicTE: 3.0, anaerobicTE: 0.8,
|
||||
})
|
||||
activityIDs = append(activityIDs, id)
|
||||
}
|
||||
|
||||
for _, id := range activityIDs {
|
||||
must(svc.ClassifyActivity(ctx, id))
|
||||
}
|
||||
|
||||
fmt.Printf("Seeded %d activities (kinds: Easy=%d, Tempo=%d) into %s\n", len(activityIDs), easyID, tempoID, *dbPath)
|
||||
}
|
||||
|
||||
type seedParams struct {
|
||||
garminID int64
|
||||
name string
|
||||
start time.Time
|
||||
distance float64
|
||||
duration float64
|
||||
speedMps float64
|
||||
avgHR float64
|
||||
aerobicTE float64
|
||||
anaerobicTE float64
|
||||
}
|
||||
|
||||
func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
|
||||
speed := p.speedMps
|
||||
hr := p.avgHR
|
||||
aerobic := p.aerobicTE
|
||||
anaerobic := p.anaerobicTE
|
||||
id, err := db.UpsertActivity(ctx, store.Activity{
|
||||
GarminActivityID: p.garminID,
|
||||
ActivityName: p.name,
|
||||
ActivityType: "running",
|
||||
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||||
BeginTimestampMs: p.start.UnixMilli(),
|
||||
DurationSeconds: p.duration,
|
||||
DistanceMeters: p.distance,
|
||||
AvgSpeedMps: &speed,
|
||||
AvgHR: &hr,
|
||||
AerobicTrainingEffect: &aerobic,
|
||||
AnaerobicTrainingEffect: &anaerobic,
|
||||
RawJSON: "{}",
|
||||
})
|
||||
must(err)
|
||||
return id
|
||||
}
|
||||
|
||||
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
||||
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
||||
// and falling HR within each REST lap (recovery).
|
||||
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) {
|
||||
var laps []store.Lap
|
||||
var samples []store.Sample
|
||||
elapsed := 0.0
|
||||
baseHR := 140.0
|
||||
|
||||
for i := 0; i < 6; i++ {
|
||||
isActive := i%2 == 0
|
||||
lapDuration := 180.0
|
||||
intensity := "REST"
|
||||
if isActive {
|
||||
intensity = "ACTIVE"
|
||||
}
|
||||
|
||||
var lapSamples []classify.SampleInfo
|
||||
for s := 0.0; s < lapDuration; s += 5 {
|
||||
var hr float64
|
||||
if isActive {
|
||||
hr = baseHR + s/10 // rising through the work interval
|
||||
} else {
|
||||
hr = baseHR + 20 - s/8 // falling through the rest interval
|
||||
}
|
||||
samples = append(samples, store.Sample{
|
||||
ElapsedSeconds: elapsed + s,
|
||||
TimestampMs: int64((elapsed + s) * 1000),
|
||||
HeartRate: &hr,
|
||||
})
|
||||
lapSamples = append(lapSamples, classify.SampleInfo{ElapsedSeconds: elapsed + s, HeartRate: &hr})
|
||||
}
|
||||
|
||||
var drift, recovery *float64
|
||||
if isActive {
|
||||
if v, ok := classify.HRDrift(lapSamples); ok {
|
||||
drift = &v
|
||||
}
|
||||
} else {
|
||||
if v, ok := classify.HRRecovery(lapSamples); ok {
|
||||
recovery = &v
|
||||
}
|
||||
}
|
||||
|
||||
laps = append(laps, store.Lap{
|
||||
LapIndex: i + 1, DurationSeconds: lapDuration,
|
||||
DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
|
||||
HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
|
||||
})
|
||||
elapsed += lapDuration
|
||||
}
|
||||
|
||||
must(db.ReplaceActivitySamples(ctx, activityID, samples))
|
||||
must(db.ReplaceLaps(ctx, activityID, laps))
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
91
backend/cmd/smartrund/main.go
Normal file
91
backend/cmd/smartrund/main.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Command smartrund is smartrun's backend server: syncs runs from Garmin,
|
||||
// classifies them into workout kinds, and serves the REST API the frontend
|
||||
// talks to.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/api"
|
||||
"smartrun/backend/internal/config"
|
||||
"smartrun/backend/internal/garmin"
|
||||
"smartrun/backend/internal/store"
|
||||
appsync "smartrun/backend/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
garminClient := garmin.NewClient(garmin.Config{
|
||||
PythonPath: cfg.GarminPythonPath,
|
||||
ServerPath: cfg.GarminServerPath,
|
||||
GarminEmail: cfg.GarminEmail,
|
||||
GarminPassword: cfg.GarminPassword,
|
||||
TokenStorePath: cfg.GarminTokenStore,
|
||||
})
|
||||
defer garminClient.Close()
|
||||
|
||||
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
|
||||
BackfillHorizonDays: cfg.BackfillHorizonDays,
|
||||
MinConfidence: cfg.MinConfidence,
|
||||
MaxHR: cfg.MaxHR,
|
||||
}, nil)
|
||||
|
||||
server := api.NewServer(db, garminClient, syncSvc)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery)
|
||||
|
||||
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||
go func() {
|
||||
log.Printf("smartrund listening on %s", cfg.Addr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("http server shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runIncrementalSyncLoop periodically syncs new activities in the
|
||||
// background so the frontend doesn't need to trigger every sync manually.
|
||||
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) {
|
||||
ticker := time.NewTicker(every)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := svc.IncrementalSync(ctx); err != nil {
|
||||
log.Printf("incremental sync: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
||||
log.Printf("fill pending details: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user