This commit is contained in:
269
server/step_manager.go
Normal file
269
server/step_manager.go
Normal file
@@ -0,0 +1,269 @@
|
||||
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
|
||||
start, _ := time.Parse("2006-01-02", tripStateCopy.StartDate)
|
||||
end, _ := time.Parse("2006-01-02", today)
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user