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:
65
internal/web/funcs.go
Normal file
65
internal/web/funcs.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"asset": s.asset,
|
||||
"num": num,
|
||||
"dateLong": dateLong,
|
||||
"dateShort": dateShort,
|
||||
"weekday": weekday,
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"pluralDays": pluralDays,
|
||||
"tileTitle": tileTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// num renders an optional number for a form field: blank stays blank, so an
|
||||
// unlogged day does not come back pre-filled with a zero.
|
||||
func num(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
// dateLong renders "Thursday 31 July" for the day being logged.
|
||||
func dateLong(t time.Time) string {
|
||||
return t.Format("Monday 2 January")
|
||||
}
|
||||
|
||||
// dateShort renders "31 Jul" for the tiles.
|
||||
func dateShort(t time.Time) string {
|
||||
return t.Format("2 Jan")
|
||||
}
|
||||
|
||||
func weekday(t time.Time) string {
|
||||
return t.Format("Mon")
|
||||
}
|
||||
|
||||
func pluralDays(n int) string {
|
||||
if n == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return strconv.Itoa(n) + " days"
|
||||
}
|
||||
|
||||
// tileTitle is the hover and screen-reader text for a square on the card.
|
||||
func tileTitle(v dayView) string {
|
||||
label := "Day " + strconv.Itoa(v.Number) + ", " + dateShort(v.Time) + " — "
|
||||
switch {
|
||||
case v.IsFuture:
|
||||
return label + "still ahead"
|
||||
case v.Marks.Complete():
|
||||
return label + "all five"
|
||||
case v.Score == 0:
|
||||
return label + "nothing logged"
|
||||
default:
|
||||
return label + strconv.Itoa(v.Score) + " of 5"
|
||||
}
|
||||
}
|
||||
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) }
|
||||
636
internal/web/static/app.css
Normal file
636
internal/web/static/app.css
Normal file
@@ -0,0 +1,636 @@
|
||||
/* 75 — a daily log for one person, opened on a phone.
|
||||
The look is municipal pool tile: a pale aqua ground, grout lines, deep
|
||||
navy-teal ink, chlorine blue for anything that fills, and the orange of
|
||||
backstroke flags for the day you are standing on. */
|
||||
|
||||
:root {
|
||||
--tile: #E6EEEF;
|
||||
--grout: #C6D6DA;
|
||||
--chalk: #FBFDFD;
|
||||
--ink: #0E3140;
|
||||
--ink-soft: #557987;
|
||||
--lane: #0F7391;
|
||||
--lane-wash: rgba(15, 115, 145, 0.16);
|
||||
--flag: #D8471C;
|
||||
--flag-wash: rgba(216, 71, 28, 0.14);
|
||||
|
||||
--display: "Avenir Next Condensed", "Arial Narrow", Impact, sans-serif;
|
||||
--body: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--gap: 0.5rem;
|
||||
--radius: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--tile: #0B1E27;
|
||||
--grout: #1F3F4D;
|
||||
--chalk: #102A35;
|
||||
--ink: #DCEAED;
|
||||
--ink-soft: #7E9EA9;
|
||||
--lane: #3FA9CC;
|
||||
--lane-wash: rgba(63, 169, 204, 0.20);
|
||||
--flag: #FF7043;
|
||||
--flag-wash: rgba(255, 112, 67, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--tile);
|
||||
color: var(--ink);
|
||||
font-family: var(--body);
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--lane); }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--flag);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem calc(2rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.page-narrow { max-width: 26rem; }
|
||||
|
||||
/* ----------------------------------------------------------- masthead */
|
||||
|
||||
.masthead { margin-bottom: 1.5rem; }
|
||||
|
||||
.masthead-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: block;
|
||||
font-family: var(--display);
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--ink-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.step.is-spent { opacity: 0.2; }
|
||||
|
||||
.masthead-day { text-align: center; }
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.masthead-count {
|
||||
margin: 0;
|
||||
font-family: var(--display);
|
||||
font-size: clamp(4.5rem, 22vw, 6.5rem);
|
||||
font-weight: 600;
|
||||
line-height: 0.82;
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.masthead-count.is-spent { color: var(--ink-soft); }
|
||||
|
||||
.masthead-of {
|
||||
margin: 0.15rem 0 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.masthead-date {
|
||||
margin: 0.9rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.masthead-date b { color: var(--ink); }
|
||||
|
||||
.masthead-back { margin-left: 0.5rem; white-space: nowrap; }
|
||||
|
||||
.notice, .alert, .flash {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notice { background: var(--lane-wash); }
|
||||
.flash { background: var(--lane-wash); }
|
||||
.alert { background: var(--flag-wash); color: var(--ink); }
|
||||
|
||||
/* --------------------------------------------------------------- bands
|
||||
Every measurable thing is a band that fills. Protein and water fill
|
||||
towards a floor to reach; calories fill towards a ceiling, so the band
|
||||
itself is the budget and running past it turns the whole thing hot. */
|
||||
|
||||
.log {
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.band {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 0 0.5rem;
|
||||
grid-template-columns: 1fr auto 2rem;
|
||||
grid-template-areas:
|
||||
"label entry tick"
|
||||
"note note tick";
|
||||
min-height: 4.25rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: var(--chalk);
|
||||
border: 1px solid var(--grout);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The fill runs inside a track that stops short of the tick, so a band at 99%
|
||||
never slides its water line underneath the verdict. */
|
||||
.band-track {
|
||||
position: absolute;
|
||||
inset: 0 3.35rem 0 0; /* clears the tick column, its gap and the padding */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The water line is painted as the last two pixels of the fill's own gradient
|
||||
rather than a border, so an empty band shows nothing at all. */
|
||||
.band-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--fill, 0%);
|
||||
background: linear-gradient(to right,
|
||||
var(--lane-wash) calc(100% - 2px), var(--lane) calc(100% - 2px));
|
||||
transition: width 220ms ease-out;
|
||||
}
|
||||
|
||||
.band-label {
|
||||
grid-area: label;
|
||||
position: relative;
|
||||
font-family: var(--display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.band-note {
|
||||
grid-area: note;
|
||||
position: relative;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.band-entry {
|
||||
grid-area: entry;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.band-input {
|
||||
width: 4.5ch;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 2px solid var(--grout);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.band-input::-webkit-inner-spin-button,
|
||||
.band-input::-webkit-outer-spin-button { appearance: none; margin: 0; }
|
||||
|
||||
.band-input:focus { outline: 0; border-bottom-color: var(--flag); }
|
||||
|
||||
.band-unit {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.band-tick {
|
||||
grid-area: tick;
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: var(--grout);
|
||||
}
|
||||
|
||||
.band-tick::before { content: "○"; }
|
||||
|
||||
.band.is-met { border-color: var(--lane); }
|
||||
.band.is-met .band-tick { color: var(--lane); }
|
||||
.band.is-met .band-tick::before { content: "●"; }
|
||||
|
||||
/* Past the ceiling there is nothing left to measure, so the meter gives way to
|
||||
the whole band going hot. */
|
||||
.band.is-over { border-color: var(--flag); background: var(--flag-wash); }
|
||||
.band.is-over .band-fill { background: none; }
|
||||
.band.is-over .band-tick { color: var(--flag); }
|
||||
.band.is-over .band-tick::before { content: "▲"; }
|
||||
|
||||
/* the workout band is one tap on the whole thing */
|
||||
|
||||
.band-toggle {
|
||||
grid-template-areas: "label state tick";
|
||||
grid-template-rows: auto;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.band-check {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px; height: 1px;
|
||||
}
|
||||
|
||||
.band-check:focus-visible ~ .band-label {
|
||||
outline: 2px solid var(--flag);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.band-state {
|
||||
grid-area: state;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.state-on { display: none; }
|
||||
.band-check:checked ~ .band-state .state-on { display: inline; color: var(--lane); }
|
||||
.band-check:checked ~ .band-state .state-off { display: none; }
|
||||
|
||||
.band-toggle:active { transform: scale(0.995); }
|
||||
|
||||
/* ----------------------------------------------------------- gratitude */
|
||||
|
||||
.gratitude {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.85rem 0.9rem;
|
||||
background: var(--chalk);
|
||||
border: 1px solid var(--grout);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.gratitude.is-met { border-color: var(--lane); }
|
||||
|
||||
.gratitude legend {
|
||||
padding: 0 0.35rem;
|
||||
margin-left: -0.35rem;
|
||||
font-family: var(--display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gratitude-hint {
|
||||
margin: 0 0 0.6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.gratitude-lines {
|
||||
list-style: none;
|
||||
counter-reset: line;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.gratitude-lines li {
|
||||
counter-increment: line;
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gratitude-lines li::before {
|
||||
content: counter(line);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.gratitude-input {
|
||||
width: 100%;
|
||||
padding: 0.2rem 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--grout);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--body);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.gratitude-input:focus { outline: 0; border-bottom-color: var(--flag); }
|
||||
|
||||
/* --------------------------------------------------------------- saving */
|
||||
|
||||
.log-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.save {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: var(--ink);
|
||||
color: var(--tile);
|
||||
font-family: var(--display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-quiet {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--grout);
|
||||
}
|
||||
|
||||
.log-status {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lane);
|
||||
}
|
||||
|
||||
.log-status.is-bad { color: var(--flag); }
|
||||
|
||||
/* Once the page can save in the background, the button is only there for the
|
||||
no-JavaScript case. */
|
||||
.has-sync .save-day { display: none; }
|
||||
|
||||
/* ----------------------------------------------------------------- card
|
||||
Seventy-five tiles, fifteen across. Each one fills from the bottom by how
|
||||
many of the five that day hit, so a glance shows the texture of the whole
|
||||
challenge rather than a row of pass/fail. */
|
||||
|
||||
.card { margin-top: 2.25rem; }
|
||||
|
||||
.card-heading {
|
||||
margin: 0 0 0.7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 400;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
/* minmax(0,…) rather than 1fr: a track that cannot shrink below its content
|
||||
pushes the fifteenth column off the edge on a narrow phone. */
|
||||
grid-template-columns: repeat(15, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: var(--chalk);
|
||||
border: 1px solid var(--grout);
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile a {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tile-fill {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: calc(var(--score, 0) * 20%);
|
||||
background: var(--lane);
|
||||
transition: height 220ms ease-out;
|
||||
}
|
||||
|
||||
.tile-full { border-color: var(--lane); }
|
||||
|
||||
.tile-ahead {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tile-today {
|
||||
outline: 2px solid var(--flag);
|
||||
outline-offset: 1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tally {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0.9rem 0 0;
|
||||
border-top: 1px solid var(--grout);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tally dt {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.tally dd {
|
||||
margin: 0.1rem 0 0;
|
||||
font-family: var(--display);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.legend {
|
||||
list-style: none;
|
||||
margin: 1.1rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.legend li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
border-bottom: 1px dotted var(--grout);
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.legend b { color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --------------------------------------------------------------- sheets */
|
||||
|
||||
.sheet {
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 1rem;
|
||||
background: var(--chalk);
|
||||
border: 1px solid var(--grout);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.sheet-heading {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sheet-note {
|
||||
margin: 0.9rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.field input, .field select {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--grout);
|
||||
border-radius: var(--radius);
|
||||
background: var(--tile);
|
||||
color: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.field-row input { width: 7rem; }
|
||||
|
||||
.field-unit {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.restore {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--grout);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.restore label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.restore input { font-size: 0.85rem; }
|
||||
|
||||
/* --------------------------------------------------------------- footer */
|
||||
|
||||
.footer {
|
||||
max-width: 34rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem calc(2rem + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.footer a { color: var(--ink-soft); text-decoration: none; }
|
||||
.footer a:hover { color: var(--lane); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
161
internal/web/static/app.js
Normal file
161
internal/web/static/app.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// Progressive enhancement only. With JavaScript off the form posts, the page
|
||||
// reloads, and everything still works; this saves in the background instead,
|
||||
// so ticking a box does not cost a page load first thing in the morning.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var form = document.querySelector("[data-log]");
|
||||
if (!form) {
|
||||
confirmDestructiveForms();
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.add("has-sync");
|
||||
|
||||
var status = form.querySelector("[data-status]");
|
||||
var todayTile = document.querySelector(".tile-today");
|
||||
var timer = null;
|
||||
var dirty = false;
|
||||
|
||||
// ------------------------------------------------------------- painting
|
||||
// The meters redraw from the values already on the page rather than from
|
||||
// the response, so the band moves as you type instead of after a round trip.
|
||||
|
||||
function paintMeter(band) {
|
||||
var input = band.querySelector(".band-input");
|
||||
var target = parseInt(band.getAttribute("data-target"), 10);
|
||||
var ceiling = band.getAttribute("data-mode") === "ceiling";
|
||||
var raw = input.value.trim();
|
||||
var value = raw === "" ? null : parseInt(raw, 10);
|
||||
|
||||
if (value === null || isNaN(value) || !target) {
|
||||
band.style.setProperty("--fill", "0%");
|
||||
band.classList.remove("is-met", "is-over");
|
||||
return false;
|
||||
}
|
||||
|
||||
band.style.setProperty("--fill", Math.min(100, (value * 100) / target) + "%");
|
||||
var met = ceiling ? value <= target : value >= target;
|
||||
band.classList.toggle("is-met", met);
|
||||
band.classList.toggle("is-over", ceiling && value > target);
|
||||
return met;
|
||||
}
|
||||
|
||||
function gratitudeMet() {
|
||||
var lines = form.querySelectorAll(".gratitude-input");
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].value.trim() === "") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function repaint() {
|
||||
var score = 0;
|
||||
|
||||
var workout = form.querySelector(".band-check");
|
||||
if (workout) {
|
||||
form.querySelector(".band-toggle").classList.toggle("is-met", workout.checked);
|
||||
if (workout.checked) score++;
|
||||
}
|
||||
|
||||
var bands = form.querySelectorAll(".band-meter");
|
||||
for (var i = 0; i < bands.length; i++) {
|
||||
if (paintMeter(bands[i])) score++;
|
||||
}
|
||||
|
||||
var gratitude = form.querySelector(".gratitude");
|
||||
var gratitudeOK = gratitudeMet();
|
||||
if (gratitude) gratitude.classList.toggle("is-met", gratitudeOK);
|
||||
if (gratitudeOK) score++;
|
||||
|
||||
// Only the day being edited is on screen, so only its tile can change.
|
||||
if (todayTile && todayTile.getAttribute("data-date") === currentDate()) {
|
||||
todayTile.style.setProperty("--score", score);
|
||||
todayTile.classList.toggle("tile-full", score === 5);
|
||||
}
|
||||
}
|
||||
|
||||
function currentDate() {
|
||||
return form.getAttribute("action").replace("/d/", "");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- saving
|
||||
|
||||
function say(message, bad) {
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.classList.toggle("is-bad", !!bad);
|
||||
}
|
||||
|
||||
function save() {
|
||||
dirty = false;
|
||||
say("Saving");
|
||||
|
||||
fetch(form.getAttribute("action"), {
|
||||
method: "POST",
|
||||
headers: { "X-Sync": "1" },
|
||||
body: new FormData(form),
|
||||
keepalive: true
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || "That did not save.");
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
say("Saved");
|
||||
})
|
||||
.catch(function (err) {
|
||||
// Leaving the entry on screen means nothing typed is lost; the button
|
||||
// is still there to retry with.
|
||||
say(err.message || "Not saved — still on this device only", true);
|
||||
dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
function queue() {
|
||||
dirty = true;
|
||||
say("");
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(save, 600);
|
||||
}
|
||||
|
||||
form.addEventListener("input", function () {
|
||||
repaint();
|
||||
queue();
|
||||
});
|
||||
|
||||
// A checkbox is a decision, not a draft: write it straight away.
|
||||
form.addEventListener("change", function (event) {
|
||||
repaint();
|
||||
if (event.target.type === "checkbox") {
|
||||
clearTimeout(timer);
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
// Backgrounding the tab on a phone is how most edits end. Flush first.
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden" && dirty) {
|
||||
clearTimeout(timer);
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
repaint();
|
||||
confirmDestructiveForms();
|
||||
|
||||
function confirmDestructiveForms() {
|
||||
document.addEventListener("submit", function (event) {
|
||||
var message = event.target.getAttribute("data-confirm");
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
BIN
internal/web/static/icon.png
Normal file
BIN
internal/web/static/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 605 B |
24
internal/web/static/icon.svg
Normal file
24
internal/web/static/icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Nine tiles filling up">
|
||||
<rect width="512" height="512" rx="96" fill="#0E3140"/>
|
||||
<g fill="#FBFDFD" fill-opacity=".12">
|
||||
<rect x="52" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="196" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="340" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="52" y="196" width="120" height="120" rx="6"/>
|
||||
<rect x="196" y="196" width="120" height="120" rx="6"/>
|
||||
<rect x="340" y="196" width="120" height="120" rx="6"/>
|
||||
<rect x="52" y="340" width="120" height="120" rx="6"/>
|
||||
<rect x="196" y="340" width="120" height="120" rx="6"/>
|
||||
<rect x="340" y="340" width="120" height="120" rx="6"/>
|
||||
</g>
|
||||
<!-- Each tile fills from the bottom, the way a day's tile does on the card. -->
|
||||
<g fill="#3FA9CC">
|
||||
<rect x="52" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="196" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="340" y="52" width="120" height="120" rx="6"/>
|
||||
<rect x="52" y="196" width="120" height="120" rx="6"/>
|
||||
<rect x="196" y="220" width="120" height="96"/>
|
||||
<rect x="340" y="244" width="120" height="72"/>
|
||||
<rect x="52" y="388" width="120" height="72"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
93
internal/web/templates/day.html
Normal file
93
internal/web/templates/day.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{{template "head" .}}
|
||||
|
||||
<main class="page">
|
||||
|
||||
<header class="masthead">
|
||||
<nav class="masthead-nav">
|
||||
{{if .PrevDate}}
|
||||
<a class="step" href="/d/{{.PrevDate}}" rel="prev" aria-label="The day before">‹</a>
|
||||
{{else}}
|
||||
<span class="step is-spent" aria-hidden="true">‹</span>
|
||||
{{end}}
|
||||
|
||||
<div class="masthead-day">
|
||||
<p class="eyebrow">Day</p>
|
||||
{{if .View.Number}}
|
||||
<p class="masthead-count">{{.View.Number}}</p>
|
||||
<p class="masthead-of">of {{.Settings.Length}}</p>
|
||||
{{else}}
|
||||
<p class="masthead-count is-spent">·</p>
|
||||
<p class="masthead-of">outside the {{.Settings.Length}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .NextDate}}
|
||||
<a class="step" href="/d/{{.NextDate}}" rel="next" aria-label="The day after">›</a>
|
||||
{{else}}
|
||||
<span class="step is-spent" aria-hidden="true">›</span>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<p class="masthead-date">
|
||||
{{if .View.IsToday}}<b>Today</b> · {{end}}{{dateLong .View.Time}}
|
||||
{{if not .View.IsToday}}<a class="masthead-back" href="/">Back to today</a>{{end}}
|
||||
</p>
|
||||
|
||||
{{if eq .Status "before"}}
|
||||
<p class="notice">Day 1 is {{dateLong .Settings.Start}}. Nothing counts until then.</p>
|
||||
{{else if eq .Status "after"}}
|
||||
<p class="notice">The {{.Settings.Length}} days are done. {{.Stats.Complete}} of them were perfect.</p>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
<form class="log" method="post" action="/d/{{.View.Date}}" data-log>
|
||||
|
||||
<label class="band band-toggle{{if .Day.Workout}} is-met{{end}}" data-band="workout">
|
||||
<input class="band-check" type="checkbox" name="workout" value="1" {{if .Day.Workout}}checked{{end}}>
|
||||
<span class="band-label">Workout</span>
|
||||
<span class="band-state">
|
||||
<span class="state-off">Not yet</span>
|
||||
<span class="state-on">Done</span>
|
||||
</span>
|
||||
<span class="band-tick" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
{{range .Meters}}
|
||||
<div class="band band-meter{{if .Met}} is-met{{end}}{{if .Over}} is-over{{end}}"
|
||||
style="--fill:{{.Pct}}%" data-band="{{.Key}}" data-target="{{.Target}}" data-mode="{{.Mode}}">
|
||||
<span class="band-track" aria-hidden="true"><span class="band-fill"></span></span>
|
||||
<label class="band-label" for="f-{{.Key}}">{{.Label}}</label>
|
||||
<span class="band-entry">
|
||||
<input class="band-input" id="f-{{.Key}}" name="{{.Key}}" type="number"
|
||||
inputmode="numeric" min="0" step="1" value="{{num .Value}}" placeholder="—">
|
||||
<span class="band-unit">{{.Unit}}</span>
|
||||
</span>
|
||||
<span class="band-note">{{.Note}}</span>
|
||||
<span class="band-tick" aria-hidden="true"></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<fieldset class="gratitude{{if .View.Marks.Gratitude}} is-met{{end}}" data-band="gratitude">
|
||||
<legend>Gratitude</legend>
|
||||
<p class="gratitude-hint">Three lines. Any size.</p>
|
||||
<ol class="gratitude-lines">
|
||||
{{range $i, $line := .Day.Gratitude}}
|
||||
<li>
|
||||
<input class="gratitude-input" type="text" name="gratitude{{add $i 1}}"
|
||||
value="{{$line}}" maxlength="280" autocomplete="off"
|
||||
aria-label="Gratitude {{add $i 1}} of 3">
|
||||
</li>
|
||||
{{end}}
|
||||
</ol>
|
||||
</fieldset>
|
||||
|
||||
<div class="log-foot">
|
||||
<button class="save save-day" type="submit">Save the day</button>
|
||||
<p class="log-status" data-status role="status">{{.Flash}}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{template "card" .}}
|
||||
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
12
internal/web/templates/error.html
Normal file
12
internal/web/templates/error.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{template "head" .}}
|
||||
<main class="page page-narrow">
|
||||
<header class="masthead">
|
||||
<div class="masthead-day">
|
||||
<p class="eyebrow">Stop</p>
|
||||
<p class="masthead-count is-spent">·</p>
|
||||
</div>
|
||||
</header>
|
||||
<p class="alert">{{.Error}}</p>
|
||||
<p><a class="save save-quiet" href="/">Back to today</a></p>
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
107
internal/web/templates/partials.html
Normal file
107
internal/web/templates/partials.html
Normal file
@@ -0,0 +1,107 @@
|
||||
{{define "head"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{.Title}} · 75</title>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#E6EEEF" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0B1E27" media="(prefers-color-scheme: dark)">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="{{asset "/static/icon.svg"}}" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="{{asset "/static/icon.png"}}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="75">
|
||||
<link rel="stylesheet" href="{{asset "/static/app.css"}}">
|
||||
</head>
|
||||
<body>
|
||||
{{end}}
|
||||
|
||||
|
||||
{{define "foot"}}
|
||||
<footer class="footer">
|
||||
<a href="/">Today</a>
|
||||
<a href="/settings">Settings</a>
|
||||
</footer>
|
||||
<script src="{{asset "/static/app.js"}}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
|
||||
{{/* The card: seventy-five tiles, each filling from the bottom by how many of
|
||||
the five that day hit. Fifteen across so a phone gets five clean rows. */}}
|
||||
{{define "card"}}
|
||||
<section class="card">
|
||||
<h2 class="card-heading">The card</h2>
|
||||
|
||||
<ol class="card-grid">
|
||||
{{range .Card}}
|
||||
{{if .IsFuture}}
|
||||
<li class="tile tile-ahead" style="--score:0"><span class="tile-fill"></span></li>
|
||||
{{else}}
|
||||
<li class="tile{{if .IsToday}} tile-today{{end}}{{if .Marks.Complete}} tile-full{{end}}" style="--score:{{.Score}}" data-date="{{.Date}}">
|
||||
<a href="/d/{{.Date}}" title="{{tileTitle .}}"><span class="tile-fill"></span><span class="sr-only">{{tileTitle .}}</span></a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ol>
|
||||
|
||||
<dl class="tally">
|
||||
<div><dt>Perfect</dt><dd>{{.Stats.Complete}}</dd></div>
|
||||
<div><dt>Streak</dt><dd>{{.Stats.Streak}}</dd></div>
|
||||
<div><dt>Left</dt><dd>{{.Stats.Remaining}}</dd></div>
|
||||
</dl>
|
||||
|
||||
<ul class="legend">
|
||||
<li><span>Workout</span><b>{{index .Stats.Marks 0}}</b></li>
|
||||
<li><span>Protein</span><b>{{index .Stats.Marks 1}}</b></li>
|
||||
<li><span>Calories</span><b>{{index .Stats.Marks 2}}</b></li>
|
||||
<li><span>Water</span><b>{{index .Stats.Marks 3}}</b></li>
|
||||
<li><span>Gratitude</span><b>{{index .Stats.Marks 4}}</b></li>
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
|
||||
{{/* The settings fields, shared by first-run setup and the settings page. */}}
|
||||
{{define "settings-fields"}}
|
||||
<div class="field">
|
||||
<label for="start_date">Day 1</label>
|
||||
<input id="start_date" name="start_date" type="date" value="{{.Settings.StartDate}}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="length">Days</label>
|
||||
<input id="length" name="length" type="number" inputmode="numeric" min="1" max="3650" value="{{.Settings.Length}}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="protein_target">Protein target</label>
|
||||
<div class="field-row">
|
||||
<input id="protein_target" name="protein_target" type="number" inputmode="numeric" min="1" max="1000" value="{{.Settings.ProteinTarget}}" required>
|
||||
<span class="field-unit">grams or more</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="calorie_target">Calorie target</label>
|
||||
<div class="field-row">
|
||||
<input id="calorie_target" name="calorie_target" type="number" inputmode="numeric" min="1" max="20000" value="{{.Settings.CalorieTarget}}" required>
|
||||
<select name="calorie_mode" aria-label="Calorie target direction">
|
||||
<option value="under" {{if ne .Settings.CalorieMode "over"}}selected{{end}}>at most</option>
|
||||
<option value="over" {{if eq .Settings.CalorieMode "over"}}selected{{end}}>at least</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="water_target">Water target</label>
|
||||
<div class="field-row">
|
||||
<input id="water_target" name="water_target" type="number" inputmode="numeric" min="1" max="1000" value="{{.Settings.WaterTarget}}" required>
|
||||
<span class="field-unit">fluid ounces (a gallon is 128)</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
35
internal/web/templates/settings.html
Normal file
35
internal/web/templates/settings.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{template "head" .}}
|
||||
<main class="page page-narrow">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="masthead-day">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<p class="masthead-count">{{.Settings.Length}}</p>
|
||||
<p class="masthead-of">days from {{.Settings.StartDate}}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<p class="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Flash}}<p class="flash">{{.Flash}}</p>{{end}}
|
||||
|
||||
<form class="sheet" method="post" action="/settings">
|
||||
{{template "settings-fields" .}}
|
||||
<button class="save" type="submit">Save settings</button>
|
||||
<p class="sheet-note">Targets apply to the whole challenge, past days included. Move day 1 and every entry keeps its date but changes its number.</p>
|
||||
</form>
|
||||
|
||||
<section class="sheet">
|
||||
<h2 class="sheet-heading">Backup</h2>
|
||||
<p class="sheet-note">The log lives in one file on the server. Download a copy before you change anything you cannot retype.</p>
|
||||
<p><a class="save save-quiet" href="/export.json">Download the log</a></p>
|
||||
|
||||
<form method="post" action="/import" enctype="multipart/form-data" class="restore"
|
||||
data-confirm="Restoring replaces every day currently in the log. Continue?">
|
||||
<label for="backup">Restore from a download</label>
|
||||
<input id="backup" name="backup" type="file" accept="application/json,.json" required>
|
||||
<button class="save save-quiet" type="submit">Restore</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
23
internal/web/templates/setup.html
Normal file
23
internal/web/templates/setup.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{{template "head" .}}
|
||||
<main class="page page-narrow">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="masthead-day">
|
||||
<p class="eyebrow">Starting</p>
|
||||
<p class="masthead-count">75</p>
|
||||
<p class="masthead-of">days</p>
|
||||
</div>
|
||||
<p class="masthead-date">Five things a day. Set what counts.</p>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<p class="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<form class="sheet" method="post" action="/setup">
|
||||
{{template "settings-fields" .}}
|
||||
<button class="save" type="submit">Start the challenge</button>
|
||||
<p class="sheet-note">All of this is editable later. Changing a target rescores every day against the new one.</p>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
201
internal/web/web.go
Normal file
201
internal/web/web.go
Normal 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
|
||||
}
|
||||
272
internal/web/web_test.go
Normal file
272
internal/web/web_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/scottyah/tracker/internal/config"
|
||||
"github.com/scottyah/tracker/internal/store"
|
||||
)
|
||||
|
||||
// newServer builds a server whose "today" is fixed, so the day arithmetic is
|
||||
// the same in January as it is in July.
|
||||
func newServer(t *testing.T, today string) (*Server, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
tz, err := time.LoadLocation("America/Los_Angeles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "tracker.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := New(&config.Config{Addr: ":0", BaseURL: "http://test", TZ: tz}, st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.now = func() time.Time {
|
||||
d, err := time.ParseInLocation(store.DateLayout, today, tz)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.Add(9 * time.Hour) // mid-morning, well inside the local day
|
||||
}
|
||||
return srv, st
|
||||
}
|
||||
|
||||
func configured() store.Settings {
|
||||
s := store.Defaults()
|
||||
s.StartDate = "2026-06-20"
|
||||
return s
|
||||
}
|
||||
|
||||
func get(t *testing.T, srv *Server, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func post(t *testing.T, srv *Server, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHomeAsksForSetupFirst(t *testing.T) {
|
||||
srv, _ := newServer(t, "2026-07-31")
|
||||
|
||||
rec := get(t, srv, "/")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Start the challenge") {
|
||||
t.Error("an unconfigured tracker should open on the setup form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeShowsToday(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
body := get(t, srv, "/").Body.String()
|
||||
if !strings.Contains(body, "Friday 31 July") {
|
||||
t.Error("today's date is missing from the page")
|
||||
}
|
||||
// 2026-06-20 is day 1, so 2026-07-31 is day 42.
|
||||
if !strings.Contains(body, `<p class="masthead-count">42</p>`) {
|
||||
t.Error("the day number is wrong or missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDayRoundTrips(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
rec := post(t, srv, "/d/2026-07-30", url.Values{
|
||||
"workout": {"1"},
|
||||
"protein": {"186"},
|
||||
"calories": {"2140"},
|
||||
"water": {"132"},
|
||||
"gratitude1": {"Coffee"},
|
||||
"gratitude2": {" Dog "},
|
||||
"gratitude3": {"Squats"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status %d, want a redirect back to the day", rec.Code)
|
||||
}
|
||||
|
||||
day := st.Day("2026-07-30")
|
||||
if !day.Workout || *day.Protein != 186 || *day.Calories != 2140 || *day.Water != 132 {
|
||||
t.Fatalf("day did not save: %+v", day)
|
||||
}
|
||||
if day.Gratitude[1] != "Dog" {
|
||||
t.Errorf("gratitude kept its whitespace: %q", day.Gratitude[1])
|
||||
}
|
||||
if !configured().Marks(day).Complete() {
|
||||
t.Error("a day hitting all five did not score 5")
|
||||
}
|
||||
|
||||
if body := get(t, srv, "/d/2026-07-30").Body.String(); !strings.Contains(body, `value="186"`) {
|
||||
t.Error("the saved protein is not back in the form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundSaveAnswersWithTheScore(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/d/2026-07-31",
|
||||
strings.NewReader(url.Values{"workout": {"1"}, "water": {"130"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Sync", "1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); !strings.Contains(got, `"score":2`) {
|
||||
t.Errorf("body %s, want a score of 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRejectsNonNumbers(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
rec := post(t, srv, "/d/2026-07-31", url.Values{"protein": {"lots"}})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400", rec.Code)
|
||||
}
|
||||
if st.Day("2026-07-31").Logged() {
|
||||
t.Error("a rejected form still wrote to the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadDatesAreNotFound(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
for _, date := range []string{"2026-13-01", "2026-2-03", "tuesday", "2026-02-30"} {
|
||||
if rec := get(t, srv, "/d/"+date); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET /d/%s = %d, want 404", date, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationStopsAtTodayAndAtTheEnds(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
if got := srv.step(configured(), "2026-06-20", -1); got != "" {
|
||||
t.Errorf("stepped back past day 1 to %q", got)
|
||||
}
|
||||
if got := srv.step(configured(), "2026-07-31", +1); got != "" {
|
||||
t.Errorf("stepped forward into tomorrow, to %q", got)
|
||||
}
|
||||
if got := srv.step(configured(), "2026-07-30", +1); got != "2026-07-31" {
|
||||
t.Errorf("step forward = %q, want 2026-07-31", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreakIgnoresAnUnfinishedToday(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
settings := configured()
|
||||
st.SaveSettings(settings)
|
||||
|
||||
perfect := func(date string) {
|
||||
st.SaveDay(store.Day{Date: date, Workout: true, Protein: intp(200),
|
||||
Calories: intp(2000), Water: intp(130),
|
||||
Gratitude: [3]string{"a", "b", "c"}})
|
||||
}
|
||||
perfect("2026-07-28")
|
||||
perfect("2026-07-29")
|
||||
perfect("2026-07-30")
|
||||
// Nothing logged for today yet: the streak is still three, not broken.
|
||||
|
||||
if got := srv.stats(settings, srv.card(settings)).Streak; got != 3 {
|
||||
t.Errorf("streak = %d, want 3", got)
|
||||
}
|
||||
|
||||
perfect("2026-07-31")
|
||||
if got := srv.stats(settings, srv.card(settings)).Streak; got != 4 {
|
||||
t.Errorf("streak after finishing today = %d, want 4", got)
|
||||
}
|
||||
|
||||
// A gap two days back ends the run at yesterday and today.
|
||||
st.SaveDay(store.Day{Date: "2026-07-30"})
|
||||
if got := srv.stats(settings, srv.card(settings)).Streak; got != 1 {
|
||||
t.Errorf("streak across a missed day = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossSiteWritesAreRefused(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/d/2026-07-31",
|
||||
strings.NewReader("workout=1"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", rec.Code)
|
||||
}
|
||||
if st.Day("2026-07-31").Logged() {
|
||||
t.Error("a cross-site post still wrote to the log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportThenImport(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-07-31")
|
||||
st.SaveSettings(configured())
|
||||
st.SaveDay(store.Day{Date: "2026-07-30", Workout: true})
|
||||
|
||||
rec := get(t, srv, "/export.json")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), "tracker-2026-07-31.json") {
|
||||
t.Errorf("download name = %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
fresh, err := store.Open(filepath.Join(t.TempDir(), "tracker.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fresh.Import(rec.Body.Bytes()); err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if !fresh.Day("2026-07-30").Workout || fresh.Settings().StartDate != "2026-06-20" {
|
||||
t.Error("the export did not carry the log and the settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandingBeforeTheChallengeStarts(t *testing.T) {
|
||||
srv, st := newServer(t, "2026-06-01")
|
||||
st.SaveSettings(configured())
|
||||
|
||||
body := get(t, srv, "/").Body.String()
|
||||
if !strings.Contains(body, "Nothing counts until then") {
|
||||
t.Error("a challenge that has not started should say so")
|
||||
}
|
||||
if !strings.Contains(body, `<p class="masthead-count">1</p>`) {
|
||||
t.Error("it should land on day 1 rather than a date outside the challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func intp(n int) *int { return &n }
|
||||
Reference in New Issue
Block a user