Track five things a day for seventy-five days

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>
This commit is contained in:
2026-08-01 21:59:01 -07:00
commit faf73c8be5
25 changed files with 3197 additions and 0 deletions

349
internal/store/store.go Normal file
View File

@@ -0,0 +1,349 @@
// 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
}