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( + `` + + `${escapeText(title)}` + ); + } + const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name; + parts.push(`${escapeText(name)}`); + }); + + const yAxis = H - MB + 12; + parts.push(`${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}`); + parts.push(`Today`); + + 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() { const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); @@ -1235,6 +1464,7 @@ renderWeekly(events); renderSleepTimeline(events); renderHourHeatmap(events); + renderTraining(events); renderWeight(events); renderHistory(events); } @@ -1281,13 +1511,14 @@ syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS); } - // Merge server response back into local storage. Anything local with a newer - // updatedAt than the server's copy wins — that covers events the user added - // during the in-flight sync request. - function mergeServer(serverEvents) { - const localById = new Map(loadAll().map(e => [e.id, e])); + // Merge a server response back into local storage. Anything local with a + // newer updatedAt than the server's copy wins — that covers items the user + // added/edited during the in-flight sync request. Shared by the events and + // exercises collections, which follow the same LWW contract. + function mergeSynced(serverItems, load, save) { + const localById = new Map(load().map(e => [e.id, e])); const merged = new Map(); - for (const se of serverEvents) { + for (const se of serverItems) { if (se && se.id) merged.set(se.id, se); } for (const [id, le] of localById) { @@ -1296,7 +1527,7 @@ merged.set(id, le); } } - saveAll([...merged.values()]); + save([...merged.values()]); } async function sync() { @@ -1318,12 +1549,25 @@ if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); - if (Array.isArray(body.events)) { - mergeServer(body.events); - lastSynced = Date.now(); - lastError = null; - render(); + + 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)) { + mergeSynced(body.events, loadAll, saveAll); + } + lastSynced = Date.now(); + lastError = null; + render(); setStatus("synced"); } catch (err) { lastError = err.message || String(err); @@ -1680,6 +1924,51 @@ 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 ---------- const deleteAccountDialog = document.getElementById("delete-account-dialog"); const deleteAccountPassword = document.getElementById("delete-account-password"); @@ -1712,6 +2001,7 @@ try { localStorage.removeItem(eventsKey()); localStorage.removeItem(configKey()); + localStorage.removeItem(exercisesKey()); } catch { /* ignore */ } clearUser(); deleteAccountDialog.close(); @@ -1968,6 +2258,7 @@ renderWeekly(evs); renderSleepTimeline(evs); renderHourHeatmap(evs); + renderTraining(evs); if (navigator.onLine && !syncing) setStatus(); }, 60_000); diff --git a/src/index.html b/src/index.html index afdc796..897ce9f 100644 --- a/src/index.html +++ b/src/index.html @@ -120,6 +120,10 @@
Poos
0
+
+
Training
+
0
+
@@ -183,6 +187,18 @@

Darker = happens more often at that hour.

+
+

Training

+ +

No exercises yet. Add one to start tracking training.

+ + +
+

Weight

@@ -252,6 +268,23 @@ + +
+

Add exercise

+ + + + + + + +
+
+

Add note

diff --git a/src/style.css b/src/style.css index 58fe925..9ec0e6f 100644 --- a/src/style.css +++ b/src/style.css @@ -10,6 +10,7 @@ --pee: #ffd23f; --poo: #8a5a3b; --weight: #2bb3a3; + --training: #b04ecf; --danger: #d64545; --gain: #2e9e5b; --border: #e9e6f5; @@ -357,6 +358,7 @@ textarea { resize: vertical; } .event[data-type="pee"] .dot { background: var(--pee); } .event[data-type="poo"] .dot { background: var(--poo); } .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 .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-poo { fill: var(--poo); } .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 ---------- */ section.collapsible > h2 {