From faf73c8be5d6b9f7be484d1cc3f6cf49234368e9 Mon Sep 17 00:00:00 2001 From: scott Date: Sat, 1 Aug 2026 21:59:01 -0700 Subject: [PATCH] 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 --- .dockerignore | 6 + .env.example | 7 + .gitea/workflows/deploy.yaml | 57 +++ .gitignore | 4 + Dockerfile | 28 ++ README.md | 124 ++++++ cmd/server/main.go | 77 ++++ go.mod | 3 + internal/config/config.go | 47 ++ internal/store/store.go | 349 +++++++++++++++ internal/store/store_test.go | 199 +++++++++ internal/web/funcs.go | 65 +++ internal/web/handlers.go | 511 +++++++++++++++++++++ internal/web/static/app.css | 636 +++++++++++++++++++++++++++ internal/web/static/app.js | 161 +++++++ internal/web/static/icon.png | Bin 0 -> 605 bytes internal/web/static/icon.svg | 24 + internal/web/templates/day.html | 93 ++++ internal/web/templates/error.html | 12 + internal/web/templates/partials.html | 107 +++++ internal/web/templates/settings.html | 35 ++ internal/web/templates/setup.html | 23 + internal/web/web.go | 201 +++++++++ internal/web/web_test.go | 272 ++++++++++++ k8s.yaml | 156 +++++++ 25 files changed, 3197 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/web/funcs.go create mode 100644 internal/web/handlers.go create mode 100644 internal/web/static/app.css create mode 100644 internal/web/static/app.js create mode 100644 internal/web/static/icon.png create mode 100644 internal/web/static/icon.svg create mode 100644 internal/web/templates/day.html create mode 100644 internal/web/templates/error.html create mode 100644 internal/web/templates/partials.html create mode 100644 internal/web/templates/settings.html create mode 100644 internal/web/templates/setup.html create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go create mode 100644 k8s.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f69b4a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitea +*.md +.env* +k8s.yaml +data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..14da4f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Where the log is written. In the cluster this is the mounted volume. +DATA_PATH=data/tracker.json + +# Public origin, and the timezone the day rolls over in. +BASE_URL=https://fit.scottyah.com +TZ_NAME=America/Los_Angeles +ADDR=:8080 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..960db31 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,57 @@ +name: Build and Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + container: + image: quay.io/buildah/stable + steps: + - name: Checkout + env: + # Values reach the shell as environment variables rather than being + # interpolated into the command, so a crafted branch name cannot run + # as part of it. + REF_NAME: ${{ github.ref_name }} + REPO: ${{ github.repository }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # The clone URL is spelled out: github.server_url resolves to Gitea's + # in-cluster address, which the runner can reach but has no + # credentials for. The token authenticates a private repo. + git clone --depth 1 --branch "$REF_NAME" \ + "https://x-access-token:${TOKEN}@git.scottyah.com/${REPO}.git" . + git checkout "$GITHUB_SHA" + + - name: Build image + run: | + IMAGE=harbor.scottyah.com/scottyah/tracker + buildah --isolation chroot bud -t $IMAGE:${{ github.sha }} -t $IMAGE:latest . + + - name: Push image + run: | + IMAGE=harbor.scottyah.com/scottyah/tracker + # Harbor serves a publicly trusted certificate, so TLS is verified. + # Disabling verification would send these credentials, and accept the + # image, over a connection nothing has authenticated. + buildah login -u "$HARBOR_USERNAME" -p "$HARBOR_PASSWORD" harbor.scottyah.com + buildah push $IMAGE:${{ github.sha }} + buildah push $IMAGE:latest + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + + - name: Deploy + run: | + curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config + sed -i "s|harbor.scottyah.com/scottyah/tracker:latest|harbor.scottyah.com/scottyah/tracker:${{ github.sha }}|" k8s.yaml + ./kubectl apply -f k8s.yaml + ./kubectl rollout status deployment/tracker-dep -n tracker --timeout=120s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48551e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/tracker +*.env +.env +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..073f660 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM golang:1.26-alpine AS build + +WORKDIR /src + +# Dependencies first, so edits to the app don't re-download the module cache. +# There are none, but the layer keeps the shape of the other apps. +COPY go.mod ./ +RUN go mod download + +COPY . . + +# Static binary: no libc, so it runs in a scratch image. The timezone database +# is embedded by the `time/tzdata` import in main.go. +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/tracker ./cmd/server + + +FROM scratch + +COPY --from=build /out/tracker /tracker + +# nobody:nogroup — the only writable path is the mounted volume at /data. +USER 65534:65534 + +EXPOSE 8080 +ENTRYPOINT ["/tracker"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eebf563 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# tracker + +A 75-day challenge, five things a day: the workout, the protein, the calories, +the water, and three lines of gratitude. + +Single Go binary, no dependencies, one JSON file on a volume. Auth is Traefik's +basic auth at the ingress; the app itself has no login. + +## What it does + +- **One page per day.** Open it, tick the workout, type three numbers, write + three lines. Ticking a box saves in the background — no page reload. +- **Targets, not just numbers.** Protein and water fill towards a floor to + reach; calories fill towards a ceiling not to cross. A day is complete when + all five are hit. +- **The card.** Seventy-five tiles, fifteen across, each filling from the + bottom by how many of that day's five landed. A missed day stays a gap — it + does not reset the count. +- **Yesterday is editable.** Back-arrow to any past day and fill it in. You + cannot log the future. +- **Export and restore** from the settings page, so the log is never only in + one place. +- **Installable.** Add to the home screen and it opens without browser chrome. + +## Design + +Municipal pool tile: a pale aqua ground with 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. It follows the system light/dark setting. + +Each measurable is a band that is its own meter — the fill *is* the band, not a +bar next to it — and the track ends where the target is. Go past a ceiling and +the whole band turns hot. + +Typography is Avenir Next Condensed for display and the system UI and monospace +faces for everything else, so there are no web fonts to serve. + +## Running it locally + +Needs Go 1.26. No database. + +```sh +export DATA_PATH="$PWD/data/tracker.json" +export TZ_NAME="America/Los_Angeles" + +go run ./cmd/server +``` + +Then open . The first load asks for the start date and +the targets; everything after that is the day page. + +```sh +go test ./... +``` + +## Configuration + +| Variable | Required | Notes | +| --- | --- | --- | +| `DATA_PATH` | no | The log file. Default `data/tracker.json`, relative to the working directory. The directory is created if missing. | +| `TZ_NAME` | no | Default `America/Los_Angeles`. Decides when the day rolls over; a wrong value files entries against the wrong date. | +| `BASE_URL` | no | Public origin. Default `http://localhost:8080`. | +| `ADDR` | no | Listen address, default `:8080`. | + +The start date, the length, and the three targets are **not** environment +variables. They live in the data file and are edited from `/settings`, so +changing a target does not need a deploy. + +## Deploying + +Follows the same shape as the other apps in `~/dev`: push to `main`, Gitea +Actions builds with buildah, pushes to Harbor, and applies `k8s.yaml`. + +Before the first deploy, create the basic-auth secret. This is the only thing +guarding the log, so pick a real password: + +```sh +htpasswd -nbB scott 'a-real-password' > /tmp/users +kubectl create namespace tracker +kubectl create secret generic tracker-basic-auth -n tracker --from-file=users=/tmp/users +rm /tmp/users +``` + +Point `fit.scottyah.com` at the cluster and Traefik terminates TLS with the +existing `scottyah-tls` secret. + +To pull a copy of the log without the browser: + +```sh +kubectl exec -n tracker deploy/tracker-dep -- cat /data/tracker.json > tracker.json +``` + +## Notes on how it's built + +- **The file is the database.** Seventy-five rows for one person do not justify + Postgres. The file is read into memory at boot and written back on every + change: temp file in the same directory, fsync, rename, fsync the directory. + A crash mid-write leaves the previous file intact rather than half of a new + one. +- **A file that does not parse is a fatal error, not a fresh start.** Silently + replacing an unreadable log with an empty challenge would destroy the one + thing here that cannot be recreated. Boot also leaves a `.bak` copy of + whatever last parsed. +- **Blank is not zero.** The three numbers are pointers, so a day with no + calories entered does not read as a perfect calorie day. +- **Dates are local dates, everywhere.** No time of day is stored. Arithmetic + parses them in UTC so a daylight-saving change cannot make a day 23 or 25 + hours long and shift the numbering. +- **Nothing needs JavaScript.** The form posts and the page reloads. With + JavaScript the meters move as you type and the save happens in the + background, flushed when the tab goes away. +- **Static assets are content-hashed.** `/static/app.css?v=…` changes whenever + the file does, so a deploy is never stuck behind a cached stylesheet. +- **Writes must come from this site.** Browsers attach basic-auth credentials + to cross-origin form posts, so any other page could otherwise post to + `/import` and replace the log. Every `POST` checks `Sec-Fetch-Site`. + +## Things deliberately left out + +- Accounts, sessions and passwords in the app. One person, one basic-auth + prompt at the edge. +- A reset rule. Missing a day leaves a gap; it does not send you back to day 1. +- Weights, reps, photos, and measurements. This tracks whether the five things + happened, not what the workout was. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c254da7 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + // Embeds the timezone database in the binary so the local day rolls over + // correctly inside a scratch image, which has no /usr/share/zoneinfo. + _ "time/tzdata" + + "github.com/scottyah/tracker/internal/config" + "github.com/scottyah/tracker/internal/store" + "github.com/scottyah/tracker/internal/web" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) + + if err := run(); err != nil { + slog.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer stop() + + st, err := store.Open(cfg.DataPath) + if err != nil { + return err + } + slog.Info("data ready", "path", cfg.DataPath, "tz", cfg.TZ.String()) + + srv, err := web.New(cfg, st) + if err != nil { + return err + } + + httpServer := &http.Server{ + Addr: cfg.Addr, + Handler: srv, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 90 * time.Second, + } + + go func() { + <-ctx.Done() + slog.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown", "err", err) + } + }() + + slog.Info("listening", "addr", cfg.Addr, "base_url", cfg.BaseURL) + if err := httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..178759c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/scottyah/tracker + +go 1.26.1 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..571f7df --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..adc74e4 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..624b08f --- /dev/null +++ b/internal/store/store_test.go @@ -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 } diff --git a/internal/web/funcs.go b/internal/web/funcs.go new file mode 100644 index 0000000..e87d149 --- /dev/null +++ b/internal/web/funcs.go @@ -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" + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go new file mode 100644 index 0000000..a03cb7f --- /dev/null +++ b/internal/web/handlers.go @@ -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) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..b6e8326 --- /dev/null +++ b/internal/web/static/app.css @@ -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; + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..d1dc4a1 --- /dev/null +++ b/internal/web/static/app.js @@ -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(); + } + }); + } +})(); diff --git a/internal/web/static/icon.png b/internal/web/static/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cc0c60c6771af641e77530d1f358b183c4463dfe GIT binary patch literal 605 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofl0;F#WAE}&YQc2Ud(|a4uMmz zlpQ?go5G%V)Rtox3-|YDYL_}@ZxDON_e6No{D*VOI4X}m6?Hyw+g2{_UBb!3TiFxh z9w~G-tME;-@W8{^y|G``Zr?fElbrkP@4uROP;4#FwnNW|(1^!Oyi#{}r}}TSM0e7q zYv(F#?ceHKpSC|f|9+Lp+XU?#G?(-4dp_gZzpsD0?$^oe; + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/web/templates/day.html b/internal/web/templates/day.html new file mode 100644 index 0000000..e748476 --- /dev/null +++ b/internal/web/templates/day.html @@ -0,0 +1,93 @@ +{{template "head" .}} + +
+ +
+ + +

