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:
511
internal/web/handlers.go
Normal file
511
internal/web/handlers.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/tracker/internal/store"
|
||||
)
|
||||
|
||||
// gratitudeMax caps one gratitude line. It is a line, not an essay, and a cap
|
||||
// keeps a stuck key from growing the data file without bound.
|
||||
const gratitudeMax = 280
|
||||
|
||||
// ---------------------------------------------------------------- the day
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.store.Settings()
|
||||
if !st.Configured() {
|
||||
s.render(w, http.StatusOK, "setup.html", pageData{
|
||||
Title: "Set up the challenge",
|
||||
Settings: store.Defaults(),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.showDay(w, r, s.landingDate(st))
|
||||
}
|
||||
|
||||
// landingDate is the day the home page opens on: today while the challenge is
|
||||
// running, and the nearest end of it otherwise, so the page always has
|
||||
// something real to show.
|
||||
func (s *Server) landingDate(st store.Settings) string {
|
||||
today := s.today()
|
||||
if st.DayNumber(today) > 0 {
|
||||
return today
|
||||
}
|
||||
if today < st.StartDate {
|
||||
return st.StartDate
|
||||
}
|
||||
return st.End().Format(store.DateLayout)
|
||||
}
|
||||
|
||||
func (s *Server) handleDay(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.store.Settings()
|
||||
if !st.Configured() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
date, ok := validDate(r.PathValue("date"))
|
||||
if !ok {
|
||||
s.fail(w, http.StatusNotFound, "That is not a date.")
|
||||
return
|
||||
}
|
||||
s.showDay(w, r, date)
|
||||
}
|
||||
|
||||
func (s *Server) showDay(w http.ResponseWriter, r *http.Request, date string) {
|
||||
st := s.store.Settings()
|
||||
day := s.store.Day(date)
|
||||
card := s.card(st)
|
||||
|
||||
data := pageData{
|
||||
Title: "Day " + strconv.Itoa(st.DayNumber(date)),
|
||||
Settings: st,
|
||||
Day: day,
|
||||
View: s.view(st, day),
|
||||
Meters: meters(st, day),
|
||||
Card: card,
|
||||
Stats: s.stats(st, card),
|
||||
PrevDate: s.step(st, date, -1),
|
||||
NextDate: s.step(st, date, +1),
|
||||
Status: s.status(st),
|
||||
}
|
||||
if st.DayNumber(date) == 0 {
|
||||
data.Title = "Off the calendar"
|
||||
}
|
||||
if r.URL.Query().Get("saved") != "" {
|
||||
data.Flash = "Saved"
|
||||
}
|
||||
s.render(w, http.StatusOK, "day.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveDay(w http.ResponseWriter, r *http.Request) {
|
||||
date, ok := validDate(r.PathValue("date"))
|
||||
if !ok {
|
||||
s.respondError(w, r, http.StatusNotFound, "That is not a date.")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.respondError(w, r, http.StatusBadRequest, "That form did not arrive intact.")
|
||||
return
|
||||
}
|
||||
|
||||
day := store.Day{Date: date, Workout: r.FormValue("workout") != ""}
|
||||
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
label string
|
||||
into **int
|
||||
}{
|
||||
{"protein", "Protein", &day.Protein},
|
||||
{"calories", "Calories", &day.Calories},
|
||||
{"water", "Water", &day.Water},
|
||||
} {
|
||||
value, ok := optionalInt(r.FormValue(field.name))
|
||||
if !ok {
|
||||
s.respondError(w, r, http.StatusBadRequest, field.label+" has to be a whole number.")
|
||||
return
|
||||
}
|
||||
*field.into = value
|
||||
}
|
||||
|
||||
for i := range day.Gratitude {
|
||||
day.Gratitude[i] = truncate(strings.TrimSpace(r.FormValue("gratitude"+strconv.Itoa(i+1))), gratitudeMax)
|
||||
}
|
||||
|
||||
if err := s.store.SaveDay(day); err != nil {
|
||||
s.respondError(w, r, http.StatusInternalServerError, "The day did not save: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
st := s.store.Settings()
|
||||
marks := st.Marks(day)
|
||||
|
||||
// The background save from the page wants the new score back to repaint
|
||||
// today's tile; a plain form post wants to land back on the day.
|
||||
if wantsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"score": marks.Score(),
|
||||
"complete": marks.Complete(),
|
||||
"saved": "Saved",
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/d/"+date+"?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.store.Settings().Configured() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next, err := s.settingsFromForm(r, store.Defaults())
|
||||
if err != nil {
|
||||
s.render(w, http.StatusBadRequest, "setup.html", pageData{
|
||||
Title: "Set up the challenge",
|
||||
Settings: next,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.store.SaveSettings(next); err != nil {
|
||||
s.fail(w, http.StatusInternalServerError, "The settings did not save: "+err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.store.Settings()
|
||||
if !st.Configured() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
data := pageData{Title: "Settings", Settings: st}
|
||||
if r.URL.Query().Get("saved") != "" {
|
||||
data.Flash = "Settings saved"
|
||||
}
|
||||
if r.URL.Query().Get("imported") != "" {
|
||||
data.Flash = "Log restored from the file"
|
||||
}
|
||||
s.render(w, http.StatusOK, "settings.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
||||
current := s.store.Settings()
|
||||
next, err := s.settingsFromForm(r, current)
|
||||
if err != nil {
|
||||
s.render(w, http.StatusBadRequest, "settings.html", pageData{
|
||||
Title: "Settings",
|
||||
Settings: next,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := s.store.SaveSettings(next); err != nil {
|
||||
s.fail(w, http.StatusInternalServerError, "The settings did not save: "+err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// settingsFromForm reads the setup and settings forms, which are the same
|
||||
// fields. On error it returns what the user typed, so the form comes back
|
||||
// filled in rather than reset.
|
||||
func (s *Server) settingsFromForm(r *http.Request, current store.Settings) (store.Settings, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return current, errText("That form did not arrive intact.")
|
||||
}
|
||||
next := current
|
||||
|
||||
if raw := strings.TrimSpace(r.FormValue("start_date")); raw != "" {
|
||||
if _, err := time.Parse(store.DateLayout, raw); err != nil {
|
||||
return next, errText("The start date needs to look like 2026-08-01.")
|
||||
}
|
||||
next.StartDate = raw
|
||||
}
|
||||
if next.StartDate == "" {
|
||||
return next, errText("Pick the day you are counting from.")
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
label string
|
||||
min int
|
||||
max int
|
||||
into *int
|
||||
}{
|
||||
{"length", "The challenge", 1, 3650, &next.Length},
|
||||
{"protein_target", "The protein target", 1, 1000, &next.ProteinTarget},
|
||||
{"calorie_target", "The calorie target", 1, 20000, &next.CalorieTarget},
|
||||
{"water_target", "The water target", 1, 1000, &next.WaterTarget},
|
||||
} {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(r.FormValue(field.name)))
|
||||
if err != nil || n < field.min || n > field.max {
|
||||
return next, errText(field.label + " needs a number between " +
|
||||
strconv.Itoa(field.min) + " and " + strconv.Itoa(field.max) + ".")
|
||||
}
|
||||
*field.into = n
|
||||
}
|
||||
|
||||
next.CalorieMode = "under"
|
||||
if r.FormValue("calorie_mode") == "over" {
|
||||
next.CalorieMode = "over"
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- backup
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := s.store.Export()
|
||||
if err != nil {
|
||||
s.fail(w, http.StatusInternalServerError, "The export did not build: "+err.Error())
|
||||
return
|
||||
}
|
||||
name := "tracker-" + s.today() + ".json"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
// A challenge this size is a few tens of kilobytes; the cap is only here
|
||||
// so a wrong file cannot be read into memory whole.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
s.fail(w, http.StatusBadRequest, "That upload was too large or malformed.")
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("backup")
|
||||
if err != nil {
|
||||
s.fail(w, http.StatusBadRequest, "Choose an export file first.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
body, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
s.fail(w, http.StatusBadRequest, "That file could not be read.")
|
||||
return
|
||||
}
|
||||
if err := s.store.Import(body); err != nil {
|
||||
s.fail(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings?imported=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleManifest makes the page installable, so it opens from the home screen
|
||||
// without Safari's chrome and keeps its own window.
|
||||
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": "75",
|
||||
"short_name": "75",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#E6EEEF",
|
||||
"theme_color": "#0E3140",
|
||||
"icons": []map[string]any{{
|
||||
"src": s.asset("/static/icon.svg"),
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
|
||||
func (s *Server) view(st store.Settings, day store.Day) dayView {
|
||||
t, _ := time.Parse(store.DateLayout, day.Date)
|
||||
marks := st.Marks(day)
|
||||
today := s.today()
|
||||
|
||||
return dayView{
|
||||
Date: day.Date,
|
||||
Time: t,
|
||||
Number: st.DayNumber(day.Date),
|
||||
Marks: marks,
|
||||
Score: marks.Score(),
|
||||
Logged: day.Logged(),
|
||||
IsToday: day.Date == today,
|
||||
IsFuture: day.Date > today,
|
||||
}
|
||||
}
|
||||
|
||||
// card is every day of the challenge in order, blanks included.
|
||||
func (s *Server) card(st store.Settings) []dayView {
|
||||
days := s.store.Days(st.Start(), st.Length)
|
||||
out := make([]dayView, 0, len(days))
|
||||
for _, d := range days {
|
||||
out = append(out, s.view(st, d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) stats(st store.Settings, card []dayView) stats {
|
||||
var out stats
|
||||
today := s.today()
|
||||
|
||||
for _, v := range card {
|
||||
if v.Marks.Complete() {
|
||||
out.Complete++
|
||||
}
|
||||
if v.Logged {
|
||||
out.Logged++
|
||||
}
|
||||
if v.Date <= today {
|
||||
out.Elapsed++
|
||||
}
|
||||
for i, hit := range [5]bool{v.Marks.Workout, v.Marks.Protein, v.Marks.Calories, v.Marks.Water, v.Marks.Gratitude} {
|
||||
if hit {
|
||||
out.Marks[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Remaining = st.Length - out.Elapsed
|
||||
|
||||
// The streak runs back from the most recent day that could still count.
|
||||
// Today is skipped when it is not finished yet, so an unlogged morning
|
||||
// does not read as a broken streak.
|
||||
i := out.Elapsed - 1
|
||||
if i >= 0 && i < len(card) && !card[i].Marks.Complete() {
|
||||
i--
|
||||
}
|
||||
for ; i >= 0 && card[i].Marks.Complete(); i-- {
|
||||
out.Streak++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// meters builds the three number bands. Protein and water fill towards a floor
|
||||
// they should reach; calories fill towards a ceiling they should not cross,
|
||||
// which is why the bar can overflow.
|
||||
func meters(st store.Settings, day store.Day) []meter {
|
||||
calorieMode := "ceiling"
|
||||
calorieNote := "Stay under " + strconv.Itoa(st.CalorieTarget)
|
||||
if st.CalorieMode == "over" {
|
||||
calorieMode = "floor"
|
||||
calorieNote = "Eat at least " + strconv.Itoa(st.CalorieTarget)
|
||||
}
|
||||
|
||||
out := []meter{
|
||||
{Key: "protein", Label: "Protein", Unit: "g", Value: day.Protein,
|
||||
Target: st.ProteinTarget, Mode: "floor",
|
||||
Note: "Hit " + strconv.Itoa(st.ProteinTarget) + " g"},
|
||||
{Key: "calories", Label: "Calories", Unit: "kcal", Value: day.Calories,
|
||||
Target: st.CalorieTarget, Mode: calorieMode, Note: calorieNote},
|
||||
{Key: "water", Label: "Water", Unit: "oz", Value: day.Water,
|
||||
Target: st.WaterTarget, Mode: "floor",
|
||||
Note: waterNote(st.WaterTarget)},
|
||||
}
|
||||
|
||||
marks := st.Marks(day)
|
||||
met := [3]bool{marks.Protein, marks.Calories, marks.Water}
|
||||
for i := range out {
|
||||
out[i].Met = met[i]
|
||||
if out[i].Value == nil || out[i].Target <= 0 {
|
||||
continue
|
||||
}
|
||||
out[i].Pct = min(100, *out[i].Value*100/out[i].Target)
|
||||
out[i].Over = out[i].Mode == "ceiling" && *out[i].Value > out[i].Target
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func waterNote(target int) string {
|
||||
if target == 128 {
|
||||
return "A gallon"
|
||||
}
|
||||
return "Drink " + strconv.Itoa(target) + " oz"
|
||||
}
|
||||
|
||||
// step returns the neighbouring date, held inside the challenge and never past
|
||||
// today. It returns "" when there is nowhere to go, which hides the arrow.
|
||||
func (s *Server) step(st store.Settings, date string, delta int) string {
|
||||
t, err := time.Parse(store.DateLayout, date)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
next := t.AddDate(0, 0, delta).Format(store.DateLayout)
|
||||
if next < st.StartDate || next > st.End().Format(store.DateLayout) {
|
||||
return ""
|
||||
}
|
||||
if next > s.today() {
|
||||
return ""
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) status(st store.Settings) string {
|
||||
today := s.today()
|
||||
switch {
|
||||
case today < st.StartDate:
|
||||
return "before"
|
||||
case today > st.End().Format(store.DateLayout):
|
||||
return "after"
|
||||
default:
|
||||
return "during"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, status int, message string) {
|
||||
s.render(w, status, "error.html", pageData{Title: "Nothing here", Error: message})
|
||||
}
|
||||
|
||||
// respondError answers in whichever form the request came in, so a background
|
||||
// save reports the problem instead of failing silently.
|
||||
func (s *Server) respondError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
if wantsJSON(r) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
return
|
||||
}
|
||||
s.fail(w, status, message)
|
||||
}
|
||||
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
return r.Header.Get("X-Sync") == "1"
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
if w.Header().Get("Content-Type") == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// validDate keeps anything but a real date out of the map keys and the URLs.
|
||||
func validDate(raw string) (string, bool) {
|
||||
t, err := time.Parse(store.DateLayout, raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Reject forms that parse but do not round-trip, so one day has one URL.
|
||||
if t.Format(store.DateLayout) != raw {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// optionalInt reads a number field that is allowed to be blank. Blank means
|
||||
// not logged, which is deliberately different from zero.
|
||||
func optionalInt(raw string) (*int, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, true
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
return nil, false
|
||||
}
|
||||
if n > 100000 {
|
||||
n = 100000
|
||||
}
|
||||
return &n, true
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:max]))
|
||||
}
|
||||
|
||||
// errText is an error that is already phrased for the person reading it.
|
||||
type errText string
|
||||
|
||||
func (e errText) Error() string { return string(e) }
|
||||
Reference in New Issue
Block a user