Show age in weight graph
This commit is contained in:
+52
-20
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user