package main import ( "encoding/json" "fmt" "os" "sync" "time" ) type TripState struct { StartDate string `json:"start_date"` // YYYY-MM-DD StartTime time.Time `json:"start_time"` // Exact start time StartDayInitialSteps int `json:"start_day_initial_steps"` // Steps on the tracker when trip started DailyCache map[string]int `json:"daily_cache"` // Cache of steps for past days } type StepManager struct { mu sync.Mutex userID string // Fitbit user ID tripState TripState // Smoothing State previousTotalSteps int // What we last told the client (or where we started smoothing from) targetTotalSteps int // The actual total steps we just fetched/calculated lastSyncTime time.Time nextSyncTime time.Time syncInterval time.Duration } func NewStepManager(userID string) *StepManager { now := time.Now() interval := 15 * time.Minute // Default state (will be used if load fails or file missing) defaultState := TripState{ StartDate: now.Format("2006-01-02"), StartTime: now, DailyCache: make(map[string]int), } sm := &StepManager{ userID: userID, tripState: defaultState, syncInterval: interval, lastSyncTime: now.Add(-interval), nextSyncTime: now, } if err := sm.LoadTripState(); err != nil { fmt.Printf("Warning: Failed to load trip state: %v. Using new trip defaults.\n", err) } else { // Initialize total steps from the loaded state to avoid interpolating from 0 initialTotal := sm.RecalculateTotalFromState() sm.previousTotalSteps = initialTotal sm.targetTotalSteps = initialTotal fmt.Printf("Initialized step counts from cache: %d\n", initialTotal) } return sm } func (sm *StepManager) LoadTripState() error { tripPath := fmt.Sprintf("data/users/%s/trip.json", sm.userID) data, err := os.ReadFile(tripPath) if err != nil { if os.IsNotExist(err) { return nil // Normal for first run } return err } var loadedState TripState if err := json.Unmarshal(data, &loadedState); err != nil { return fmt.Errorf("failed to parse trip.json: %w", err) } // Only update if valid sm.tripState = loadedState if sm.tripState.DailyCache == nil { sm.tripState.DailyCache = make(map[string]int) } fmt.Printf("Loaded trip state: StartDate=%s, InitialSteps=%d\n", sm.tripState.StartDate, sm.tripState.StartDayInitialSteps) return nil } func (sm *StepManager) SaveTripState() { userDir := fmt.Sprintf("data/users/%s", sm.userID) if err := os.MkdirAll(userDir, 0755); err != nil { fmt.Printf("Error creating user directory: %v\n", err) return } tripPath := fmt.Sprintf("%s/trip.json", userDir) data, _ := json.MarshalIndent(sm.tripState, "", " ") os.WriteFile(tripPath, data, 0644) } func (sm *StepManager) StartNewTrip() { sm.mu.Lock() defer sm.mu.Unlock() now := time.Now() initialSteps, err := GetDailySteps(sm.userID, now.Format("2006-01-02")) if err != nil { initialSteps = 0 fmt.Printf("Error fetching initial steps: %v\n", err) } sm.tripState = TripState{ StartDate: now.Format("2006-01-02"), StartTime: now, StartDayInitialSteps: initialSteps, DailyCache: make(map[string]int), } // On new trip, previous total is 0 sm.previousTotalSteps = 0 sm.targetTotalSteps = 0 sm.SaveTripState() // Trigger immediate sync to set baseline go sm.Sync() } // Sync fetches data for all days in the trip using the default interval func (sm *StepManager) Sync() { sm.performSync(sm.syncInterval) } // Drain fetches data and sets a short sync interval to fast-forward interpolation func (sm *StepManager) Drain() { sm.performSync(30 * time.Second) } // performSync implementation func (sm *StepManager) performSync(interval time.Duration) { sm.mu.Lock() tripStateCopy := sm.tripState // Deep copy the map to avoid data races during async network calls newDailyCache := make(map[string]int) for k, v := range tripStateCopy.DailyCache { newDailyCache[k] = v } sm.mu.Unlock() totalSteps := 0 today := time.Now().Format("2006-01-02") // Parse start date in local time start, _ := time.ParseInLocation("2006-01-02", tripStateCopy.StartDate, time.Local) end, _ := time.ParseInLocation("2006-01-02", today, time.Local) // Iterate from Start Date to Today for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") var steps int var err error // Check cache first for past days // For today, we always fetch. // Ideally we might trust cache for past days, but re-checking isn't bad if we want to catch up. // The current logic trusts cache for past days. shouldFetch := (dateStr == today) if !shouldFetch { if cached, ok := newDailyCache[dateStr]; ok { steps = cached } else { shouldFetch = true } } if shouldFetch { steps, err = GetDailySteps(sm.userID, dateStr) if err != nil { fmt.Printf("Error fetching steps for %s: %v\n", dateStr, err) return // Don't proceed with sync if fetch fails } // Update the local cache newDailyCache[dateStr] = steps } // Calculate contribution to total if dateStr == tripStateCopy.StartDate { // Substract the steps that were already there when we started contribution := steps - tripStateCopy.StartDayInitialSteps if contribution < 0 { contribution = 0 } totalSteps += contribution } else { totalSteps += steps } } sm.mu.Lock() defer sm.mu.Unlock() // Update State sm.tripState.DailyCache = newDailyCache sm.SaveTripState() // Update Smoothing Targets sm.previousTotalSteps = sm.calculateSmoothedTokenAt(time.Now()) // Snapshot current interpolated value as new start sm.targetTotalSteps = totalSteps sm.lastSyncTime = time.Now() sm.nextSyncTime = time.Now().Add(interval) fmt.Printf("Sync Complete. Total Trip Steps: %d\n", sm.targetTotalSteps) } // calculateSmoothedTokenAt returns the interpolated step count at a given time func (sm *StepManager) calculateSmoothedTokenAt(t time.Time) int { totalDuration := sm.nextSyncTime.Sub(sm.lastSyncTime) elapsed := t.Sub(sm.lastSyncTime) if totalDuration <= 0 { return sm.targetTotalSteps } progress := float64(elapsed) / float64(totalDuration) if progress < 0 { progress = 0 } if progress > 1 { progress = 1 } // Linear interpolation from Previous -> Target delta := sm.targetTotalSteps - sm.previousTotalSteps return sm.previousTotalSteps + int(float64(delta)*progress) } func (sm *StepManager) GetStatus() map[string]interface{} { sm.mu.Lock() defer sm.mu.Unlock() // Auto-trigger sync if needed if time.Now().After(sm.nextSyncTime) { go sm.Sync() // Async sync } currentSmoothed := sm.calculateSmoothedTokenAt(time.Now()) return map[string]interface{}{ "tripSteps": currentSmoothed, "nextSyncTime": sm.nextSyncTime.UnixMilli(), } } // RecalculateTotalFromState sums up the steps from the DailyCache without making external API calls func (sm *StepManager) RecalculateTotalFromState() int { total := 0 for dateStr, steps := range sm.tripState.DailyCache { // YYYY-MM-DD string comparison works for chronological order if dateStr < sm.tripState.StartDate { continue } if dateStr == sm.tripState.StartDate { contribution := steps - sm.tripState.StartDayInitialSteps if contribution < 0 { contribution = 0 } total += contribution } else { total += steps } } return total }