The workout, the protein, the calories, the water, and three lines of gratitude. One page per day, a card of seventy-five tiles that fill by how many of the five landed, and a missed day left as a gap rather than a reset. Single Go binary with no dependencies. The log is one JSON file on a mounted volume, written atomically and never silently replaced when it fails to parse, since it is the one thing here that cannot be recreated. Auth is Traefik basic auth at the ingress; the app has no login of its own, so every POST checks Sec-Fetch-Site to stop another origin posting with those credentials. 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 tracker 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, ".tracker-*.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
|
|
}
|