Compare commits

..

2 Commits

Author SHA1 Message Date
Alexander Heldt 3687229178 Remove the redundant "Currently" row from the overview
The big clock already shows the asleep/awake state prominently.
2026-07-12 16:02:22 +00:00
Alexander Heldt 18fae835a5 Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same
LWW/tombstone contract as events, served by POST /api/exercises/sync.
Training sessions are ordinary events (type "training") referencing an
exercise by id, so they ride the existing event sync unchanged.

The Training panel lists each exercise with last-trained / this-week /
streak stats, expandable instructions, and a one-tap Log button with the
usual undo/add-note snackbar. An exercise-by-day heatmap shows the last
14 days of consistency, and history and the daily overview count
training sessions like any other event.
2026-07-12 16:01:41 +00:00
6 changed files with 573 additions and 50 deletions
+7 -2
View File
@@ -1,7 +1,7 @@
# puppy-tracker # puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
weight. weight, and training.
The browser is the primary client; a small Go server provides a shared The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices. 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`. `config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server - Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use. 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 - 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 (`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 of being configured per-client. The client caches the last-seen values in
+4
View File
@@ -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 { if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err return err
} }
if _, err := a.db.Exec(`UPDATE exercises SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
return a.adoptPhotos(userID) return a.adoptPhotos(userID)
} }
@@ -402,6 +405,7 @@ func (a *Auth) deleteAccount(userID string) error {
defer tx.Rollback() defer tx.Rollback()
for _, q := range []string{ for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`, `DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`, `DELETE FROM config WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`, `DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`, `DELETE FROM users WHERE id = ?`,
+136 -5
View File
@@ -30,6 +30,19 @@ type Event struct {
Note string `json:"note"` Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` PhotoID string `json:"photoId,omitempty"`
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events 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"` UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"` Deleted bool `json:"deleted,omitempty"`
} }
@@ -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 — // 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. // the row stays put and, because reads are scoped, stays invisible to them.
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id) INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note, type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight, photo_id = excluded.photo_id, weight = excluded.weight,
exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`) AND events.user_id = excluded.user_id`)
@@ -132,7 +146,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
continue continue
} }
if _, err := stmt.Exec( 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 { ); err != nil {
return nil, err 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. // all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) { func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query( 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) FROM events WHERE user_id = ?`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -156,7 +170,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() { for rows.Next() {
var e Event var e Event
if err := rows.Scan( 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 { ); err != nil {
return nil, err return nil, err
} }
@@ -165,6 +179,72 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err() 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 // openDB opens (creating if needed) the SQLite database and ensures the schema
// exists. WAL mode plays nicely with concurrent readers during a sync write; // exists. WAL mode plays nicely with concurrent readers during a sync write;
// busy_timeout avoids spurious "database is locked" errors under contention. // busy_timeout avoids spurious "database is locked" errors under contention.
@@ -197,11 +277,21 @@ func openDB(path string) (*sql.DB, error) {
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0, weight REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0, updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '' user_id TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id); 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 ( CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
@@ -249,6 +339,15 @@ func migrateSchema(db *sql.DB) error {
return err 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") oldConfig, err := columnExists(db, "config", "id")
if err != nil { if err != nil {
return err return err
@@ -383,6 +482,14 @@ type syncResponse struct {
ServerNow int64 `json:"serverNow"` ServerNow int64 `json:"serverNow"`
} }
type exerciseSyncRequest struct {
Exercises []Exercise `json:"exercises"`
}
type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type cacheControlFS struct { type cacheControlFS struct {
root http.FileSystem root http.FileSystem
} }
@@ -481,6 +588,7 @@ func main() {
store := newStore(db) store := newStore(db)
configStore := newConfigStore(db) configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil { 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. // GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt). // PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
+302 -20
View File
@@ -8,6 +8,7 @@
let currentUser = null; let currentUser = null;
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`; const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config: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_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200; const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000; const SYNC_POLL_MS = 60_000;
@@ -19,6 +20,7 @@
"pee": "Pee", "pee": "Pee",
"poo": "Poo", "poo": "Poo",
"weight": "Weigh-in", "weight": "Weigh-in",
"training": "Training",
}; };
// ---------- photos: IndexedDB store ---------- // ---------- photos: IndexedDB store ----------
@@ -255,7 +257,7 @@
return `${wk} · ${mo} old`; return `${wk} · ${mo} old`;
} }
function addEvent(type, note, at, photoId, weight) { function addEvent(type, note, at, photoId, weight, exerciseId) {
const events = loadAll(); const events = loadAll();
const now = Date.now(); const now = Date.now();
const ev = { const ev = {
@@ -265,6 +267,7 @@
note: note || "", note: note || "",
photoId: photoId || "", photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined, weight: Number.isFinite(weight) ? weight : undefined,
exerciseId: exerciseId || "",
updatedAt: now, updatedAt: now,
}; };
events.push(ev); events.push(ev);
@@ -293,6 +296,62 @@
render(); 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 ---------- // ---------- helpers ----------
function ymd(date) { function ymd(date) {
const y = date.getFullYear(); const y = date.getFullYear();
@@ -533,6 +592,7 @@
document.getElementById("stat-meals").textContent = count("eat"); document.getElementById("stat-meals").textContent = count("eat");
document.getElementById("stat-pees").textContent = count("pee"); document.getElementById("stat-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo"); 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 // Gaps (ms) between consecutive events of `type` logged within the last
@@ -599,15 +659,6 @@
document.getElementById("last-sleep").textContent = lastSleep document.getElementById("last-sleep").textContent = lastSleep
? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})` ? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})`
: "—"; : "—";
const row = document.getElementById("currently-row");
const currently = document.getElementById("currently");
if (currentSleepState(events).state === "asleep") {
row.hidden = false;
currently.textContent = "😴 Asleep";
} else {
row.hidden = true;
}
} }
// Tracks the latest sleep transition so the 1-second tick can update the // Tracks the latest sleep transition so the 1-second tick can update the
@@ -691,6 +742,7 @@
function renderHistory(events) { function renderHistory(events) {
const dayEvents = eventsForDay(events, selectedDay()).reverse(); const dayEvents = eventsForDay(events, selectedDay()).reverse();
const exNames = exerciseNames();
eventList.innerHTML = ""; eventList.innerHTML = "";
if (dayEvents.length === 0) { if (dayEvents.length === 0) {
emptyState.hidden = false; emptyState.hidden = false;
@@ -698,6 +750,10 @@
} }
emptyState.hidden = true; emptyState.hidden = true;
for (const ev of dayEvents) { 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"); const li = document.createElement("li");
li.className = "event"; li.className = "event";
li.dataset.type = ev.type; li.dataset.type = ev.type;
@@ -705,7 +761,7 @@
li.innerHTML = ` li.innerHTML = `
<span class="dot"></span> <span class="dot"></span>
<span class="time">${formatTime(ev.at)}</span> <span class="time">${formatTime(ev.at)}</span>
<span class="label">${EVENT_LABELS[ev.type] || ev.type}</span> <span class="label">${escapeText(label)}</span>
<span class="note"></span> <span class="note"></span>
`; `;
const noteEl = li.querySelector(".note"); const noteEl = li.querySelector(".note");
@@ -1194,6 +1250,170 @@
drawWeightChart(weights, birthday); 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(
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2" fill-opacity="${op.toFixed(2)}">` +
`<title>${escapeText(title)}</title></rect>`
);
}
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(name)}</text>`);
});
const yAxis = H - MB + 12;
parts.push(`<text x="${ML}" y="${yAxis}" text-anchor="start">${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}</text>`);
parts.push(`<text x="${ML + innerW}" y="${yAxis}" text-anchor="end">Today</text>`);
setChartSVG(svg, parts);
}
function renderTraining(events) {
const list = document.getElementById("training-list");
const empty = document.getElementById("training-empty");
const exercises = liveExercises();
list.innerHTML = "";
empty.hidden = exercises.length > 0;
for (const ex of exercises) {
const times = events
.filter(e => e.type === "training" && e.exerciseId === ex.id)
.map(e => e.at);
const li = document.createElement("li");
li.className = "exercise" + (expandedExercises.has(ex.id) ? " expanded" : "");
const row = document.createElement("div");
row.className = "ex-row";
const main = document.createElement("div");
main.className = "ex-main";
const nameEl = document.createElement("span");
nameEl.className = "ex-name";
nameEl.textContent = ex.name;
const metaEl = document.createElement("span");
metaEl.className = "ex-meta";
metaEl.textContent = exerciseMeta(times);
main.appendChild(nameEl);
main.appendChild(metaEl);
const logBtn = document.createElement("button");
logBtn.type = "button";
logBtn.className = "ex-log";
logBtn.textContent = " Log";
logBtn.addEventListener("click", (e) => {
e.stopPropagation();
const ev = addEvent("training", "", Date.now(), "", undefined, ex.id);
showSnackbar(`${ex.name} logged`, ev);
});
row.appendChild(main);
row.appendChild(logBtn);
row.addEventListener("click", () => {
if (expandedExercises.has(ex.id)) expandedExercises.delete(ex.id);
else expandedExercises.add(ex.id);
li.classList.toggle("expanded");
});
const detail = document.createElement("div");
detail.className = "ex-detail";
const noteEl = document.createElement("p");
noteEl.className = "ex-note";
noteEl.textContent = ex.note || "No instructions yet — tap Edit to add how to train this.";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "ghost ex-edit";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
openExerciseDialog(ex);
});
detail.appendChild(noteEl);
detail.appendChild(editBtn);
li.appendChild(row);
li.appendChild(detail);
list.appendChild(li);
}
renderTrainingHeatmap(exercises, events);
}
function renderDayBar() { function renderDayBar() {
const day = selectedDay(); const day = selectedDay();
const isToday = ymd(day) === ymd(new Date()); const isToday = ymd(day) === ymd(new Date());
@@ -1235,6 +1455,7 @@
renderWeekly(events); renderWeekly(events);
renderSleepTimeline(events); renderSleepTimeline(events);
renderHourHeatmap(events); renderHourHeatmap(events);
renderTraining(events);
renderWeight(events); renderWeight(events);
renderHistory(events); renderHistory(events);
} }
@@ -1281,13 +1502,14 @@
syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS); syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS);
} }
// Merge server response back into local storage. Anything local with a newer // Merge a server response back into local storage. Anything local with a
// updatedAt than the server's copy wins — that covers events the user added // newer updatedAt than the server's copy wins — that covers items the user
// during the in-flight sync request. // added/edited during the in-flight sync request. Shared by the events and
function mergeServer(serverEvents) { // exercises collections, which follow the same LWW contract.
const localById = new Map(loadAll().map(e => [e.id, e])); function mergeSynced(serverItems, load, save) {
const localById = new Map(load().map(e => [e.id, e]));
const merged = new Map(); const merged = new Map();
for (const se of serverEvents) { for (const se of serverItems) {
if (se && se.id) merged.set(se.id, se); if (se && se.id) merged.set(se.id, se);
} }
for (const [id, le] of localById) { for (const [id, le] of localById) {
@@ -1296,7 +1518,7 @@
merged.set(id, le); merged.set(id, le);
} }
} }
saveAll([...merged.values()]); save([...merged.values()]);
} }
async function sync() { async function sync() {
@@ -1318,12 +1540,25 @@
if (res.status === 401) { handleLoggedOut(); return; } if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const exRes = await fetch("api/exercises/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exercises: loadExercises() }),
});
if (exRes.status === 401) { handleLoggedOut(); return; }
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
const exBody = await exRes.json();
if (Array.isArray(exBody.exercises)) {
mergeSynced(exBody.exercises, loadExercises, saveExercises);
}
if (Array.isArray(body.events)) { if (Array.isArray(body.events)) {
mergeServer(body.events); mergeSynced(body.events, loadAll, saveAll);
}
lastSynced = Date.now(); lastSynced = Date.now();
lastError = null; lastError = null;
render(); render();
}
setStatus("synced"); setStatus("synced");
} catch (err) { } catch (err) {
lastError = err.message || String(err); lastError = err.message || String(err);
@@ -1680,6 +1915,51 @@
settingsDialog.close(); settingsDialog.close();
}); });
// ---------- exercise dialog (add / edit a training exercise) ----------
const exerciseDialog = document.getElementById("exercise-dialog");
const exerciseForm = document.getElementById("exercise-form");
const exerciseTitle = document.getElementById("exercise-title");
const exerciseName = document.getElementById("exercise-name");
const exerciseNote = document.getElementById("exercise-note");
const exerciseDelete = document.getElementById("exercise-delete");
let editingExerciseId = null;
function openExerciseDialog(ex) {
editingExerciseId = ex ? ex.id : null;
exerciseTitle.textContent = ex ? "Edit exercise" : "Add exercise";
exerciseName.value = ex ? ex.name : "";
exerciseNote.value = ex ? (ex.note || "") : "";
exerciseDelete.hidden = !ex;
exerciseDialog.showModal();
setTimeout(() => exerciseName.focus(), 50);
}
document.getElementById("exercise-add").addEventListener("click", () => openExerciseDialog(null));
exerciseForm.querySelector('button[value="save"]').addEventListener("click", (e) => {
e.preventDefault();
const name = exerciseName.value.trim();
if (!name) { alert("Give the exercise a name."); return; }
const note = exerciseNote.value.trim();
if (editingExerciseId) updateExercise(editingExerciseId, { name, note });
else addExercise(name, note);
editingExerciseId = null;
exerciseDialog.close();
});
exerciseForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault();
editingExerciseId = null;
exerciseDialog.close();
});
exerciseDelete.addEventListener("click", (e) => {
e.preventDefault();
if (editingExerciseId && confirm("Delete this exercise? Logged sessions stay in history.")) {
deleteExercise(editingExerciseId);
}
editingExerciseId = null;
exerciseDialog.close();
});
// ---------- delete account ---------- // ---------- delete account ----------
const deleteAccountDialog = document.getElementById("delete-account-dialog"); const deleteAccountDialog = document.getElementById("delete-account-dialog");
const deleteAccountPassword = document.getElementById("delete-account-password"); const deleteAccountPassword = document.getElementById("delete-account-password");
@@ -1712,6 +1992,7 @@
try { try {
localStorage.removeItem(eventsKey()); localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey()); localStorage.removeItem(configKey());
localStorage.removeItem(exercisesKey());
} catch { /* ignore */ } } catch { /* ignore */ }
clearUser(); clearUser();
deleteAccountDialog.close(); deleteAccountDialog.close();
@@ -1968,6 +2249,7 @@
renderWeekly(evs); renderWeekly(evs);
renderSleepTimeline(evs); renderSleepTimeline(evs);
renderHourHeatmap(evs); renderHourHeatmap(evs);
renderTraining(evs);
if (navigator.onLine && !syncing) setStatus(); if (navigator.onLine && !syncing) setStatus();
}, 60_000); }, 60_000);
+33 -1
View File
@@ -120,6 +120,10 @@
<div class="stat-label">Poos</div> <div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div> <div class="stat-value" id="stat-poos">0</div>
</div> </div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div> </div>
<div class="lasts"> <div class="lasts">
@@ -127,7 +131,6 @@
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div> <div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div> <div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div> <div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row" id="currently-row" hidden><span>Currently</span><span id="currently"></span></div>
</div> </div>
</section> </section>
@@ -183,6 +186,18 @@
<p class="muted-note">Darker = happens more often at that hour.</p> <p class="muted-note">Darker = happens more often at that hour.</p>
</section> </section>
<section class="training" data-panel="training">
<h2>Training</h2>
<ul id="training-list" class="training-list"></ul>
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="exercise-add" class="ghost training-add"> Add exercise</button>
<div class="chart training-chart" id="training-chart-wrap" hidden>
<div class="chart-title">Consistency (last 14 days)</div>
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day over the last 14 days"></svg>
<p class="muted-note">Darker = more sessions that day. Tap a cell to open that day.</p>
</div>
</section>
<section class="weight" data-panel="weight"> <section class="weight" data-panel="weight">
<h2>Weight</h2> <h2>Weight</h2>
<div class="weight-summary"> <div class="weight-summary">
@@ -252,6 +267,23 @@
</form> </form>
</dialog> </dialog>
<dialog id="exercise-dialog">
<form method="dialog" id="exercise-form">
<h3 id="exercise-title">Add exercise</h3>
<label>Name
<input type="text" id="exercise-name" placeholder="e.g. Sit" autocomplete="off" />
</label>
<label>How to do it
<textarea id="exercise-note" rows="5" placeholder="Reminder for how to train it, e.g. lure with a treat, mark the moment the butt touches the ground, reward"></textarea>
</label>
<menu>
<button value="delete" id="exercise-delete" class="danger" hidden>Delete</button>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="exercise-save">Save</button>
</menu>
</form>
</dialog>
<dialog id="note-dialog"> <dialog id="note-dialog">
<form method="dialog" id="note-form"> <form method="dialog" id="note-form">
<h3 id="note-title">Add note</h3> <h3 id="note-title">Add note</h3>
+69
View File
@@ -10,6 +10,7 @@
--pee: #ffd23f; --pee: #ffd23f;
--poo: #8a5a3b; --poo: #8a5a3b;
--weight: #2bb3a3; --weight: #2bb3a3;
--training: #b04ecf;
--danger: #d64545; --danger: #d64545;
--gain: #2e9e5b; --gain: #2e9e5b;
--border: #e9e6f5; --border: #e9e6f5;
@@ -357,6 +358,7 @@ textarea { resize: vertical; }
.event[data-type="pee"] .dot { background: var(--pee); } .event[data-type="pee"] .dot { background: var(--pee); }
.event[data-type="poo"] .dot { background: var(--poo); } .event[data-type="poo"] .dot { background: var(--poo); }
.event[data-type="weight"] .dot { background: var(--weight); } .event[data-type="weight"] .dot { background: var(--weight); }
.event[data-type="training"] .dot { background: var(--training); }
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; } .event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
.event .label { font-weight: 600; min-width: 110px; } .event .label { font-weight: 600; min-width: 110px; }
@@ -755,6 +757,73 @@ input.switch:checked::after { transform: translateX(18px); }
.chart-svg .hm-pee { fill: var(--pee); } .chart-svg .hm-pee { fill: var(--pee); }
.chart-svg .hm-poo { fill: var(--poo); } .chart-svg .hm-poo { fill: var(--poo); }
.chart-svg .hm-eat { fill: var(--eat); } .chart-svg .hm-eat { fill: var(--eat); }
.chart-svg .hm-training { fill: var(--training); }
/* ---------- training ---------- */
.training-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.training-list:empty { margin: 0; }
.exercise {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
}
.ex-row {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.ex-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ex-name { font-weight: 600; }
.ex-meta { color: var(--muted); font-size: 0.8rem; }
button.ex-log {
background: var(--training);
padding: 8px 14px;
flex-shrink: 0;
}
.ex-detail { display: none; }
.exercise.expanded .ex-detail {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.ex-note {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-wrap;
}
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
.training-add { width: 100%; }
.training-chart { margin-top: 16px; }
/* ---------- collapsible panels ---------- */ /* ---------- collapsible panels ---------- */
section.collapsible > h2 { section.collapsible > h2 {