Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
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>
350 lines
9.1 KiB
Go
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
|
|
}
|