2026-01-11 17:16:59 -07:00
|
|
|
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")
|
|
|
|
|
|
2026-01-11 18:34:22 -07:00
|
|
|
// 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)
|
2026-01-11 17:16:59 -07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|