Files
pedestrian-simulator/server/step_manager.go
Steven Polley 24ecddd034
All checks were successful
pedestrian-simulator / build (push) Successful in 53s
initial commit
2026-01-11 17:16:59 -07:00

270 lines
7.2 KiB
Go

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
}