Files
fit/internal/web/handlers.go
scott 3168a4ef96
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
Rename the app from tracker to fit
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>
2026-08-01 22:03:33 -07:00

512 lines
14 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/scottyah/fit/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 := "fit-" + 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) }