Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
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>
512 lines
14 KiB
Go
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) }
|