Track age and weight
This commit is contained in:
+308
-5
@@ -2,6 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "puppy-tracker:events:v1";
|
||||
const CONFIG_KEY = "puppy-tracker:config:v1";
|
||||
const SYNC_URL = "api/events/sync";
|
||||
const SYNC_DEBOUNCE_MS = 1200;
|
||||
const SYNC_POLL_MS = 60_000;
|
||||
@@ -12,6 +13,7 @@
|
||||
"eat": "Ate",
|
||||
"pee": "Pee",
|
||||
"poo": "Poo",
|
||||
"weight": "Weigh-in",
|
||||
};
|
||||
|
||||
// ---------- photos: IndexedDB store ----------
|
||||
@@ -198,7 +200,56 @@
|
||||
return loadAll().filter(e => !e.deleted);
|
||||
}
|
||||
|
||||
function addEvent(type, note, at, photoId) {
|
||||
// ---------- config (puppy name + birthday) ----------
|
||||
// The shared profile lives on the host so every client sees the same values.
|
||||
// localStorage is just a cache for instant paint + offline; the server is the
|
||||
// source of truth, reconciled by last-write-wins on updatedAt (see syncConfig).
|
||||
function loadConfig() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(CONFIG_KEY));
|
||||
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 };
|
||||
return {
|
||||
name: parsed.name || "",
|
||||
birthday: parsed.birthday || "",
|
||||
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
|
||||
};
|
||||
} catch {
|
||||
return { name: "", birthday: "", updatedAt: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(cfg) {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||||
}
|
||||
|
||||
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday.
|
||||
// Returns null for a missing/invalid/future birthday.
|
||||
function ageParts(birthday) {
|
||||
if (!birthday) return null;
|
||||
const [y, mo, d] = birthday.split("-").map(Number);
|
||||
if (!y || !mo || !d) return null;
|
||||
const birth = startOfDay(new Date(y, mo - 1, d));
|
||||
const now = startOfDay(new Date());
|
||||
if (birth > now) return null;
|
||||
const days = Math.floor((now - birth) / 86_400_000);
|
||||
const weeks = Math.floor(days / 7);
|
||||
let months = (now.getFullYear() - birth.getFullYear()) * 12 +
|
||||
(now.getMonth() - birth.getMonth());
|
||||
if (now.getDate() < birth.getDate()) months--;
|
||||
if (months < 0) months = 0;
|
||||
return { days, weeks, months };
|
||||
}
|
||||
|
||||
function formatAge(birthday) {
|
||||
const a = ageParts(birthday);
|
||||
if (!a) return "";
|
||||
const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`;
|
||||
if (a.months < 1) return `${wk} old`;
|
||||
const mo = `${a.months} month${a.months === 1 ? "" : "s"}`;
|
||||
return `${wk} · ${mo} old`;
|
||||
}
|
||||
|
||||
function addEvent(type, note, at, photoId, weight) {
|
||||
const events = loadAll();
|
||||
const now = Date.now();
|
||||
events.push({
|
||||
@@ -207,6 +258,7 @@
|
||||
at: Number.isFinite(at) ? at : now,
|
||||
note: note || "",
|
||||
photoId: photoId || "",
|
||||
weight: Number.isFinite(weight) ? weight : undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
saveAll(events);
|
||||
@@ -283,6 +335,11 @@
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg").
|
||||
function formatWeight(kg) {
|
||||
return `${Math.round(kg * 100) / 100} kg`;
|
||||
}
|
||||
|
||||
function formatRelative(ts) {
|
||||
if (!ts) return "—";
|
||||
const diff = Date.now() - ts;
|
||||
@@ -646,7 +703,12 @@
|
||||
<span class="label">${EVENT_LABELS[ev.type] || ev.type}</span>
|
||||
<span class="note"></span>
|
||||
`;
|
||||
li.querySelector(".note").textContent = ev.note || "";
|
||||
const noteEl = li.querySelector(".note");
|
||||
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
|
||||
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
|
||||
} else {
|
||||
noteEl.textContent = ev.note || "";
|
||||
}
|
||||
li.addEventListener("click", () => openEditDialog(ev));
|
||||
|
||||
if (ev.photoId) {
|
||||
@@ -859,6 +921,129 @@
|
||||
drawCountsChart(days);
|
||||
}
|
||||
|
||||
// ---------- weight ----------
|
||||
// Pick a "nice" kg axis that frames the data with a little headroom rather
|
||||
// than forcing 0-based (a puppy going 5→8 kg would otherwise look flat).
|
||||
function niceWeightAxis(min, max) {
|
||||
if (!(max > 0)) return { lo: 0, hi: 1, steps: 1 };
|
||||
if (min === max) { min = Math.max(0, min - 0.5); max = max + 0.5; }
|
||||
const span = max - min;
|
||||
let lo = Math.max(0, min - span * 0.15);
|
||||
let hi = max + span * 0.15;
|
||||
const rawStep = (hi - lo) / 4;
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||
const norm = rawStep / mag;
|
||||
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
|
||||
lo = Math.floor(lo / step) * step;
|
||||
hi = Math.ceil(hi / step) * step;
|
||||
return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) };
|
||||
}
|
||||
|
||||
function drawWeightChart(weights) {
|
||||
const svg = document.getElementById("chart-weight");
|
||||
const W = 320, H = 180;
|
||||
const ML = 30, MR = 8, MT = 10, MB = 24;
|
||||
const innerW = W - ML - MR;
|
||||
const innerH = H - MT - MB;
|
||||
|
||||
if (weights.length === 0) { svg.innerHTML = ""; return; }
|
||||
|
||||
const vals = weights.map(w => w.weight);
|
||||
const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals));
|
||||
|
||||
const t0 = weights[0].at;
|
||||
const t1 = weights[weights.length - 1].at;
|
||||
const tSpan = t1 - t0;
|
||||
const xOf = (t) => tSpan > 0 ? ML + ((t - t0) / tSpan) * innerW : ML + innerW / 2;
|
||||
const yOf = (v) => MT + innerH * (1 - (v - lo) / (hi - lo));
|
||||
|
||||
const parts = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const v = lo + (hi - lo) * i / steps;
|
||||
const y = yOf(v);
|
||||
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||||
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${Math.round(v * 10) / 10}</text>`);
|
||||
}
|
||||
|
||||
if (weights.length > 1) {
|
||||
const d = weights
|
||||
.map((w, i) => `${i === 0 ? "M" : "L"}${xOf(w.at).toFixed(1)} ${yOf(w.weight).toFixed(1)}`)
|
||||
.join(" ");
|
||||
parts.push(`<path class="weight-line" d="${d}"/>`);
|
||||
}
|
||||
|
||||
weights.forEach(w => {
|
||||
const title = `${new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })} — ${formatWeight(w.weight)}`;
|
||||
parts.push(
|
||||
`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5">` +
|
||||
`<title>${escapeText(title)}</title></circle>`
|
||||
);
|
||||
});
|
||||
|
||||
const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
parts.push(`<text x="${ML}" y="${H - MB + 16}" text-anchor="start">${escapeText(fmtX(t0))}</text>`);
|
||||
if (tSpan > 0) {
|
||||
parts.push(`<text x="${W - MR}" y="${H - MB + 16}" text-anchor="end">${escapeText(fmtX(t1))}</text>`);
|
||||
}
|
||||
|
||||
svg.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
function renderWeight(events) {
|
||||
const weights = events
|
||||
.filter(e => e.type === "weight" && Number.isFinite(e.weight))
|
||||
.sort((a, b) => a.at - b.at);
|
||||
|
||||
const empty = document.getElementById("weight-empty");
|
||||
const latestEl = document.getElementById("weight-latest");
|
||||
const changeEl = document.getElementById("weight-change");
|
||||
const list = document.getElementById("weight-list");
|
||||
|
||||
list.innerHTML = "";
|
||||
changeEl.classList.remove("up", "down");
|
||||
|
||||
if (weights.length === 0) {
|
||||
empty.hidden = false;
|
||||
latestEl.textContent = "—";
|
||||
changeEl.textContent = "—";
|
||||
drawWeightChart([]);
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
|
||||
const latest = weights[weights.length - 1];
|
||||
latestEl.textContent = formatWeight(latest.weight);
|
||||
|
||||
if (weights.length >= 2) {
|
||||
const d = latest.weight - weights[weights.length - 2].weight;
|
||||
const rounded = Math.round(d * 100) / 100;
|
||||
const arrow = d > 0 ? "▲" : d < 0 ? "▼" : "▬";
|
||||
changeEl.textContent = `${arrow} ${d > 0 ? "+" : ""}${rounded} kg`;
|
||||
changeEl.classList.toggle("up", d > 0);
|
||||
changeEl.classList.toggle("down", d < 0);
|
||||
} else {
|
||||
changeEl.textContent = "—";
|
||||
}
|
||||
|
||||
// Most-recent-first log; tap a row to edit that weigh-in.
|
||||
for (const w of [...weights].reverse()) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "ww weight-ww";
|
||||
const date = document.createElement("span");
|
||||
date.className = "ww-range";
|
||||
date.textContent = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
const val = document.createElement("span");
|
||||
val.className = "ww-dur";
|
||||
val.textContent = formatWeight(w.weight);
|
||||
li.appendChild(date);
|
||||
li.appendChild(val);
|
||||
li.addEventListener("click", () => openEditDialog(w));
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
drawWeightChart(weights);
|
||||
}
|
||||
|
||||
function renderDayBar() {
|
||||
const day = selectedDay();
|
||||
const isToday = ymd(day) === ymd(new Date());
|
||||
@@ -872,8 +1057,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const cfg = loadConfig();
|
||||
const title = document.getElementById("app-title");
|
||||
const ageEl = document.getElementById("puppy-age");
|
||||
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker";
|
||||
document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker";
|
||||
const ageText = formatAge(cfg.birthday);
|
||||
ageEl.textContent = ageText;
|
||||
ageEl.hidden = !ageText;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const events = live();
|
||||
renderHeader();
|
||||
renderDayBar();
|
||||
renderBigClock(events);
|
||||
renderStats(events);
|
||||
@@ -882,6 +1079,7 @@
|
||||
renderSleepWindows(events);
|
||||
renderWakeWindows(events);
|
||||
renderWeekly(events);
|
||||
renderWeight(events);
|
||||
renderHistory(events);
|
||||
}
|
||||
|
||||
@@ -978,6 +1176,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- config sync ----------
|
||||
// Reconcile the local config cache with the host. Whichever side has the
|
||||
// newer updatedAt wins: adopt the server's copy, or push ours if it's ahead
|
||||
// (e.g. edited on this device while another client hadn't changed it). This
|
||||
// self-heals a failed push — the local copy stays newer and re-pushes next tick.
|
||||
async function syncConfig() {
|
||||
if (!navigator.onLine) return;
|
||||
const local = loadConfig();
|
||||
try {
|
||||
const res = await fetch("api/config");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const server = {
|
||||
name: body.name || "",
|
||||
birthday: body.birthday || "",
|
||||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||||
};
|
||||
if (server.updatedAt > local.updatedAt) {
|
||||
saveConfig(server);
|
||||
renderHeader();
|
||||
} else if (local.updatedAt > server.updatedAt) {
|
||||
await pushConfig(local);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("config sync failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function pushConfig(cfg) {
|
||||
if (!navigator.onLine) return;
|
||||
const res = await fetch("api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cfg),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
// Adopt the server's answer if it turned out to be newer (another client won).
|
||||
if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) {
|
||||
saveConfig({ name: body.name || "", birthday: body.birthday || "", updatedAt: body.updatedAt });
|
||||
renderHeader();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- dialogs ----------
|
||||
const noteDialog = document.getElementById("note-dialog");
|
||||
const noteForm = document.getElementById("note-form");
|
||||
@@ -989,6 +1231,8 @@
|
||||
const notePhotoBtn = document.getElementById("note-photo-btn");
|
||||
const notePhotoClear = document.getElementById("note-photo-clear");
|
||||
const notePhotoPreview = document.getElementById("note-photo-preview");
|
||||
const noteWeightField = document.getElementById("note-weight-field");
|
||||
const noteWeight = document.getElementById("note-weight");
|
||||
let pendingType = null;
|
||||
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
|
||||
let notePhotoURL = null; // current preview object URL
|
||||
@@ -1010,9 +1254,12 @@
|
||||
noteDate.value = toDateInput(now);
|
||||
noteTime.value = toTimeInput(now);
|
||||
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
|
||||
const isWeight = type === "weight";
|
||||
noteWeightField.hidden = !isWeight;
|
||||
noteWeight.value = "";
|
||||
clearNotePhoto();
|
||||
noteDialog.showModal();
|
||||
setTimeout(() => noteInput.focus(), 50);
|
||||
setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50);
|
||||
}
|
||||
|
||||
function noteDialogAt() {
|
||||
@@ -1047,6 +1294,12 @@
|
||||
document.getElementById("note-save").addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!pendingType) { noteDialog.close(); return; }
|
||||
let weight;
|
||||
if (pendingType === "weight") {
|
||||
weight = parseFloat(noteWeight.value);
|
||||
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
|
||||
weight = Math.round(weight * 100) / 100;
|
||||
}
|
||||
let photoId = "";
|
||||
if (notePhotoBlob) {
|
||||
photoId = uuid();
|
||||
@@ -1056,7 +1309,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId);
|
||||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight);
|
||||
pendingType = null;
|
||||
clearNotePhoto();
|
||||
noteDialog.close();
|
||||
@@ -1079,6 +1332,8 @@
|
||||
const editPhotoBtn = document.getElementById("edit-photo-btn");
|
||||
const editPhotoClear = document.getElementById("edit-photo-clear");
|
||||
const editPhotoPreview = document.getElementById("edit-photo-preview");
|
||||
const editWeightField = document.getElementById("edit-weight-field");
|
||||
const editWeight = document.getElementById("edit-weight");
|
||||
let editingId = null;
|
||||
let editPhotoId = ""; // current photoId for this event
|
||||
let editPhotoBlob = null; // new blob chosen in this session
|
||||
@@ -1102,6 +1357,8 @@
|
||||
editDate.value = toDateInput(ev.at);
|
||||
editTime.value = toTimeInput(ev.at);
|
||||
editNote.value = ev.note || "";
|
||||
editWeightField.hidden = ev.type !== "weight";
|
||||
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
|
||||
editPhotoId = ev.photoId || "";
|
||||
editPhotoCleared = false;
|
||||
clearEditPhotoLocalState();
|
||||
@@ -1150,6 +1407,11 @@
|
||||
at: Number.isFinite(newAt) ? newAt : undefined,
|
||||
note: editNote.value.trim(),
|
||||
};
|
||||
if (!editWeightField.hidden) {
|
||||
const kg = parseFloat(editWeight.value);
|
||||
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
|
||||
patch.weight = Math.round(kg * 100) / 100;
|
||||
}
|
||||
if (editPhotoBlob) {
|
||||
const newId = uuid();
|
||||
try { await putPhoto(newId, editPhotoBlob, false); }
|
||||
@@ -1181,6 +1443,44 @@
|
||||
editDialog.close();
|
||||
});
|
||||
|
||||
// Settings dialog (puppy name + birthday)
|
||||
const settingsDialog = document.getElementById("settings-dialog");
|
||||
const settingsForm = document.getElementById("settings-form");
|
||||
const settingsName = document.getElementById("settings-name");
|
||||
const settingsBirthday = document.getElementById("settings-birthday");
|
||||
|
||||
function openSettingsDialog() {
|
||||
const cfg = loadConfig();
|
||||
settingsName.value = cfg.name;
|
||||
settingsBirthday.value = cfg.birthday;
|
||||
settingsDialog.showModal();
|
||||
setTimeout(() => settingsName.focus(), 50);
|
||||
}
|
||||
|
||||
document.getElementById("settings-btn").addEventListener("click", openSettingsDialog);
|
||||
|
||||
settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const cfg = {
|
||||
name: settingsName.value.trim(),
|
||||
birthday: settingsBirthday.value,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
saveConfig(cfg); // cache locally for instant + offline paint
|
||||
renderHeader();
|
||||
settingsDialog.close();
|
||||
try {
|
||||
await pushConfig(cfg);
|
||||
} catch (err) {
|
||||
console.warn("config save failed:", err);
|
||||
// Kept locally; syncConfig retries automatically once the host is reachable.
|
||||
}
|
||||
});
|
||||
settingsForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
settingsDialog.close();
|
||||
});
|
||||
|
||||
// ---------- wiring ----------
|
||||
document.querySelectorAll("button.action").forEach(btn => {
|
||||
btn.addEventListener("click", () => openNoteDialog(btn.dataset.type));
|
||||
@@ -1207,12 +1507,13 @@
|
||||
statusEl.title = "Click to sync now";
|
||||
statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); });
|
||||
|
||||
window.addEventListener("online", () => { setStatus(); sync(); });
|
||||
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
|
||||
window.addEventListener("offline", () => setStatus());
|
||||
|
||||
// Live-update relative times and (eventually) sync status text.
|
||||
setInterval(() => {
|
||||
const evs = live();
|
||||
renderHeader();
|
||||
renderBigClock(evs);
|
||||
renderStats(evs);
|
||||
renderLasts(evs);
|
||||
@@ -1228,6 +1529,7 @@
|
||||
|
||||
// Periodic pull from server so other clients' changes show up.
|
||||
setInterval(sync, SYNC_POLL_MS);
|
||||
setInterval(syncConfig, SYNC_POLL_MS);
|
||||
|
||||
// Service worker
|
||||
if ("serviceWorker" in navigator) {
|
||||
@@ -1240,4 +1542,5 @@
|
||||
setStatus();
|
||||
render();
|
||||
sync();
|
||||
syncConfig();
|
||||
})();
|
||||
|
||||
+51
-2
@@ -12,8 +12,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🐶 Puppy Tracker</h1>
|
||||
<div id="online-status" class="status-pill"></div>
|
||||
<div class="title">
|
||||
<h1 id="app-title">🐶 Puppy Tracker</h1>
|
||||
<div id="puppy-age" class="puppy-age" hidden></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
|
||||
<div id="online-status" class="status-pill"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -31,6 +37,7 @@
|
||||
<button class="action eat" data-type="eat">🍽️ Ate</button>
|
||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -115,6 +122,26 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="weight">
|
||||
<h2>Weight</h2>
|
||||
<div class="weight-summary">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Latest</div>
|
||||
<div class="stat-value" id="weight-latest">—</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Since last</div>
|
||||
<div class="stat-value" id="weight-change">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<div class="chart-title">Weight (kg)</div>
|
||||
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
|
||||
</div>
|
||||
<ul id="weight-list" class="wake-list"></ul>
|
||||
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
||||
</section>
|
||||
|
||||
<section class="history">
|
||||
<h2>History</h2>
|
||||
<ul id="event-list" class="event-list"></ul>
|
||||
@@ -122,6 +149,22 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="settings-dialog">
|
||||
<form method="dialog" id="settings-form">
|
||||
<h3>Puppy settings</h3>
|
||||
<label>Name
|
||||
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
|
||||
</label>
|
||||
<label>Birthday
|
||||
<input type="date" id="settings-birthday" />
|
||||
</label>
|
||||
<menu>
|
||||
<button value="cancel" class="ghost">Cancel</button>
|
||||
<button value="save" id="settings-save">Save</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="note-dialog">
|
||||
<form method="dialog" id="note-form">
|
||||
<h3 id="note-title">Add note</h3>
|
||||
@@ -132,6 +175,9 @@
|
||||
<button type="button" id="note-time-now" class="ghost">Now</button>
|
||||
</div>
|
||||
</label>
|
||||
<label id="note-weight-field" hidden>Weight (kg)
|
||||
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
|
||||
</label>
|
||||
<label>Note
|
||||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||||
</label>
|
||||
@@ -157,6 +203,9 @@
|
||||
<input type="time" id="edit-time" lang="en-GB" />
|
||||
</div>
|
||||
</label>
|
||||
<label id="edit-weight-field" hidden>Weight (kg)
|
||||
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
|
||||
</label>
|
||||
<label>Note
|
||||
<textarea id="edit-note" rows="4"></textarea>
|
||||
</label>
|
||||
|
||||
+44
-1
@@ -9,7 +9,9 @@
|
||||
--eat: #ff9b3d;
|
||||
--pee: #ffd23f;
|
||||
--poo: #8a5a3b;
|
||||
--weight: #2bb3a3;
|
||||
--danger: #d64545;
|
||||
--gain: #2e9e5b;
|
||||
--border: #e9e6f5;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05);
|
||||
@@ -57,6 +59,32 @@ h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.puppy-age {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
@@ -156,6 +184,7 @@ button.action.sleep { background: var(--sleep); }
|
||||
button.action.eat { background: var(--eat); }
|
||||
button.action.pee { background: var(--pee); color: #2b240a; }
|
||||
button.action.poo { background: var(--poo); }
|
||||
button.action.weight { background: var(--weight); }
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
@@ -227,7 +256,7 @@ button.danger { background: var(--danger); }
|
||||
|
||||
.history-controls label { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
input[type="date"], input[type="datetime-local"], textarea {
|
||||
input[type="date"], input[type="datetime-local"], input[type="text"], input[type="number"], textarea {
|
||||
font: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
@@ -313,6 +342,7 @@ textarea { resize: vertical; }
|
||||
.event[data-type="eat"] .dot { background: var(--eat); }
|
||||
.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 .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
|
||||
.event .label { font-weight: 600; min-width: 110px; }
|
||||
@@ -459,6 +489,19 @@ dialog menu {
|
||||
.chart-svg .bar-pee { fill: var(--pee); }
|
||||
.chart-svg .bar-poo { fill: var(--poo); }
|
||||
.chart-svg .bar-eat { fill: var(--eat); }
|
||||
.chart-svg .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; }
|
||||
.chart-svg .weight-dot { fill: var(--weight); }
|
||||
|
||||
.weight-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.weight-summary .stat-value.up { color: var(--gain); }
|
||||
.weight-summary .stat-value.down { color: var(--danger); }
|
||||
.ww.weight-ww { cursor: pointer; }
|
||||
.ww.weight-ww .ww-dur { text-align: right; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user