+ {{if .View.IsToday}}Today · {{end}}{{dateLong .View.Time}} + {{if not .View.IsToday}}Back to today{{end}} +

+ + {{if eq .Status "before"}} +

Day 1 is {{dateLong .Settings.Start}}. Nothing counts until then.

+ {{else if eq .Status "after"}} +

The {{.Settings.Length}} days are done. {{.Stats.Complete}} of them were perfect.

+ {{end}} +
+ +
+ + + + {{range .Meters}} +
+ + + + + {{.Unit}} + + {{.Note}} + +
+ {{end}} + +
+ Gratitude +

Three lines. Any size.

+
    + {{range $i, $line := .Day.Gratitude}} +
  1. + +
  2. + {{end}} +
+
+ +
+ +

{{.Flash}}

+
+
+ + {{template "card" .}} + +
+{{template "foot" .}} diff --git a/internal/web/templates/error.html b/internal/web/templates/error.html new file mode 100644 index 0000000..e3c8917 --- /dev/null +++ b/internal/web/templates/error.html @@ -0,0 +1,12 @@ +{{template "head" .}} +
+
+
+

Stop

+

·

+
+
+

{{.Error}}

+

Back to today

+
+{{template "foot" .}} diff --git a/internal/web/templates/partials.html b/internal/web/templates/partials.html new file mode 100644 index 0000000..83bdec4 --- /dev/null +++ b/internal/web/templates/partials.html @@ -0,0 +1,107 @@ +{{define "head"}} + + + + +{{.Title}} · 75 + + + + + + + + + + + + + +{{end}} + + +{{define "foot"}} + + + + +{{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"}} +
+

The card

+ +
    + {{range .Card}} + {{if .IsFuture}} +
  1. + {{else}} +
  2. + {{tileTitle .}} +
  3. + {{end}} + {{end}} +
+ +
+
Perfect
{{.Stats.Complete}}
+
Streak
{{.Stats.Streak}}
+
Left
{{.Stats.Remaining}}
+
+ +
    +
  • Workout{{index .Stats.Marks 0}}
  • +
  • Protein{{index .Stats.Marks 1}}
  • +
  • Calories{{index .Stats.Marks 2}}
  • +
  • Water{{index .Stats.Marks 3}}
  • +
  • Gratitude{{index .Stats.Marks 4}}
  • +
+
+{{end}} + + +{{/* The settings fields, shared by first-run setup and the settings page. */}} +{{define "settings-fields"}} +
+ + +
+ +
+ + +
+ +
+ +
+ + grams or more +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + fluid ounces (a gallon is 128) +
+
+{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html new file mode 100644 index 0000000..2822d6a --- /dev/null +++ b/internal/web/templates/settings.html @@ -0,0 +1,35 @@ +{{template "head" .}} +
+ +
+
+

Settings

+

{{.Settings.Length}}

+

days from {{.Settings.StartDate}}

+
+
+ + {{if .Error}}

{{.Error}}

{{end}} + {{if .Flash}}

{{.Flash}}

{{end}} + +
+ {{template "settings-fields" .}} + +

Targets apply to the whole challenge, past days included. Move day 1 and every entry keeps its date but changes its number.

+
+ +
+

Backup

+

The log lives in one file on the server. Download a copy before you change anything you cannot retype.

+

Download the log

+ +
+ + + +
+
+ +
+{{template "foot" .}} diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html new file mode 100644 index 0000000..e1e38e5 --- /dev/null +++ b/internal/web/templates/setup.html @@ -0,0 +1,23 @@ +{{template "head" .}} +
+ +
+
+

Starting

+

75

+

days

+
+

Five things a day. Set what counts.

+
+ + {{if .Error}}

{{.Error}}

{{end}} + +
+ {{template "settings-fields" .}} + +

All of this is editable later. Changing a target rescores every day against the new one.

+
+ +
+ + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..276d7a4 --- /dev/null +++ b/internal/web/web.go @@ -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 +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..36de917 --- /dev/null +++ b/internal/web/web_test.go @@ -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, `

42

`) { + 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, `

1

`) { + t.Error("it should land on day 1 rather than a date outside the challenge") + } +} + +func intp(n int) *int { return &n } diff --git a/k8s.yaml b/k8s.yaml new file mode 100644 index 0000000..a3712eb --- /dev/null +++ b/k8s.yaml @@ -0,0 +1,156 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: tracker + +--- +# The whole log is one JSON file, so the "database" is this volume. It is +# ReadWriteOnce, which is why the deployment below replaces rather than rolls. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: tracker-data-pvc + namespace: tracker +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: tracker-svc + namespace: tracker +spec: + selector: + app: tracker + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tracker-dep + namespace: tracker +spec: + replicas: 1 + # Two pods would both hold the file in memory and overwrite each other's + # writes, and the volume only attaches to one node anyway. Recreate makes the + # old pod let go before the new one starts. + strategy: + type: Recreate + selector: + matchLabels: + app: tracker + template: + metadata: + labels: + app: tracker + spec: + imagePullSecrets: + - name: harborcred + securityContext: + runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 + # Without fsGroup the volume comes up owned by root and nobody cannot + # write the file it exists to hold. + fsGroup: 65534 + containers: + - name: tracker + image: harbor.scottyah.com/scottyah/tracker:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + env: + - name: ADDR + value: ":8080" + - name: BASE_URL + value: "https://fit.scottyah.com" + - name: DATA_PATH + value: "/data/tracker.json" + # Every date in the app is a local date, so this decides when the + # day rolls over. + - name: TZ_NAME + value: "America/Los_Angeles" + - name: GOMEMLIMIT + value: "40MiB" + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "64Mi" + cpu: "200m" + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 2 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + # /data is the only thing the process writes. + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: data + persistentVolumeClaim: + claimName: tracker-data-pvc + +--- +# The app has no login of its own; this is the only thing between the log and +# the internet, so the Service must not be exposed any other way. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: tracker-basic-auth + namespace: tracker +spec: + basicAuth: + secret: tracker-basic-auth + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: tracker-ingress + namespace: tracker + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" + traefik.ingress.kubernetes.io/router.middlewares: tracker-tracker-basic-auth@kubernetescrd +spec: + ingressClassName: traefik + tls: + - hosts: + - fit.scottyah.com + secretName: scottyah-tls + rules: + - host: fit.scottyah.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: tracker-svc + port: + number: 80