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) }