Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42ea2e309 | ||
|
|
29b961c1b0 | ||
|
|
2a14c34010 |
+186
-17
@@ -692,16 +692,28 @@
|
|||||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each type gets a small range chart: a band spanning the shortest to the
|
||||||
|
// typical gap over the window, and a marker for how long it has been since
|
||||||
|
// the last one. The marker is free to sit outside the band — just went (left
|
||||||
|
// of shortest) or overdue (right of typical) — which is the reading that
|
||||||
|
// decides whether to take the puppy out now.
|
||||||
|
const TIMING_ROWS = [
|
||||||
|
{ type: "pee", label: "Pees", noun: "pee", cls: "tm-pee" },
|
||||||
|
{ type: "poo", label: "Poos", noun: "poo", cls: "tm-poo" },
|
||||||
|
{ type: "eat", label: "Meals", noun: "meal", cls: "tm-eat" },
|
||||||
|
];
|
||||||
|
|
||||||
function renderTiming(events) {
|
function renderTiming(events) {
|
||||||
const peeGaps = gapsBetween(events, "pee");
|
const peeGaps = gapsBetween(events, "pee");
|
||||||
const pooGaps = gapsBetween(events, "poo");
|
|
||||||
const show = (id, ms) => {
|
for (const row of TIMING_ROWS) {
|
||||||
document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms);
|
const svg = document.getElementById(`timing-chart-${row.type}`);
|
||||||
};
|
const note = document.getElementById(`timing-note-${row.type}`);
|
||||||
show("gap-pee", median(peeGaps));
|
if (!svg || !note) continue;
|
||||||
show("gap-pee-min", peeGaps[0] ?? null);
|
const gaps = row.type === "pee" ? peeGaps : gapsBetween(events, row.type);
|
||||||
show("gap-poo", median(pooGaps));
|
const last = lastEventOfType(events, row.type);
|
||||||
show("gap-poo-min", pooGaps[0] ?? null);
|
drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null);
|
||||||
|
}
|
||||||
|
|
||||||
const hint = document.getElementById("timing-hint");
|
const hint = document.getElementById("timing-hint");
|
||||||
const typicalPee = median(peeGaps);
|
const typicalPee = median(peeGaps);
|
||||||
@@ -710,10 +722,129 @@
|
|||||||
`Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` +
|
`Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` +
|
||||||
`Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`;
|
`Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`;
|
||||||
} else {
|
} else {
|
||||||
hint.textContent = "Log a few more pees and poos to see typical timings.";
|
hint.textContent = "Log a few more pees, poos and meals to see typical timings.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawTimingChart(svg, note, row, gaps, since) {
|
||||||
|
const typical = median(gaps);
|
||||||
|
const shortest = gaps[0] ?? null;
|
||||||
|
|
||||||
|
// Fewer than two events in the window means there is no gap to draw, so the
|
||||||
|
// row falls back to a sentence rather than an axis with nothing on it.
|
||||||
|
if (typical == null) {
|
||||||
|
svg.innerHTML = "";
|
||||||
|
svg.style.display = "none";
|
||||||
|
note.hidden = false;
|
||||||
|
note.textContent = since == null
|
||||||
|
? `No ${row.noun}s logged in the last 7 days.`
|
||||||
|
: `One ${row.noun} logged, ${formatDuration(since)} ago — log another to see the typical gap.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
svg.style.display = "";
|
||||||
|
note.hidden = true;
|
||||||
|
|
||||||
|
const W = 320, H = 50, ML = 26, MR = 26;
|
||||||
|
const innerW = W - ML - MR;
|
||||||
|
const trackY = 20, trackH = 8;
|
||||||
|
|
||||||
|
// A gap in tracking can leave "since" at days while the typical gap is
|
||||||
|
// hours, which would squash the band to a sliver. Past twice the typical
|
||||||
|
// the marker parks at the right edge behind a chevron; its label still
|
||||||
|
// carries the real number, so nothing reads as being in range when it isn't.
|
||||||
|
const cap = typical * 2;
|
||||||
|
const over = since != null && since > cap;
|
||||||
|
const cur = since == null ? null : (over ? cap : since);
|
||||||
|
|
||||||
|
let lo = Math.min(shortest, cur ?? shortest);
|
||||||
|
let hi = Math.max(typical, cur ?? typical);
|
||||||
|
if (!(hi > lo)) { const pad = Math.max(60_000, hi * 0.25); lo -= pad; hi += pad; }
|
||||||
|
const xOf = (v) => ML + ((v - lo) / (hi - lo)) * innerW;
|
||||||
|
|
||||||
|
// The band collapses to a dot when a single gap is all there is, so it gets
|
||||||
|
// centred on the point instead of growing to the right of it.
|
||||||
|
const bandA = xOf(shortest), bandB = xOf(typical);
|
||||||
|
const bandW = Math.max(trackH + 2, bandB - bandA);
|
||||||
|
const bandX = bandB - bandA < trackH + 2 ? (bandA + bandB) / 2 - bandW / 2 : bandA;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
`<rect class="tm-track" x="${ML}" y="${trackY}" width="${innerW}" height="${trackH}" rx="${trackH / 2}"/>`,
|
||||||
|
`<rect class="tm-band ${row.cls}" x="${bandX.toFixed(1)}" y="${trackY}" ` +
|
||||||
|
`width="${bandW.toFixed(1)}" height="${trackH}" rx="${trackH / 2}">` +
|
||||||
|
`<title>${escapeText(`Usually ${formatDuration(shortest)}–${formatDuration(typical)} between ${row.noun}s`)}</title></rect>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ticks tie each label to the exact point it describes, which is what keeps
|
||||||
|
// a centred label honest when the band is too narrow to hang one off each end.
|
||||||
|
const tickY = trackY + trackH + 5;
|
||||||
|
const tick = (x) =>
|
||||||
|
`<line class="tm-tick" x1="${x.toFixed(1)}" y1="${tickY}" x2="${x.toFixed(1)}" y2="${tickY + 4}"/>`;
|
||||||
|
const labelY = tickY + 13;
|
||||||
|
// Whitespace inside a <tspan> is at the mercy of XML normalisation (an
|
||||||
|
//   does not survive every renderer either), so the gap between a word
|
||||||
|
// and its value is an explicit dx offset.
|
||||||
|
const word = (w, dx) => `<tspan class="tm-word"${dx ? ` dx="${dx}"` : ""}>${w}</tspan>`;
|
||||||
|
const val = (w, ms, dx) => `${word(w, dx)}<tspan dx="3">${escapeText(formatDuration(ms))}</tspan>`;
|
||||||
|
// Rough advance width for the 9px label font — only needs to be close
|
||||||
|
// enough to stop a centred label running off either edge.
|
||||||
|
const widthOf = (plain) => plain.length * 5;
|
||||||
|
const centred = (markup, plain, x) => {
|
||||||
|
const half = widthOf(plain) / 2;
|
||||||
|
const cx = Math.min(Math.max(x, half + 4), W - half - 4);
|
||||||
|
return `<text x="${cx.toFixed(1)}" y="${labelY}" text-anchor="middle">${markup}</text>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mid = (bandA + bandB) / 2;
|
||||||
|
if (bandB - bandA < 4) {
|
||||||
|
// One gap, or gaps that round to the same figure: a single point to name.
|
||||||
|
parts.push(tick(mid));
|
||||||
|
parts.push(centred(val("typical", typical), `typical ${formatDuration(typical)}`, mid));
|
||||||
|
} else if (bandB - bandA < 150) {
|
||||||
|
// Too narrow to hang a label off each end without the two touching, so
|
||||||
|
// both values ride together over the middle of the band rather than being
|
||||||
|
// pushed out to the track ends, where they would imply a spread the band
|
||||||
|
// doesn't have.
|
||||||
|
parts.push(tick(bandA), tick(bandB));
|
||||||
|
const plain = `shortest ${formatDuration(shortest)} · typical ${formatDuration(typical)}`;
|
||||||
|
parts.push(centred(
|
||||||
|
`${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}`,
|
||||||
|
plain, mid));
|
||||||
|
} else {
|
||||||
|
parts.push(tick(bandA), tick(bandB));
|
||||||
|
parts.push(`<text x="${bandA.toFixed(1)}" y="${labelY}" text-anchor="start">${val("shortest", shortest)}</text>`);
|
||||||
|
parts.push(`<text x="${bandB.toFixed(1)}" y="${labelY}" text-anchor="end">${val("typical", typical)}</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cur != null) {
|
||||||
|
const x = over ? W - MR : xOf(cur);
|
||||||
|
const y1 = trackY - 5, y2 = trackY + trackH + 5;
|
||||||
|
// Surface-coloured underlay: a ring that keeps the marker legible
|
||||||
|
// wherever on the band it lands.
|
||||||
|
parts.push(`<line class="tm-now-ring" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}"/>`);
|
||||||
|
parts.push(
|
||||||
|
`<line class="tm-now" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}">` +
|
||||||
|
`<title>${escapeText(`${formatDuration(since)} since the last ${row.noun}`)}</title></line>`
|
||||||
|
);
|
||||||
|
if (over) {
|
||||||
|
parts.push(
|
||||||
|
`<path class="tm-over" d="M${(x + 5).toFixed(1)} ${trackY} ` +
|
||||||
|
`L${(x + 12).toFixed(1)} ${trackY + trackH / 2} L${(x + 5).toFixed(1)} ${trackY + trackH} Z"/>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const anchor = x < ML + 55 ? "start" : (x > W - MR - 55 ? "end" : "middle");
|
||||||
|
parts.push(
|
||||||
|
`<text class="tm-now-label" x="${x.toFixed(1)}" y="11" text-anchor="${anchor}">` +
|
||||||
|
`${escapeText(`${formatDuration(since)} ago`)}</text>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.innerHTML = parts.join("");
|
||||||
|
svg.setAttribute("aria-label",
|
||||||
|
`${row.label}: typically ${formatDuration(typical)} between ${row.noun}s, ` +
|
||||||
|
`shortest ${formatDuration(shortest)}` +
|
||||||
|
(since == null ? "" : `, ${formatDuration(since)} since the last one`) + ".");
|
||||||
|
}
|
||||||
|
|
||||||
function renderLasts(events) {
|
function renderLasts(events) {
|
||||||
const setLast = (id, type) => {
|
const setLast = (id, type) => {
|
||||||
const ev = lastEventOfType(events, type);
|
const ev = lastEventOfType(events, type);
|
||||||
@@ -1349,18 +1480,25 @@
|
|||||||
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which heatmap block is focused, remembered across re-renders so a background
|
||||||
|
// sync doesn't wipe the block the user just tapped. The counts behind it are
|
||||||
|
// recomputed every render, so the readout stays current.
|
||||||
|
let hourCellSel = null; // `${type}-${hour}`, or null for nothing focused
|
||||||
|
const HOUR_CELL_HINT = "Tap a block to see how many it counts.";
|
||||||
|
|
||||||
// Hour-of-day heatmap: one row per event type, 24 cells shaded by how often
|
// Hour-of-day heatmap: one row per event type, 24 cells shaded by how often
|
||||||
// that event lands in each hour across the window. Reveals daily rhythm that
|
// that event lands in each hour across the window. Reveals daily rhythm that
|
||||||
// the median gap can't show (e.g. "always poos ~7am and ~6pm").
|
// the median gap can't show (e.g. "always poos ~7am and ~6pm").
|
||||||
function renderHourHeatmap(events) {
|
function renderHourHeatmap(events) {
|
||||||
const svg = document.getElementById("chart-hour-heatmap");
|
const svg = document.getElementById("chart-hour-heatmap");
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
const info = document.getElementById("hour-heatmap-info");
|
||||||
const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime();
|
const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime();
|
||||||
|
|
||||||
const series = [
|
const series = [
|
||||||
{ type: "pee", label: "Pees", cls: "hm-pee" },
|
{ type: "pee", label: "Pees", cls: "hm-pee", one: "pee", many: "pees" },
|
||||||
{ type: "poo", label: "Poos", cls: "hm-poo" },
|
{ type: "poo", label: "Poos", cls: "hm-poo", one: "poo", many: "poos" },
|
||||||
{ type: "eat", label: "Meals", cls: "hm-eat" },
|
{ type: "eat", label: "Meals", cls: "hm-eat", one: "meal", many: "meals" },
|
||||||
];
|
];
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const s of series) counts[s.type] = new Array(24).fill(0);
|
for (const s of series) counts[s.type] = new Array(24).fill(0);
|
||||||
@@ -1376,7 +1514,15 @@
|
|||||||
const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length;
|
const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length;
|
||||||
const cellW = innerW / 24;
|
const cellW = innerW / 24;
|
||||||
|
|
||||||
|
// "3 meals between 07:00 and 08:00" — self-describing, so the same string
|
||||||
|
// serves as the pointer tooltip and as the tap readout under the chart.
|
||||||
|
const cellText = (s, h, c) =>
|
||||||
|
`${c === 0 ? "No" : c} ${c === 1 ? s.one : s.many} between ` +
|
||||||
|
`${pad2(h)}:00 and ${pad2((h + 1) % 24)}:00`;
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const hits = []; // appended last so they sit above every row
|
||||||
|
const details = {}; // cell key → readout text
|
||||||
series.forEach((s, r) => {
|
series.forEach((s, r) => {
|
||||||
const y = MT + r * (rowH + rowGap);
|
const y = MT + r * (rowH + rowGap);
|
||||||
const max = Math.max(1, ...counts[s.type]);
|
const max = Math.max(1, ...counts[s.type]);
|
||||||
@@ -1384,11 +1530,18 @@
|
|||||||
const c = counts[s.type][h];
|
const c = counts[s.type][h];
|
||||||
const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max);
|
const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max);
|
||||||
const x = ML + h * cellW;
|
const x = ML + h * cellW;
|
||||||
const range = `${pad2(h)}:00–${pad2((h + 1) % 24)}:00`;
|
const key = `${s.type}-${h}`;
|
||||||
|
details[key] = cellText(s, h, c);
|
||||||
parts.push(
|
parts.push(
|
||||||
`<rect class="hm-cell ${s.cls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
|
`<rect class="hm-cell ${s.cls}" data-cell="${key}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||||||
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}">` +
|
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}"/>`
|
||||||
`<title>${escapeText(`${s.label} · ${range}: ${c}`)}</title></rect>`
|
);
|
||||||
|
// A cell is only ~12px wide on a phone, so the tap target claims the
|
||||||
|
// spacing between cells and half the gap to the neighbouring rows.
|
||||||
|
hits.push(
|
||||||
|
`<rect class="hm-hit" data-cell="${key}" x="${x.toFixed(1)}" y="${(y - rowGap / 2).toFixed(1)}" ` +
|
||||||
|
`width="${cellW.toFixed(1)}" height="${(rowH + rowGap).toFixed(1)}">` +
|
||||||
|
`<title>${escapeText(details[key])}</title></rect>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${s.label}</text>`);
|
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${s.label}</text>`);
|
||||||
@@ -1401,7 +1554,23 @@
|
|||||||
}
|
}
|
||||||
parts.push(`<text x="${(ML + innerW).toFixed(1)}" y="${yAxis}" text-anchor="end">24h</text>`);
|
parts.push(`<text x="${(ML + innerW).toFixed(1)}" y="${yAxis}" text-anchor="end">24h</text>`);
|
||||||
|
|
||||||
svg.innerHTML = parts.join("");
|
svg.innerHTML = parts.concat(hits).join("");
|
||||||
|
|
||||||
|
// <title> tooltips only ever show on a pointer, which left phones with no
|
||||||
|
// way to read a block's count. Tap/hover names the block under the chart;
|
||||||
|
// tapping the focused block again clears it.
|
||||||
|
const cells = svg.querySelectorAll(".hm-cell[data-cell]");
|
||||||
|
const focusCell = (key) => {
|
||||||
|
hourCellSel = details[key] ? key : null;
|
||||||
|
cells.forEach(c => c.classList.toggle("hm-active", c.dataset.cell === hourCellSel));
|
||||||
|
if (info) info.textContent = hourCellSel ? details[hourCellSel] : HOUR_CELL_HINT;
|
||||||
|
};
|
||||||
|
svg.querySelectorAll(".hm-hit").forEach(hit => {
|
||||||
|
const key = hit.dataset.cell;
|
||||||
|
hit.addEventListener("click", () => focusCell(hourCellSel === key ? null : key));
|
||||||
|
hit.addEventListener("mouseenter", () => focusCell(key));
|
||||||
|
});
|
||||||
|
focusCell(hourCellSel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- sleep trend ----------
|
// ---------- sleep trend ----------
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
[
|
[
|
||||||
|
{ "date": "2026-08-24", "text": "Each row of the timing panel is now a small chart instead of a number: the band spans the shortest to the typical gap over the last 7 days, and the marker is how long it has been since the last one. Inside the band means there is time yet, off the right-hand end means the puppy is due — and a row with only one event so far says so instead of drawing an empty axis" },
|
||||||
|
{ "date": "2026-08-24", "text": "The timing panel now covers meals too — typical and shortest time between them, alongside the pee and poo gaps — so you can see the feeding rhythm the same way. It is titled just “Timing” now that it is no longer only about bathroom breaks" },
|
||||||
|
{ "date": "2026-08-24", "text": "The “By hour of day” chart is now tappable: tap any block to read what it counts (“3 meals between 07:00 and 08:00”) just under the chart — until now that number only showed as a hover tooltip, which phones never get. The block you tapped gets a ring; tap it again to clear it" },
|
||||||
{ "date": "2026-08-21", "text": "The sleep timer pill in the day bar — the one that appears once you've scrolled past the big timer — is now tappable: tap it while the puppy is asleep to log the wake-up, or while awake to log a sleep start, without scrolling back up to the buttons" },
|
{ "date": "2026-08-21", "text": "The sleep timer pill in the day bar — the one that appears once you've scrolled past the big timer — is now tappable: tap it while the puppy is asleep to log the wake-up, or while awake to log a sleep start, without scrolling back up to the buttons" },
|
||||||
{ "date": "2026-08-20", "text": "Added reminders: turn them on in Settings and your phone gets a notification when it's time to sleep (\"Awake for 45 min\") or when there's been no pee, poo or meal for a while. Each one has its own interval, they arrive even with the app closed, and they stay quiet while the puppy is logged as asleep so you're not nagged all night. On iPhone, add Puppy Tracker to your Home Screen first — iOS only allows notifications for installed apps" },
|
{ "date": "2026-08-20", "text": "Added reminders: turn them on in Settings and your phone gets a notification when it's time to sleep (\"Awake for 45 min\") or when there's been no pee, poo or meal for a while. Each one has its own interval, they arrive even with the app closed, and they stay quiet while the puppy is logged as asleep so you're not nagged all night. On iPhone, add Puppy Tracker to your Home Screen first — iOS only allows notifications for installed apps" },
|
||||||
{ "date": "2026-08-18", "text": "The sleep button that would just repeat the last one is now disabled: while asleep you can only tap ⏰ Sleep end, and while awake only 😴 Sleep start — no more accidental double taps creating zero-length sleep windows. If you did miss a boundary, you can still add it at the right time from the event log" },
|
{ "date": "2026-08-18", "text": "The sleep button that would just repeat the last one is now disabled: while asleep you can only tap ⏰ Sleep end, and while awake only 😴 Sleep start — no more accidental double taps creating zero-length sleep windows. If you did miss a boundary, you can still add it at the right time from the event log" },
|
||||||
|
|||||||
+21
-6
@@ -170,12 +170,26 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="timing" data-panel="timing">
|
<section class="timing" data-panel="timing">
|
||||||
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
|
<h2>Timing <span class="muted-note">(last 7 days)</span></h2>
|
||||||
<div class="lasts">
|
<!-- One range chart per type, drawn by drawTimingChart: the band spans
|
||||||
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee">—</span></div>
|
the shortest to the typical gap and the marker is how long it has
|
||||||
<div class="last-row"><span>Shortest between pees</span><span id="gap-pee-min">—</span></div>
|
been since the last one, so a marker past the band reads as due. -->
|
||||||
<div class="last-row"><span>Typical time between poos</span><span id="gap-poo">—</span></div>
|
<div class="timing-charts">
|
||||||
<div class="last-row"><span>Shortest between poos</span><span id="gap-poo-min">—</span></div>
|
<div class="timing-item">
|
||||||
|
<div class="timing-name">Pees</div>
|
||||||
|
<svg id="timing-chart-pee" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-pee" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="timing-name">Poos</div>
|
||||||
|
<svg id="timing-chart-poo" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-poo" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="timing-item">
|
||||||
|
<div class="timing-name">Meals</div>
|
||||||
|
<svg id="timing-chart-eat" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
|
||||||
|
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted-note timing-hint" id="timing-hint"></p>
|
<p class="muted-note timing-hint" id="timing-hint"></p>
|
||||||
</section>
|
</section>
|
||||||
@@ -240,6 +254,7 @@
|
|||||||
<section class="patterns" data-panel="hour-heatmap">
|
<section class="patterns" data-panel="hour-heatmap">
|
||||||
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||||
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
||||||
|
<p id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
|
||||||
<p class="muted-note">Darker = happens more often at that hour.</p>
|
<p class="muted-note">Darker = happens more often at that hour.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -352,6 +352,34 @@ button.danger { background: var(--danger); }
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
|
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ---------- timing range charts ---------- */
|
||||||
|
.timing-charts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.timing-name {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.timing-chart { display: block; width: 100%; height: auto; }
|
||||||
|
/* Values wear ink, the words beside them are muted — the band alone carries
|
||||||
|
which type a chart is about (its heading says it too). */
|
||||||
|
.timing-chart text { fill: var(--text); font-size: 9px; font-variant-numeric: tabular-nums; }
|
||||||
|
.timing-chart .tm-word { fill: var(--muted); }
|
||||||
|
.timing-chart .tm-track { fill: var(--border); }
|
||||||
|
.timing-chart .tm-tick { stroke: var(--muted); stroke-width: 1; opacity: 0.45; }
|
||||||
|
.timing-chart .tm-pee { fill: var(--pee); }
|
||||||
|
.timing-chart .tm-poo { fill: var(--poo); }
|
||||||
|
.timing-chart .tm-eat { fill: var(--eat); }
|
||||||
|
/* "Since the last one" marker: ink over a surface-coloured ring, so it stays
|
||||||
|
readable on top of any band colour in either theme. */
|
||||||
|
.timing-chart .tm-now-ring { stroke: var(--surface); stroke-width: 7; stroke-linecap: round; }
|
||||||
|
.timing-chart .tm-now { stroke: var(--text); stroke-width: 3; stroke-linecap: round; }
|
||||||
|
.timing-chart .tm-over { fill: var(--text); }
|
||||||
|
.timing-note { margin: 2px 4px 0; }
|
||||||
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
|
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
|
||||||
|
|
||||||
.history-controls {
|
.history-controls {
|
||||||
@@ -662,7 +690,8 @@ dialog menu {
|
|||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.weight-point-info {
|
.weight-point-info,
|
||||||
|
.hour-point-info {
|
||||||
margin: 6px 4px 0;
|
margin: 6px 4px 0;
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1013,6 +1042,13 @@ input.switch:checked::after { transform: translateX(18px); }
|
|||||||
.lg[hidden] { display: none; }
|
.lg[hidden] { display: none; }
|
||||||
|
|
||||||
.chart-svg .hm-cell { stroke: none; }
|
.chart-svg .hm-cell { stroke: none; }
|
||||||
|
/* Focused block: an accent ring, drawn at full opacity so it stays visible on
|
||||||
|
the palest (zero-count) cells. */
|
||||||
|
.chart-svg .hm-cell.hm-active {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
.chart-svg .hm-hit { fill: transparent; cursor: pointer; }
|
||||||
.chart-svg .hm-pee { fill: var(--pee); }
|
.chart-svg .hm-pee { fill: var(--pee); }
|
||||||
.chart-svg .hm-poo { fill: var(--poo); }
|
.chart-svg .hm-poo { fill: var(--poo); }
|
||||||
.chart-svg .hm-eat { fill: var(--eat); }
|
.chart-svg .hm-eat { fill: var(--eat); }
|
||||||
|
|||||||
Reference in New Issue
Block a user