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));
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday.
// Returns null for a missing/invalid/future birthday.
function ageParts(birthday) {
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
// at that point). Returns null for a missing/invalid birthday or a date before it.
function ageParts(birthday, at) {
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 ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now()));
if (birth > ref) return null;
const days = Math.floor((ref - 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--;
let months = (ref.getFullYear() - birth.getFullYear()) * 12 +
(ref.getMonth() - birth.getMonth());
if (ref.getDate() < birth.getDate()) months--;
if (months < 0) months = 0;
return { days, weeks, months };
}
function formatAge(birthday) {
const a = ageParts(birthday);
function formatAge(birthday, at) {
const a = ageParts(birthday, at);
if (!a) return "";
const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`;
if (a.months < 1) return `${wk} old`;
@@ -939,14 +940,23 @@
return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) };
}
function drawWeightChart(weights) {
const svg = document.getElementById("chart-weight");
// Human-readable detail for one weigh-in: date, weight, and the puppy's age
// 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 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; }
if (weights.length === 0) { svg.innerHTML = ""; info.textContent = ""; return; }
const vals = weights.map(w => w.weight);
const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals));
@@ -972,11 +982,16 @@
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 => {
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(
`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5">` +
`<title>${escapeText(title)}</title></circle>`
`<circle class="weight-hit" data-i="${i}" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="10">` +
`<title>${escapeText(detail)}</title></circle>`
);
});
@@ -987,6 +1002,19 @@
}
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) {
@@ -999,6 +1027,8 @@
const changeEl = document.getElementById("weight-change");
const list = document.getElementById("weight-list");
const birthday = loadConfig().birthday;
list.innerHTML = "";
changeEl.classList.remove("up", "down");
@@ -1006,7 +1036,7 @@
empty.hidden = false;
latestEl.textContent = "—";
changeEl.textContent = "—";
drawWeightChart([]);
drawWeightChart([], birthday);
return;
}
empty.hidden = true;
@@ -1031,7 +1061,9 @@
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 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");
val.className = "ww-dur";
val.textContent = formatWeight(w.weight);
@@ -1041,7 +1073,7 @@
list.appendChild(li);
}
drawWeightChart(weights);
drawWeightChart(weights, birthday);
}
function renderDayBar() {
+1
View File
@@ -137,6 +137,7 @@
<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>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div>
<ul id="weight-list" class="wake-list"></ul>
<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 .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; }
.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 {
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 ASSETS = [
"./",