// Package store keeps the whole challenge in one JSON file on a mounted // volume. Seventy-five rows for one person does not need a database: the file // is read into memory at boot, every write goes back out atomically, and a // mutex serialises the handful of writes a day. package store import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "sync" "time" ) // DateLayout is the form every date takes, in the URL, in the file, and in the // map key. Local dates only; there is no time of day anywhere in the model. const DateLayout = "2006-01-02" // Settings is what the challenge is, as opposed to how it is deployed. It // lives in the data file so a target can change from the settings page. type Settings struct { StartDate string `json:"start_date"` // day 1 Length int `json:"length"` ProteinTarget int `json:"protein_target"` // grams, a floor CalorieTarget int `json:"calorie_target"` // kcal CalorieMode string `json:"calorie_mode"` // "under" (a ceiling) or "over" (a floor) WaterTarget int `json:"water_target"` // fluid ounces, a floor } // Defaults are what the setup form is pre-filled with. A gallon is 128 fl oz. func Defaults() Settings { return Settings{ Length: 75, ProteinTarget: 180, CalorieTarget: 2200, CalorieMode: "under", WaterTarget: 128, } } func (s Settings) Configured() bool { return s.StartDate != "" } // Start returns day 1 as a date. It is only valid once Configured. func (s Settings) Start() time.Time { t, _ := time.Parse(DateLayout, s.StartDate) return t } // End returns the last day of the challenge. func (s Settings) End() time.Time { return s.Start().AddDate(0, 0, s.Length-1) } // DayNumber returns the 1-based position of a date in the challenge, or 0 if // the date falls outside it. func (s Settings) DayNumber(date string) int { t, err := time.Parse(DateLayout, date) if err != nil || !s.Configured() { return 0 } n := int(t.Sub(s.Start()).Hours()/24) + 1 if n < 1 || n > s.Length { return 0 } return n } // Day is one day's log. The three numbers are pointers so that "not logged // yet" stays distinct from "logged as zero" — a day with no calories entered // should not read as a perfect calorie day. type Day struct { Date string `json:"date"` Workout bool `json:"workout"` Protein *int `json:"protein,omitempty"` Calories *int `json:"calories,omitempty"` Water *int `json:"water,omitempty"` Gratitude [3]string `json:"gratitude"` UpdatedAt time.Time `json:"updated_at,omitzero"` } // Logged reports whether the day has been touched at all, which is what // separates an empty future day from a day that was genuinely missed. func (d Day) Logged() bool { if d.Workout || d.Protein != nil || d.Calories != nil || d.Water != nil { return true } for _, g := range d.Gratitude { if strings.TrimSpace(g) != "" { return true } } return false } func (d Day) clone() Day { out := d out.Protein = clonePtr(d.Protein) out.Calories = clonePtr(d.Calories) out.Water = clonePtr(d.Water) return out } func clonePtr(p *int) *int { if p == nil { return nil } v := *p return &v } // Marks is which of the five things a day hit. The five are equal: the card on // the home page fills a day's tile by how many of them are true. type Marks struct { Workout bool Protein bool Calories bool Water bool Gratitude bool } func (m Marks) Score() int { n := 0 for _, ok := range [5]bool{m.Workout, m.Protein, m.Calories, m.Water, m.Gratitude} { if ok { n++ } } return n } func (m Marks) Complete() bool { return m.Score() == 5 } // Marks scores a day against the targets in force. func (s Settings) Marks(d Day) Marks { m := Marks{Workout: d.Workout} if d.Protein != nil && *d.Protein >= s.ProteinTarget { m.Protein = true } if d.Calories != nil { if s.CalorieMode == "over" { m.Calories = *d.Calories >= s.CalorieTarget } else { m.Calories = *d.Calories <= s.CalorieTarget } } if d.Water != nil && *d.Water >= s.WaterTarget { m.Water = true } m.Gratitude = true for _, g := range d.Gratitude { if strings.TrimSpace(g) == "" { m.Gratitude = false break } } return m } // data is the on-disk shape. Version is here so a later change to the model // has something to branch on. type data struct { Version int `json:"version"` Settings Settings `json:"settings"` Days map[string]*Day `json:"days"` } const currentVersion = 1 // Store owns the file and the in-memory copy of it. type Store struct { path string mu sync.RWMutex d data } // Open reads the data file, or starts an empty one if it does not exist yet. // A file that exists but does not parse is an error: overwriting it with a // fresh empty challenge would throw away the log, which is the one thing here // that cannot be recreated. func Open(path string) (*Store, error) { s := &Store{ path: path, d: data{Version: currentVersion, Settings: Defaults(), Days: map[string]*Day{}}, } body, err := os.ReadFile(path) switch { case errors.Is(err, fs.ErrNotExist): if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, fmt.Errorf("create data directory: %w", err) } return s, nil case err != nil: return nil, fmt.Errorf("read %s: %w", path, err) } if err := json.Unmarshal(body, &s.d); err != nil { return nil, fmt.Errorf("parse %s (left untouched; move it aside to start over): %w", path, err) } if s.d.Days == nil { s.d.Days = map[string]*Day{} } // Keep the copy that was known to parse. If a later write ever produces // something unreadable, this is the file to rename back. if err := os.WriteFile(path+".bak", body, 0o644); err != nil { return nil, fmt.Errorf("write backup: %w", err) } return s, nil } func (s *Store) Settings() Settings { s.mu.RLock() defer s.mu.RUnlock() return s.d.Settings } func (s *Store) SaveSettings(next Settings) error { s.mu.Lock() defer s.mu.Unlock() s.d.Settings = next return s.persist() } // Day returns the log for a date, or an empty one if nothing is recorded. func (s *Store) Day(date string) Day { s.mu.RLock() defer s.mu.RUnlock() if d, ok := s.d.Days[date]; ok { return d.clone() } return Day{Date: date} } // SaveDay writes one day. A day emptied of everything is deleted rather than // stored blank, so an accidental visit to a future date leaves no trace. func (s *Store) SaveDay(d Day) error { s.mu.Lock() defer s.mu.Unlock() if !d.Logged() { delete(s.d.Days, d.Date) return s.persist() } d.UpdatedAt = time.Now().UTC() stored := d.clone() s.d.Days[d.Date] = &stored return s.persist() } // Days returns count consecutive days starting at from, including the blanks, // so the caller can lay out the card without checking for gaps. func (s *Store) Days(from time.Time, count int) []Day { s.mu.RLock() defer s.mu.RUnlock() out := make([]Day, 0, count) for i := 0; i < count; i++ { date := from.AddDate(0, 0, i).Format(DateLayout) if d, ok := s.d.Days[date]; ok { out = append(out, d.clone()) continue } out = append(out, Day{Date: date}) } return out } // Export returns the whole file as it would be written, for the download. func (s *Store) Export() ([]byte, error) { s.mu.RLock() defer s.mu.RUnlock() return json.MarshalIndent(s.d, "", " ") } // Import replaces everything with a previously exported file. func (s *Store) Import(body []byte) error { var next data if err := json.Unmarshal(body, &next); err != nil { return fmt.Errorf("that file is not a fit export: %w", err) } if next.Days == nil { next.Days = map[string]*Day{} } for date, d := range next.Days { if _, err := time.Parse(DateLayout, date); err != nil { return fmt.Errorf("%q is not a date", date) } d.Date = date } s.mu.Lock() defer s.mu.Unlock() next.Version = currentVersion s.d = next return s.persist() } // persist writes the file atomically: a temp file in the same directory, then // a rename. A crash mid-write therefore leaves the previous file intact rather // than a half-written one. Callers hold the write lock. func (s *Store) persist() error { body, err := json.MarshalIndent(s.d, "", " ") if err != nil { return fmt.Errorf("encode: %w", err) } dir := filepath.Dir(s.path) tmp, err := os.CreateTemp(dir, ".fit-*.json") if err != nil { return fmt.Errorf("create temp file: %w", err) } name := tmp.Name() defer os.Remove(name) // no-op once the rename has succeeded if _, err := tmp.Write(body); err != nil { tmp.Close() return fmt.Errorf("write temp file: %w", err) } // Flush to the disk before the rename, so a power cut cannot leave the // file renamed into place but empty. if err := tmp.Sync(); err != nil { tmp.Close() return fmt.Errorf("sync temp file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) } if err := os.Chmod(name, 0o644); err != nil { return fmt.Errorf("chmod temp file: %w", err) } if err := os.Rename(name, s.path); err != nil { return fmt.Errorf("replace data file: %w", err) } // Fsync the directory so the rename itself is durable. if handle, err := os.Open(dir); err == nil { handle.Sync() handle.Close() } return nil }