Compare commits
2 Commits
561b98b64f
...
3687229178
| Author | SHA1 | Date | |
|---|---|---|---|
| 3687229178 | |||
| 18fae835a5 |
@@ -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
|
||||
|
||||
@@ -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 = ?`,
|
||||
|
||||
+136
-5
@@ -30,6 +30,19 @@ type Event struct {
|
||||
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"`
|
||||
}
|
||||
@@ -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.
|
||||
@@ -197,11 +277,21 @@ func openDB(path string) (*sql.DB, error) {
|
||||
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) {
|
||||
|
||||
+302
-20
@@ -8,6 +8,7 @@
|
||||
let currentUser = null;
|
||||
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
|
||||
@@ -599,15 +659,6 @@
|
||||
document.getElementById("last-sleep").textContent = lastSleep
|
||||
? `${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
|
||||
@@ -691,6 +742,7 @@
|
||||
|
||||
function renderHistory(events) {
|
||||
const dayEvents = eventsForDay(events, selectedDay()).reverse();
|
||||
const exNames = exerciseNames();
|
||||
eventList.innerHTML = "";
|
||||
if (dayEvents.length === 0) {
|
||||
emptyState.hidden = false;
|
||||
@@ -698,6 +750,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 +761,7 @@
|
||||
li.innerHTML = `
|
||||
<span class="dot"></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>
|
||||
`;
|
||||
const noteEl = li.querySelector(".note");
|
||||
@@ -1194,6 +1250,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(
|
||||
`<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() {
|
||||
const day = selectedDay();
|
||||
const isToday = ymd(day) === ymd(new Date());
|
||||
@@ -1235,6 +1455,7 @@
|
||||
renderWeekly(events);
|
||||
renderSleepTimeline(events);
|
||||
renderHourHeatmap(events);
|
||||
renderTraining(events);
|
||||
renderWeight(events);
|
||||
renderHistory(events);
|
||||
}
|
||||
@@ -1281,13 +1502,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 +1518,7 @@
|
||||
merged.set(id, le);
|
||||
}
|
||||
}
|
||||
saveAll([...merged.values()]);
|
||||
save([...merged.values()]);
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
@@ -1318,12 +1540,25 @@
|
||||
if (res.status === 401) { handleLoggedOut(); return; }
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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)) {
|
||||
mergeServer(body.events);
|
||||
mergeSynced(body.events, loadAll, saveAll);
|
||||
}
|
||||
lastSynced = Date.now();
|
||||
lastError = null;
|
||||
render();
|
||||
}
|
||||
setStatus("synced");
|
||||
} catch (err) {
|
||||
lastError = err.message || String(err);
|
||||
@@ -1680,6 +1915,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 +1992,7 @@
|
||||
try {
|
||||
localStorage.removeItem(eventsKey());
|
||||
localStorage.removeItem(configKey());
|
||||
localStorage.removeItem(exercisesKey());
|
||||
} catch { /* ignore */ }
|
||||
clearUser();
|
||||
deleteAccountDialog.close();
|
||||
@@ -1968,6 +2249,7 @@
|
||||
renderWeekly(evs);
|
||||
renderSleepTimeline(evs);
|
||||
renderHourHeatmap(evs);
|
||||
renderTraining(evs);
|
||||
if (navigator.onLine && !syncing) setStatus();
|
||||
}, 60_000);
|
||||
|
||||
|
||||
+33
-1
@@ -120,6 +120,10 @@
|
||||
<div class="stat-label">Poos</div>
|
||||
<div class="stat-value" id="stat-poos">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Training</div>
|
||||
<div class="stat-value" id="stat-training">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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" id="currently-row" hidden><span>Currently</span><span id="currently">—</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -183,6 +186,18 @@
|
||||
<p class="muted-note">Darker = happens more often at that hour.</p>
|
||||
</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">
|
||||
<h2>Weight</h2>
|
||||
<div class="weight-summary">
|
||||
@@ -252,6 +267,23 @@
|
||||
</form>
|
||||
</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">
|
||||
<form method="dialog" id="note-form">
|
||||
<h3 id="note-title">Add note</h3>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user