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

201
internal/web/web.go Normal file
View File

@@ -0,0 +1,201 @@
package web
import (
"crypto/sha256"
"embed"
"encoding/base64"
"fmt"
"html/template"
"log/slog"
"net/http"
"time"
"github.com/scottyah/tracker/internal/config"
"github.com/scottyah/tracker/internal/store"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static/*
var staticFS embed.FS
type Server struct {
cfg *config.Config
store *store.Store
tpl *template.Template
mux *http.ServeMux
assetVer string // content hash, appended to static URLs
now func() time.Time // swapped in tests; every date derives from it
}
func New(cfg *config.Config, st *store.Store) (*Server, error) {
s := &Server{cfg: cfg, store: st, assetVer: assetVersion(), now: time.Now}
tpl, err := template.New("").Funcs(s.templateFuncs()).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
s.tpl = tpl
s.routes()
return s, nil
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameSite(r) {
http.Error(w, "cross-site write refused", http.StatusForbidden)
return
}
s.mux.ServeHTTP(w, r)
}
// sameSite rejects writes a browser reports as coming from somewhere else.
// The only thing guarding this app is basic auth at the ingress, and browsers
// attach those credentials to a cross-origin form post, so without this check
// a page on any other domain could quietly overwrite the log. Requests with no
// Sec-Fetch-Site header are not browsers and are left alone.
func sameSite(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "", "same-origin", "none":
return true
default:
return false
}
}
func (s *Server) routes() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("POST /setup", s.handleSetup)
mux.HandleFunc("GET /d/{date}", s.handleDay)
mux.HandleFunc("POST /d/{date}", s.handleSaveDay)
mux.HandleFunc("GET /settings", s.handleSettings)
mux.HandleFunc("POST /settings", s.handleSaveSettings)
mux.HandleFunc("GET /export.json", s.handleExport)
mux.HandleFunc("POST /import", s.handleImport)
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
// Static assets are immutable in the image, so they cache hard.
static := http.FileServer(http.FS(staticFS))
mux.Handle("GET /static/", cacheForever(static))
s.mux = mux
}
func cacheForever(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe to cache hard because every reference carries a content hash;
// changing a file changes its URL.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
h.ServeHTTP(w, r)
})
}
// assetVersion hashes the embedded static files so a stylesheet edit produces
// a new URL. Without it, a deploy would leave the phone on a week-old CSS.
func assetVersion() string {
sum := sha256.New()
entries, err := staticFS.ReadDir("static")
if err != nil {
return "dev"
}
for _, entry := range entries {
body, err := staticFS.ReadFile("static/" + entry.Name())
if err != nil {
continue
}
sum.Write([]byte(entry.Name()))
sum.Write(body)
}
return base64.RawURLEncoding.EncodeToString(sum.Sum(nil))[:10]
}
// ---------------------------------------------------------------- view model
// dayView is one day as the page needs it: the log itself plus everything
// derived from the calendar and the targets.
type dayView struct {
Date string
Time time.Time
Number int // 1-based position in the challenge, 0 outside it
Marks store.Marks
Score int
Logged bool
IsToday bool
IsFuture bool
}
// meter is one of the three numbers, with everything the band needs to draw
// itself. Mode is "floor" for a target to reach and "ceiling" for a budget not
// to exceed, which is the difference between protein and calories.
type meter struct {
Key string
Label string
Unit string
Value *int
Target int
Mode string
Pct int
Met bool
Over bool
Note string
}
type stats struct {
Complete int
Logged int
Elapsed int // days of the challenge that have started, capped at Length
Remaining int
Streak int
Marks [5]int // how many days each of the five was hit
}
type pageData struct {
Title string
Settings store.Settings
Day store.Day
View dayView
Meters []meter
Card []dayView
Stats stats
PrevDate string
NextDate string
Today string
Status string // "before", "during" or "after" the challenge window
Flash string
Error string
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data pageData) {
data.Today = s.today()
if data.Settings.Length == 0 {
data.Settings = s.store.Settings()
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
slog.Error("render template", "template", name, "err", err)
}
}
// today is the current date in the configured timezone. Every date in the app
// comes from here, so the day rolls over at local midnight rather than UTC's.
func (s *Server) today() string {
return s.now().In(s.cfg.TZ).Format(store.DateLayout)
}
// asset stamps a static path with the build's content hash.
func (s *Server) asset(path string) string {
return path + "?v=" + s.assetVer
}