Show age in weight graph

This commit is contained in:
Alexander Heldt
2026-07-04 09:27:13 +00:00
parent f14a749169
commit da692d84da
4 changed files with 66 additions and 21 deletions
+52 -20
View File
@@ -222,26 +222,27 @@
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
} }
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday. // Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// Returns null for a missing/invalid/future birthday. // measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
function ageParts(birthday) { // at that point). Returns null for a missing/invalid birthday or a date before it.
function ageParts(birthday, at) {
if (!birthday) return null; if (!birthday) return null;
const [y, mo, d] = birthday.split("-").map(Number); const [y, mo, d] = birthday.split("-").map(Number);
if (!y || !mo || !d) return null; if (!y || !mo || !d) return null;
const birth = startOfDay(new Date(y, mo - 1, d)); const birth = startOfDay(new Date(y, mo - 1, d));
const now = startOfDay(new Date()); const ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now()));
if (birth > now) return null; if (birth > ref) return null;
const days = Math.floor((now - birth) / 86_400_000); const days = Math.floor((ref - birth) / 86_400_000);
const weeks = Math.floor(days / 7); const weeks = Math.floor(days / 7);
let months = (now.getFullYear() - birth.getFullYear()) * 12 + let months = (ref.getFullYear() - birth.getFullYear()) * 12 +
(now.getMonth() - birth.getMonth()); (ref.getMonth() - birth.getMonth());
if (now.getDate() < birth.getDate()) months--; if (ref.getDate() < birth.getDate()) months--;
if (months < 0) months = 0; if (months < 0) months = 0;
return { days, weeks, months }; return { days, weeks, months };
} }
function formatAge(birthday) { function formatAge(birthday, at) {
const a = ageParts(birthday); const a = ageParts(birthday, at);
if (!a) return ""; if (!a) return "";
const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`; const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`;
if (a.months < 1) return `${wk} old`; if (a.months < 1) return `${wk} old`;
@@ -939,14 +940,23 @@
return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) }; return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) };
} }
function drawWeightChart(weights) { // Human-readable detail for one weigh-in: date, weight, and the puppy's age
const svg = document.getElementById("chart-weight"); // at that date (omitted if no birthday is configured).
function weightPointInfo(w, birthday) {
const dateStr = new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
const age = formatAge(birthday, w.at);
return `${dateStr}${formatWeight(w.weight)}${age ? ` · ${age}` : ""}`;
}
function drawWeightChart(weights, birthday) {
const svg = document.getElementById("chart-weight");
const info = document.getElementById("weight-point-info");
const W = 320, H = 180; const W = 320, H = 180;
const ML = 30, MR = 8, MT = 10, MB = 24; const ML = 30, MR = 8, MT = 10, MB = 24;
const innerW = W - ML - MR; const innerW = W - ML - MR;
const innerH = H - MT - MB; const innerH = H - MT - MB;
if (weights.length === 0) { svg.innerHTML = ""; return; } if (weights.length === 0) { svg.innerHTML = ""; info.textContent = ""; return; }
const vals = weights.map(w => w.weight); const vals = weights.map(w => w.weight);
const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals)); const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals));
@@ -972,11 +982,16 @@
parts.push(`<path class="weight-line" d="${d}"/>`); parts.push(`<path class="weight-line" d="${d}"/>`);
} }
// Visible dots, then larger transparent hit targets on top (easier to tap
// on touch, and they carry the tooltip + click detail).
weights.forEach(w => { 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"/>`);
});
weights.forEach((w, i) => {
const detail = weightPointInfo(w, birthday);
parts.push( parts.push(
`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5">` + `<circle class="weight-hit" data-i="${i}" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="10">` +
`<title>${escapeText(title)}</title></circle>` `<title>${escapeText(detail)}</title></circle>`
); );
}); });
@@ -987,6 +1002,19 @@
} }
svg.innerHTML = parts.join(""); svg.innerHTML = parts.join("");
// Default the caption to the most recent weigh-in; hover/tap focuses a point.
const hits = svg.querySelectorAll(".weight-hit");
const focus = (i) => {
info.textContent = weightPointInfo(weights[i], birthday);
hits.forEach(h => h.classList.toggle("active", Number(h.dataset.i) === i));
};
hits.forEach(h => {
const i = Number(h.dataset.i);
h.addEventListener("mouseenter", () => focus(i));
h.addEventListener("click", () => focus(i));
});
info.textContent = weightPointInfo(weights[weights.length - 1], birthday);
} }
function renderWeight(events) { function renderWeight(events) {
@@ -999,6 +1027,8 @@
const changeEl = document.getElementById("weight-change"); const changeEl = document.getElementById("weight-change");
const list = document.getElementById("weight-list"); const list = document.getElementById("weight-list");
const birthday = loadConfig().birthday;
list.innerHTML = ""; list.innerHTML = "";
changeEl.classList.remove("up", "down"); changeEl.classList.remove("up", "down");
@@ -1006,7 +1036,7 @@
empty.hidden = false; empty.hidden = false;
latestEl.textContent = "—"; latestEl.textContent = "—";
changeEl.textContent = "—"; changeEl.textContent = "—";
drawWeightChart([]); drawWeightChart([], birthday);
return; return;
} }
empty.hidden = true; empty.hidden = true;
@@ -1031,7 +1061,9 @@
li.className = "ww weight-ww"; li.className = "ww weight-ww";
const date = document.createElement("span"); const date = document.createElement("span");
date.className = "ww-range"; date.className = "ww-range";
date.textContent = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" }); const age = formatAge(birthday, w.at);
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
const val = document.createElement("span"); const val = document.createElement("span");
val.className = "ww-dur"; val.className = "ww-dur";
val.textContent = formatWeight(w.weight); val.textContent = formatWeight(w.weight);
@@ -1041,7 +1073,7 @@
list.appendChild(li); list.appendChild(li);
} }
drawWeightChart(weights); drawWeightChart(weights, birthday);
} }
function renderDayBar() { function renderDayBar() {
+1
View File
@@ -137,6 +137,7 @@
<div class="chart"> <div class="chart">
<div class="chart-title">Weight (kg)</div> <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> <svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div> </div>
<ul id="weight-list" class="wake-list"></ul> <ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p> <p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
+12
View File
@@ -491,6 +491,18 @@ dialog menu {
.chart-svg .bar-eat { fill: var(--eat); } .chart-svg .bar-eat { fill: var(--eat); }
.chart-svg .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; } .chart-svg .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; }
.chart-svg .weight-dot { fill: var(--weight); } .chart-svg .weight-dot { fill: var(--weight); }
.chart-svg .weight-hit { fill: transparent; cursor: pointer; }
.chart-svg .weight-hit.active {
fill: color-mix(in srgb, var(--weight) 22%, transparent);
stroke: var(--weight);
stroke-width: 1.5;
}
.weight-point-info {
margin: 6px 4px 0;
min-height: 1.2em;
text-align: center;
}
.weight-summary { .weight-summary {
display: grid; display: grid;
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v4"; const CACHE = "puppy-tracker-v5";
const PHOTO_CACHE = "puppy-tracker-photos-v1"; const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [ const ASSETS = [
"./", "./",