diff --git a/README.md b/README.md
index 20ec3b6..ae3c833 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# puppy-tracker
-A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and
-weight.
+A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
+weight, and training.
The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices.
@@ -21,6 +21,11 @@ source-of-truth and sync between devices.
`config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use.
+- Training exercises (name + how-to instructions) are their own synced
+ collection with the same contract as events (UUIDs, last-write-wins,
+ tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
+ events (`type: "training"`) referencing an exercise by id, so they ride the
+ event sync unchanged.
- The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in
diff --git a/server/auth.go b/server/auth.go
index 5d2491f..0c6b16c 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -173,6 +173,9 @@ func (a *Auth) adopt(userID string) error {
if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
+ if _, err := a.db.Exec(`UPDATE exercises SET user_id = ? WHERE user_id = ''`, userID); err != nil {
+ return err
+ }
return a.adoptPhotos(userID)
}
@@ -402,6 +405,7 @@ func (a *Auth) deleteAccount(userID string) error {
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
+ `DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
diff --git a/server/main.go b/server/main.go
index 5f0fe84..485b94e 100644
--- a/server/main.go
+++ b/server/main.go
@@ -24,14 +24,27 @@ import (
)
type Event struct {
- ID string `json:"id"`
- Type string `json:"type"`
- At int64 `json:"at"`
- Note string `json:"note"`
- PhotoID string `json:"photoId,omitempty"`
- Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
- UpdatedAt int64 `json:"updatedAt"`
- Deleted bool `json:"deleted,omitempty"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ At int64 `json:"at"`
+ Note string `json:"note"`
+ PhotoID string `json:"photoId,omitempty"`
+ Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
+ ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
+ UpdatedAt int64 `json:"updatedAt"`
+ Deleted bool `json:"deleted,omitempty"`
+}
+
+// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
+// a name plus optional instruction text. Training sessions reference one by
+// ExerciseID on the event. Exercises sync exactly like events: UUID ids,
+// last-write-wins on UpdatedAt, tombstoned deletes.
+type Exercise struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Note string `json:"note"` // instructions / reminder how to do it
+ UpdatedAt int64 `json:"updatedAt"`
+ Deleted bool `json:"deleted,omitempty"`
}
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
@@ -114,11 +127,12 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them.
stmt, err := tx.Prepare(`
- INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
+ exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
@@ -132,7 +146,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
continue
}
if _, err := stmt.Exec(
- ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
+ ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
); err != nil {
return nil, err
}
@@ -146,7 +160,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
- `SELECT id, type, at, note, photo_id, weight, updated, deleted
+ `SELECT id, type, at, note, photo_id, weight, exercise_id, updated, deleted
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -156,7 +170,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() {
var e Event
if err := rows.Scan(
- &e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.UpdatedAt, &e.Deleted,
+ &e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
); err != nil {
return nil, err
}
@@ -165,6 +179,72 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err()
}
+// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
+// UpdatedAt, same user_id guard against cross-user id collisions, same
+// tombstone propagation.
+type ExerciseStore struct {
+ db *sql.DB
+}
+
+func newExerciseStore(db *sql.DB) *ExerciseStore {
+ return &ExerciseStore{db: db}
+}
+
+func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+
+ stmt, err := tx.Prepare(`
+ INSERT INTO exercises (id, name, note, updated, deleted, user_id)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name, note = excluded.note,
+ updated = excluded.updated, deleted = excluded.deleted
+ WHERE excluded.updated > exercises.updated
+ AND exercises.user_id = excluded.user_id`)
+ if err != nil {
+ return nil, err
+ }
+ defer stmt.Close()
+
+ for _, ce := range client {
+ if ce.ID == "" {
+ continue
+ }
+ if _, err := stmt.Exec(
+ ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID,
+ ); err != nil {
+ return nil, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return s.all(userID)
+}
+
+func (s *ExerciseStore) all(userID string) ([]Exercise, error) {
+ rows, err := s.db.Query(
+ `SELECT id, name, note, updated, deleted
+ FROM exercises WHERE user_id = ?`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := make([]Exercise, 0)
+ for rows.Next() {
+ var e Exercise
+ if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil {
+ return nil, err
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
// openDB opens (creating if needed) the SQLite database and ensures the schema
// exists. WAL mode plays nicely with concurrent readers during a sync write;
// busy_timeout avoids spurious "database is locked" errors under contention.
@@ -191,17 +271,27 @@ func openDB(path string) (*sql.DB, error) {
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
- id TEXT PRIMARY KEY,
- type TEXT NOT NULL DEFAULT '',
- at INTEGER NOT NULL DEFAULT 0,
- note TEXT NOT NULL DEFAULT '',
- photo_id TEXT NOT NULL DEFAULT '',
- weight REAL NOT NULL DEFAULT 0,
- updated INTEGER NOT NULL DEFAULT 0,
- deleted INTEGER NOT NULL DEFAULT 0,
- user_id TEXT NOT NULL DEFAULT ''
+ id TEXT PRIMARY KEY,
+ type TEXT NOT NULL DEFAULT '',
+ at INTEGER NOT NULL DEFAULT 0,
+ note TEXT NOT NULL DEFAULT '',
+ photo_id TEXT NOT NULL DEFAULT '',
+ weight REAL NOT NULL DEFAULT 0,
+ exercise_id TEXT NOT NULL DEFAULT '',
+ updated INTEGER NOT NULL DEFAULT 0,
+ deleted INTEGER NOT NULL DEFAULT 0,
+ user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
+ CREATE TABLE IF NOT EXISTS exercises (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL DEFAULT '',
+ note TEXT NOT NULL DEFAULT '',
+ updated INTEGER NOT NULL DEFAULT 0,
+ deleted INTEGER NOT NULL DEFAULT 0,
+ user_id TEXT NOT NULL DEFAULT ''
+ );
+ CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
@@ -249,6 +339,15 @@ func migrateSchema(db *sql.DB) error {
return err
}
}
+ hasExercise, err := columnExists(db, "events", "exercise_id")
+ if err != nil {
+ return err
+ }
+ if !hasExercise {
+ if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); err != nil {
+ return err
+ }
+ }
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
@@ -383,6 +482,14 @@ type syncResponse struct {
ServerNow int64 `json:"serverNow"`
}
+type exerciseSyncRequest struct {
+ Exercises []Exercise `json:"exercises"`
+}
+
+type exerciseSyncResponse struct {
+ Exercises []Exercise `json:"exercises"`
+}
+
type cacheControlFS struct {
root http.FileSystem
}
@@ -481,6 +588,7 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
+ exerciseStore := newExerciseStore(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil {
@@ -532,6 +640,29 @@ func main() {
})
}))
+ // POST /api/exercises/sync — merge the caller's training exercises, same
+ // LWW contract as /api/events/sync.
+ mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req exerciseSyncRequest
+ if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
+ http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ merged, err := exerciseStore.sync(userID(r), req.Exercises)
+ if err != nil {
+ log.Printf("exercises sync: %v", err)
+ http.Error(w, "server error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
+ }))
+
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
diff --git a/src/app.js b/src/app.js
index c994ab2..919dea8 100644
--- a/src/app.js
+++ b/src/app.js
@@ -6,8 +6,9 @@
// cached events/profile. currentUser is set by the auth gate before the app
// boots, so these are only ever called once a user is known.
let currentUser = null;
- const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
- const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
+ const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
+ const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
+ const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000;
@@ -19,6 +20,7 @@
"pee": "Pee",
"poo": "Poo",
"weight": "Weigh-in",
+ "training": "Training",
};
// ---------- photos: IndexedDB store ----------
@@ -255,7 +257,7 @@
return `${wk} · ${mo} old`;
}
- function addEvent(type, note, at, photoId, weight) {
+ function addEvent(type, note, at, photoId, weight, exerciseId) {
const events = loadAll();
const now = Date.now();
const ev = {
@@ -265,6 +267,7 @@
note: note || "",
photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined,
+ exerciseId: exerciseId || "",
updatedAt: now,
};
events.push(ev);
@@ -293,6 +296,62 @@
render();
}
+ // ---------- exercises (training definitions) ----------
+ // User-defined training exercises ("Sit", "Leash walking", …), each with
+ // optional instruction text. They sync like events: UUID ids, last-write-wins
+ // on updatedAt, tombstoned deletes — but as their own collection, since they
+ // are definitions rather than things that happened at a point in time.
+ function loadExercises() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(exercisesKey()));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ function saveExercises(list) {
+ localStorage.setItem(exercisesKey(), JSON.stringify(list));
+ }
+
+ function liveExercises() {
+ return loadExercises()
+ .filter(x => !x.deleted)
+ .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
+ }
+
+ function addExercise(name, note) {
+ const list = loadExercises();
+ list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() });
+ saveExercises(list);
+ scheduleSync();
+ render();
+ }
+
+ function updateExercise(id, patch) {
+ saveExercises(loadExercises().map(x =>
+ x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x
+ ));
+ scheduleSync();
+ render();
+ }
+
+ function deleteExercise(id) {
+ // Tombstone, like events. Logged training sessions keep referencing the id;
+ // name lookups still resolve through the tombstone (see exerciseNames).
+ saveExercises(loadExercises().map(x =>
+ x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x
+ ));
+ scheduleSync();
+ render();
+ }
+
+ // id -> name across *all* exercises, tombstones included, so history rows for
+ // a deleted exercise still show its name instead of a generic "Training".
+ function exerciseNames() {
+ return new Map(loadExercises().map(x => [x.id, x.name]));
+ }
+
// ---------- helpers ----------
function ymd(date) {
const y = date.getFullYear();
@@ -533,6 +592,7 @@
document.getElementById("stat-meals").textContent = count("eat");
document.getElementById("stat-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo");
+ document.getElementById("stat-training").textContent = count("training");
}
// Gaps (ms) between consecutive events of `type` logged within the last
@@ -691,6 +751,7 @@
function renderHistory(events) {
const dayEvents = eventsForDay(events, selectedDay()).reverse();
+ const exNames = exerciseNames();
eventList.innerHTML = "";
if (dayEvents.length === 0) {
emptyState.hidden = false;
@@ -698,6 +759,10 @@
}
emptyState.hidden = true;
for (const ev of dayEvents) {
+ let label = EVENT_LABELS[ev.type] || ev.type;
+ if (ev.type === "training" && exNames.get(ev.exerciseId)) {
+ label = `Training · ${exNames.get(ev.exerciseId)}`;
+ }
const li = document.createElement("li");
li.className = "event";
li.dataset.type = ev.type;
@@ -705,7 +770,7 @@
li.innerHTML = `
${formatTime(ev.at)}
- ${EVENT_LABELS[ev.type] || ev.type}
+ ${escapeText(label)}
`;
const noteEl = li.querySelector(".note");
@@ -1194,6 +1259,170 @@
drawWeightChart(weights, birthday);
}
+ // ---------- training ----------
+ // Per-exercise stats plus a consistency heatmap. Everything here is rolling
+ // (last session / last 7 days / streak / last 14 days) rather than scoped to
+ // the day picker — the point is keeping the habit up, not reviewing one day.
+ const TRAINING_DAYS = 14;
+ const expandedExercises = new Set(); // ids showing their instructions (per page load)
+
+ function trainingStreakDays(times) {
+ // Consecutive days with ≥1 session, counting back from today — or from
+ // yesterday, so a streak isn't shown as broken before today's session
+ // has had a chance to happen.
+ const days = new Set(times.map(t => ymd(new Date(t))));
+ const d = new Date();
+ if (!days.has(ymd(d))) d.setDate(d.getDate() - 1);
+ let streak = 0;
+ while (days.has(ymd(d))) { streak++; d.setDate(d.getDate() - 1); }
+ return streak;
+ }
+
+ function exerciseMeta(times) {
+ if (times.length === 0) return "not yet trained";
+ const weekFrom = startOfDay(new Date());
+ weekFrom.setDate(weekFrom.getDate() - 6);
+ const week = times.filter(t => t >= weekFrom.getTime()).length;
+ const parts = [`last ${formatRelative(Math.max(...times))}`, `${week}× this week`];
+ const streak = trainingStreakDays(times);
+ if (streak >= 2) parts.push(`🔥 ${streak}-day streak`);
+ return parts.join(" · ");
+ }
+
+ // Exercise × day grid: one row per exercise, one cell per day, opacity scaled
+ // by that day's session count. Same idea as the hour heatmap, with days for
+ // columns. Cells click through to the day picker via setChartSVG.
+ function renderTrainingHeatmap(exercises, events) {
+ const wrap = document.getElementById("training-chart-wrap");
+ const svg = document.getElementById("chart-training");
+ if (exercises.length === 0) { wrap.hidden = true; return; }
+ wrap.hidden = false;
+
+ const N = TRAINING_DAYS;
+ const from = startOfDay(new Date());
+ from.setDate(from.getDate() - (N - 1));
+ const dayList = [];
+ const dayIndex = new Map(); // ymd -> column
+ for (let i = 0; i < N; i++) {
+ const d = new Date(from);
+ d.setDate(d.getDate() + i);
+ dayList.push(d);
+ dayIndex.set(ymd(d), i);
+ }
+ const exIndex = new Map(exercises.map((x, i) => [x.id, i]));
+ const counts = exercises.map(() => new Array(N).fill(0));
+ for (const e of events) {
+ if (e.type !== "training") continue;
+ const r = exIndex.get(e.exerciseId);
+ const c = dayIndex.get(ymd(new Date(e.at)));
+ if (r !== undefined && c !== undefined) counts[r][c]++;
+ }
+
+ const W = 320;
+ const ML = 70, MR = 8, MT = 6, MB = 18;
+ const rowH = 16, rowGap = 4;
+ const H = MT + exercises.length * (rowH + rowGap) - rowGap + MB;
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+ const innerW = W - ML - MR;
+ const cellW = innerW / N;
+
+ const parts = [];
+ exercises.forEach((x, r) => {
+ const y = MT + r * (rowH + rowGap);
+ const max = Math.max(1, ...counts[r]);
+ for (let c = 0; c < N; c++) {
+ const n = counts[r][c];
+ const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
+ const d = dayList[c];
+ const title = `${x.name} · ${d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}: ${n}`;
+ parts.push(
+ `
Darker = happens more often at that hour.
+No exercises yet. Add one to start tracking training.
+ +Darker = more sessions that day. Tap a cell to open that day.
+