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:
47
internal/config/config.go
Normal file
47
internal/config/config.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the runtime configuration, populated from the environment.
|
||||
// Everything here is deployment shape; the things that get tuned mid-challenge
|
||||
// (start date, targets) live in the data file instead, so changing a target
|
||||
// does not mean a redeploy.
|
||||
type Config struct {
|
||||
Addr string
|
||||
BaseURL string // public origin, e.g. https://fit.scottyah.com
|
||||
DataPath string // JSON file on the mounted volume
|
||||
TZ *time.Location
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults. It returns
|
||||
// an error only for settings that have no safe default.
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
Addr: env("ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"),
|
||||
DataPath: env("DATA_PATH", "data/tracker.json"),
|
||||
}
|
||||
|
||||
// The whole app is date arithmetic, so a wrong timezone silently logs
|
||||
// entries against the wrong day. Fail loudly rather than fall back to UTC.
|
||||
tzName := env("TZ_NAME", "America/Los_Angeles")
|
||||
loc, err := time.LoadLocation(tzName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TZ_NAME %q: %w", tzName, err)
|
||||
}
|
||||
c.TZ = loc
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
349
internal/store/store.go
Normal file
349
internal/store/store.go
Normal file
@@ -0,0 +1,349 @@
|
||||
// Package store keeps the whole challenge in one JSON file on a mounted
|
||||
// volume. Seventy-five rows for one person does not need a database: the file
|
||||
// is read into memory at boot, every write goes back out atomically, and a
|
||||
// mutex serialises the handful of writes a day.
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateLayout is the form every date takes, in the URL, in the file, and in the
|
||||
// map key. Local dates only; there is no time of day anywhere in the model.
|
||||
const DateLayout = "2006-01-02"
|
||||
|
||||
// Settings is what the challenge is, as opposed to how it is deployed. It
|
||||
// lives in the data file so a target can change from the settings page.
|
||||
type Settings struct {
|
||||
StartDate string `json:"start_date"` // day 1
|
||||
Length int `json:"length"`
|
||||
ProteinTarget int `json:"protein_target"` // grams, a floor
|
||||
CalorieTarget int `json:"calorie_target"` // kcal
|
||||
CalorieMode string `json:"calorie_mode"` // "under" (a ceiling) or "over" (a floor)
|
||||
WaterTarget int `json:"water_target"` // fluid ounces, a floor
|
||||
}
|
||||
|
||||
// Defaults are what the setup form is pre-filled with. A gallon is 128 fl oz.
|
||||
func Defaults() Settings {
|
||||
return Settings{
|
||||
Length: 75,
|
||||
ProteinTarget: 180,
|
||||
CalorieTarget: 2200,
|
||||
CalorieMode: "under",
|
||||
WaterTarget: 128,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Settings) Configured() bool { return s.StartDate != "" }
|
||||
|
||||
// Start returns day 1 as a date. It is only valid once Configured.
|
||||
func (s Settings) Start() time.Time {
|
||||
t, _ := time.Parse(DateLayout, s.StartDate)
|
||||
return t
|
||||
}
|
||||
|
||||
// End returns the last day of the challenge.
|
||||
func (s Settings) End() time.Time {
|
||||
return s.Start().AddDate(0, 0, s.Length-1)
|
||||
}
|
||||
|
||||
// DayNumber returns the 1-based position of a date in the challenge, or 0 if
|
||||
// the date falls outside it.
|
||||
func (s Settings) DayNumber(date string) int {
|
||||
t, err := time.Parse(DateLayout, date)
|
||||
if err != nil || !s.Configured() {
|
||||
return 0
|
||||
}
|
||||
n := int(t.Sub(s.Start()).Hours()/24) + 1
|
||||
if n < 1 || n > s.Length {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Day is one day's log. The three numbers are pointers so that "not logged
|
||||
// yet" stays distinct from "logged as zero" — a day with no calories entered
|
||||
// should not read as a perfect calorie day.
|
||||
type Day struct {
|
||||
Date string `json:"date"`
|
||||
Workout bool `json:"workout"`
|
||||
Protein *int `json:"protein,omitempty"`
|
||||
Calories *int `json:"calories,omitempty"`
|
||||
Water *int `json:"water,omitempty"`
|
||||
Gratitude [3]string `json:"gratitude"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitzero"`
|
||||
}
|
||||
|
||||
// Logged reports whether the day has been touched at all, which is what
|
||||
// separates an empty future day from a day that was genuinely missed.
|
||||
func (d Day) Logged() bool {
|
||||
if d.Workout || d.Protein != nil || d.Calories != nil || d.Water != nil {
|
||||
return true
|
||||
}
|
||||
for _, g := range d.Gratitude {
|
||||
if strings.TrimSpace(g) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d Day) clone() Day {
|
||||
out := d
|
||||
out.Protein = clonePtr(d.Protein)
|
||||
out.Calories = clonePtr(d.Calories)
|
||||
out.Water = clonePtr(d.Water)
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePtr(p *int) *int {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := *p
|
||||
return &v
|
||||
}
|
||||
|
||||
// Marks is which of the five things a day hit. The five are equal: the card on
|
||||
// the home page fills a day's tile by how many of them are true.
|
||||
type Marks struct {
|
||||
Workout bool
|
||||
Protein bool
|
||||
Calories bool
|
||||
Water bool
|
||||
Gratitude bool
|
||||
}
|
||||
|
||||
func (m Marks) Score() int {
|
||||
n := 0
|
||||
for _, ok := range [5]bool{m.Workout, m.Protein, m.Calories, m.Water, m.Gratitude} {
|
||||
if ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (m Marks) Complete() bool { return m.Score() == 5 }
|
||||
|
||||
// Marks scores a day against the targets in force.
|
||||
func (s Settings) Marks(d Day) Marks {
|
||||
m := Marks{Workout: d.Workout}
|
||||
|
||||
if d.Protein != nil && *d.Protein >= s.ProteinTarget {
|
||||
m.Protein = true
|
||||
}
|
||||
if d.Calories != nil {
|
||||
if s.CalorieMode == "over" {
|
||||
m.Calories = *d.Calories >= s.CalorieTarget
|
||||
} else {
|
||||
m.Calories = *d.Calories <= s.CalorieTarget
|
||||
}
|
||||
}
|
||||
if d.Water != nil && *d.Water >= s.WaterTarget {
|
||||
m.Water = true
|
||||
}
|
||||
|
||||
m.Gratitude = true
|
||||
for _, g := range d.Gratitude {
|
||||
if strings.TrimSpace(g) == "" {
|
||||
m.Gratitude = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// data is the on-disk shape. Version is here so a later change to the model
|
||||
// has something to branch on.
|
||||
type data struct {
|
||||
Version int `json:"version"`
|
||||
Settings Settings `json:"settings"`
|
||||
Days map[string]*Day `json:"days"`
|
||||
}
|
||||
|
||||
const currentVersion = 1
|
||||
|
||||
// Store owns the file and the in-memory copy of it.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
d data
|
||||
}
|
||||
|
||||
// Open reads the data file, or starts an empty one if it does not exist yet.
|
||||
// A file that exists but does not parse is an error: overwriting it with a
|
||||
// fresh empty challenge would throw away the log, which is the one thing here
|
||||
// that cannot be recreated.
|
||||
func Open(path string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: path,
|
||||
d: data{Version: currentVersion, Settings: Defaults(), Days: map[string]*Day{}},
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &s.d); err != nil {
|
||||
return nil, fmt.Errorf("parse %s (left untouched; move it aside to start over): %w", path, err)
|
||||
}
|
||||
if s.d.Days == nil {
|
||||
s.d.Days = map[string]*Day{}
|
||||
}
|
||||
|
||||
// Keep the copy that was known to parse. If a later write ever produces
|
||||
// something unreadable, this is the file to rename back.
|
||||
if err := os.WriteFile(path+".bak", body, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write backup: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Settings() Settings {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.d.Settings
|
||||
}
|
||||
|
||||
func (s *Store) SaveSettings(next Settings) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.d.Settings = next
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// Day returns the log for a date, or an empty one if nothing is recorded.
|
||||
func (s *Store) Day(date string) Day {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if d, ok := s.d.Days[date]; ok {
|
||||
return d.clone()
|
||||
}
|
||||
return Day{Date: date}
|
||||
}
|
||||
|
||||
// SaveDay writes one day. A day emptied of everything is deleted rather than
|
||||
// stored blank, so an accidental visit to a future date leaves no trace.
|
||||
func (s *Store) SaveDay(d Day) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !d.Logged() {
|
||||
delete(s.d.Days, d.Date)
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
d.UpdatedAt = time.Now().UTC()
|
||||
stored := d.clone()
|
||||
s.d.Days[d.Date] = &stored
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// Days returns count consecutive days starting at from, including the blanks,
|
||||
// so the caller can lay out the card without checking for gaps.
|
||||
func (s *Store) Days(from time.Time, count int) []Day {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]Day, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
date := from.AddDate(0, 0, i).Format(DateLayout)
|
||||
if d, ok := s.d.Days[date]; ok {
|
||||
out = append(out, d.clone())
|
||||
continue
|
||||
}
|
||||
out = append(out, Day{Date: date})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Export returns the whole file as it would be written, for the download.
|
||||
func (s *Store) Export() ([]byte, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return json.MarshalIndent(s.d, "", " ")
|
||||
}
|
||||
|
||||
// Import replaces everything with a previously exported file.
|
||||
func (s *Store) Import(body []byte) error {
|
||||
var next data
|
||||
if err := json.Unmarshal(body, &next); err != nil {
|
||||
return fmt.Errorf("that file is not a tracker export: %w", err)
|
||||
}
|
||||
if next.Days == nil {
|
||||
next.Days = map[string]*Day{}
|
||||
}
|
||||
for date, d := range next.Days {
|
||||
if _, err := time.Parse(DateLayout, date); err != nil {
|
||||
return fmt.Errorf("%q is not a date", date)
|
||||
}
|
||||
d.Date = date
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next.Version = currentVersion
|
||||
s.d = next
|
||||
return s.persist()
|
||||
}
|
||||
|
||||
// persist writes the file atomically: a temp file in the same directory, then
|
||||
// a rename. A crash mid-write therefore leaves the previous file intact rather
|
||||
// than a half-written one. Callers hold the write lock.
|
||||
func (s *Store) persist() error {
|
||||
body, err := json.MarshalIndent(s.d, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(s.path)
|
||||
tmp, err := os.CreateTemp(dir, ".tracker-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
name := tmp.Name()
|
||||
defer os.Remove(name) // no-op once the rename has succeeded
|
||||
|
||||
if _, err := tmp.Write(body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
// Flush to the disk before the rename, so a power cut cannot leave the
|
||||
// file renamed into place but empty.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(name, 0o644); err != nil {
|
||||
return fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(name, s.path); err != nil {
|
||||
return fmt.Errorf("replace data file: %w", err)
|
||||
}
|
||||
|
||||
// Fsync the directory so the rename itself is durable.
|
||||
if handle, err := os.Open(dir); err == nil {
|
||||
handle.Sync()
|
||||
handle.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
199
internal/store/store_test.go
Normal file
199
internal/store/store_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func settings() Settings {
|
||||
s := Defaults()
|
||||
s.StartDate = "2026-06-20"
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDayNumber(t *testing.T) {
|
||||
s := settings()
|
||||
|
||||
cases := []struct {
|
||||
date string
|
||||
want int
|
||||
}{
|
||||
{"2026-06-19", 0}, // the day before day 1
|
||||
{"2026-06-20", 1}, // day 1
|
||||
{"2026-06-21", 2}, // and the day after
|
||||
{"2026-07-31", 42}, // across a month boundary
|
||||
{"2026-09-02", 75}, // the last day
|
||||
{"2026-09-03", 0}, // one past the end
|
||||
{"nonsense", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := s.DayNumber(c.date); got != c.want {
|
||||
t.Errorf("DayNumber(%q) = %d, want %d", c.date, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The clocks go back on 2026-11-01 in America/Los_Angeles. Dates are parsed in
|
||||
// UTC precisely so that the day either side of it still counts as one day.
|
||||
func TestDayNumberAcrossDST(t *testing.T) {
|
||||
s := Defaults()
|
||||
s.StartDate = "2026-10-30"
|
||||
s.Length = 10
|
||||
|
||||
for i, date := range []string{"2026-10-30", "2026-10-31", "2026-11-01", "2026-11-02"} {
|
||||
if got, want := s.DayNumber(date), i+1; got != want {
|
||||
t.Errorf("DayNumber(%q) = %d, want %d", date, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarks(t *testing.T) {
|
||||
s := settings() // protein >= 180, calories <= 2200, water >= 128
|
||||
full := [3]string{"one", "two", "three"}
|
||||
|
||||
t.Run("everything hit", func(t *testing.T) {
|
||||
m := s.Marks(Day{Workout: true, Protein: ptr(180), Calories: ptr(2200),
|
||||
Water: ptr(128), Gratitude: full})
|
||||
if !m.Complete() {
|
||||
t.Fatalf("targets met exactly should score 5, got %d", m.Score())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing logged is not a perfect calorie day", func(t *testing.T) {
|
||||
m := s.Marks(Day{})
|
||||
if m.Calories {
|
||||
t.Error("an unlogged calorie count must not count as under the cap")
|
||||
}
|
||||
if m.Score() != 0 {
|
||||
t.Errorf("empty day scored %d, want 0", m.Score())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero is logged", func(t *testing.T) {
|
||||
m := s.Marks(Day{Calories: ptr(0)})
|
||||
if !m.Calories {
|
||||
t.Error("zero calories is under the cap and should count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calories as a floor", func(t *testing.T) {
|
||||
over := s
|
||||
over.CalorieMode = "over"
|
||||
if over.Marks(Day{Calories: ptr(2000)}).Calories {
|
||||
t.Error("2000 is below a 2200 floor and should not count")
|
||||
}
|
||||
if !over.Marks(Day{Calories: ptr(2400)}).Calories {
|
||||
t.Error("2400 clears a 2200 floor and should count")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gratitude needs all three", func(t *testing.T) {
|
||||
if s.Marks(Day{Gratitude: [3]string{"one", " ", "three"}}).Gratitude {
|
||||
t.Error("a blank line should not count as written")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveAndReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "tracker.json")
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := st.SaveSettings(settings()); err != nil {
|
||||
t.Fatalf("save settings: %v", err)
|
||||
}
|
||||
day := Day{Date: "2026-07-01", Workout: true, Protein: ptr(190),
|
||||
Gratitude: [3]string{"a", "b", "c"}}
|
||||
if err := st.SaveDay(day); err != nil {
|
||||
t.Fatalf("save day: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got := reopened.Day("2026-07-01")
|
||||
if !got.Workout || got.Protein == nil || *got.Protein != 190 {
|
||||
t.Fatalf("day did not survive the round trip: %+v", got)
|
||||
}
|
||||
if got.Calories != nil {
|
||||
t.Error("a number that was never entered came back set")
|
||||
}
|
||||
if reopened.Settings().StartDate != "2026-06-20" {
|
||||
t.Error("settings did not survive the round trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEmptyDayRemovesIt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
|
||||
if err := st.SaveDay(Day{Date: "2026-07-01", Workout: true}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if err := st.SaveDay(Day{Date: "2026-07-01"}); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if st.Day("2026-07-01").Logged() {
|
||||
t.Error("a day cleared of everything should not stay in the file")
|
||||
}
|
||||
}
|
||||
|
||||
// A day handed out must not share memory with the stored one, or editing the
|
||||
// copy would quietly rewrite history.
|
||||
func TestDayIsACopy(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
st.SaveDay(Day{Date: "2026-07-01", Protein: ptr(190)})
|
||||
|
||||
got := st.Day("2026-07-01")
|
||||
*got.Protein = 5
|
||||
|
||||
if again := st.Day("2026-07-01"); *again.Protein != 190 {
|
||||
t.Errorf("stored day changed to %d", *again.Protein)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRefusesUnreadableFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatal("Open accepted a corrupt file; it would have been overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaysFillsGaps(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
st.SaveSettings(settings())
|
||||
st.SaveDay(Day{Date: "2026-06-22", Workout: true})
|
||||
|
||||
days := st.Days(settings().Start(), 5)
|
||||
if len(days) != 5 {
|
||||
t.Fatalf("got %d days, want 5", len(days))
|
||||
}
|
||||
if days[0].Date != "2026-06-20" || days[4].Date != "2026-06-24" {
|
||||
t.Errorf("wrong span: %s..%s", days[0].Date, days[4].Date)
|
||||
}
|
||||
if days[1].Logged() {
|
||||
t.Error("a day never written should come back empty")
|
||||
}
|
||||
if !days[2].Workout {
|
||||
t.Error("the written day is missing from the span")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsJunkDates(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tracker.json")
|
||||
st, _ := Open(path)
|
||||
if err := st.Import([]byte(`{"days":{"last tuesday":{"workout":true}}}`)); err == nil {
|
||||
t.Fatal("Import accepted a key that is not a date")
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(n int) *int { return &n }
|
||||
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