Files
fit/internal/store/store.go
scott 3168a4ef96
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
Rename the app from tracker to fit
The directory, the repo and the hostname are all fit; the module path,
the image, the namespace and the data file were still tracker. Nothing
is deployed yet, so this is free now and would not be later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:03:33 -07:00

350 lines
9.1 KiB
Go

// 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
}