Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Heldt f9894abfc9 Add free-text day notes with an always-visible notes log
A new 📝 Note quick-action logs a free-text note (with a date, optional
photo, and any text), defaulting to the day you're viewing. Notes are a
distinct "note" event type, so they edit, sync and delete like any other
event, and a new Notes section lists them newest-first across every day —
independent of the day picker — so records like vaccinations stay visible
whatever day you're looking at.
2026-08-02 13:37:39 +00:00
Alexander Heldt 09d9d38c12 Add metric checkboxes to the daily counts chart
The Daily counts legend is now a Pees/Poos/Meals checkbox group, so you
can hide metrics and focus on just the ones you care about. The choice is
persisted per device, at least one metric is always kept visible, and the
y-axis and bar widths adapt to the selected metrics.
2026-08-02 13:26:38 +00:00
Alexander Heldt a44c75d2d4 Let the confetti fade at its peak instead of falling back
Drop the descent phase: each emoji now rises to its peak, slows (ease-out) and
fades out at the top rather than arcing back down. Removes the now-unused --endY.
2026-08-01 18:28:40 +00:00
Alexander Heldt e2f99590f1 Make the pee/poo confetti a bottom-up fireworks fountain
Rework the effect: instead of a small burst from the tapped button, 30 emoji
launch from across the bottom of the screen, rise to random peaks while
drifting sideways, then arc back down and fade — staggered launches and varied
speed/size/rotation for a fuller, firework feel. Still pointer-events:none,
self-removing, and gated by the Settings toggle and reduced-motion.
2026-08-01 18:24:33 +00:00
4 changed files with 171 additions and 49 deletions
+128 -33
View File
@@ -21,6 +21,7 @@
"poo": "Poo",
"weight": "Weigh-in",
"training": "Training",
"note": "Note",
};
// ---------- photos: IndexedDB store ----------
@@ -894,6 +895,53 @@
}
}
// Cross-day log of free-text notes (vaccinations, vet visits, milestones…),
// newest first. Unlike History this ignores the day picker so the record is
// always visible regardless of which day you're viewing.
function renderNotes(events) {
const notes = events
.filter(e => e.type === "note")
.sort((a, b) => b.at - a.at);
const list = document.getElementById("notes-list");
const empty = document.getElementById("notes-empty");
list.innerHTML = "";
if (notes.length === 0) { empty.hidden = false; return; }
empty.hidden = true;
const birthday = loadConfig().birthday;
for (const ev of notes) {
const li = document.createElement("li");
li.className = "event";
li.dataset.type = "note";
li.dataset.id = ev.id;
const dateStr = new Date(ev.at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const age = formatAgeWeeks(birthday, ev.at);
li.innerHTML = `
<span class="dot"></span>
<span class="note-date">${escapeText(age ? `${dateStr} · ${age}` : dateStr)}</span>
<span class="note-text"></span>
`;
li.querySelector(".note-text").textContent = ev.note || "";
li.addEventListener("click", () => openEditDialog(ev));
for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
img.className = "thumb";
img.alt = "photo";
img.loading = "lazy";
img.dataset.photoId = pid;
img.addEventListener("click", (e) => {
e.stopPropagation();
openLightbox(pid);
});
li.appendChild(img);
photoSrc(pid).then(url => { if (url) img.src = url; });
}
list.appendChild(li);
}
}
// ---------- lightbox ----------
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
@@ -927,6 +975,27 @@
render();
}
// Which metrics the daily-counts chart shows. Device-global, toggled via the
// checkboxes under the chart. At least one is always kept on so the chart is
// never empty; an invalid/empty stored value falls back to all three.
const COUNTS_METRICS_KEY = "puppy-tracker:counts-metrics:v1";
const COUNTS_METRIC_KEYS = ["pees", "poos", "meals"];
function countsMetrics() {
let stored;
try { stored = JSON.parse(localStorage.getItem(COUNTS_METRICS_KEY)); } catch { /* ignore */ }
const on = Array.isArray(stored)
? COUNTS_METRIC_KEYS.filter(k => stored.includes(k))
: [];
return on.length ? on : COUNTS_METRIC_KEYS.slice();
}
function setCountsMetrics(keys) {
// Never let the user hide everything — keep at least one metric visible.
const on = COUNTS_METRIC_KEYS.filter(k => keys.includes(k));
if (!on.length) { renderChartWindow(); return; } // restore the checkbox we just rejected
try { localStorage.setItem(COUNTS_METRICS_KEY, JSON.stringify(on)); } catch { /* ignore */ }
render();
}
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow() {
const n = chartDays();
@@ -938,6 +1007,10 @@
document.querySelectorAll(".chart-days-picker button").forEach(b => {
b.classList.toggle("active", Number(b.dataset.days) === n);
});
const on = countsMetrics();
document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => {
cb.checked = on.includes(cb.dataset.metric);
});
}
// ---------- daily charts ----------
@@ -1094,13 +1167,19 @@
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals]));
const series = [
{ key: "pees", label: "Pees", cls: "bar-pee" },
{ key: "poos", label: "Poos", cls: "bar-poo" },
{ key: "meals", label: "Meals", cls: "bar-eat" },
].filter(s => countsMetrics().includes(s.key));
const rawMax = Math.max(0, ...days.flatMap(d => series.map(s => d[s.key])));
const { yMax, steps: ySteps } = niceAxis(rawMax);
const groupGap = days.length > 14 ? 2 : 4;
const innerBarGap = days.length > 14 ? 0.5 : 1.5;
const groupW = (innerW - (days.length - 1) * groupGap) / days.length;
const barW = (groupW - 2 * innerBarGap) / 3;
const barW = (groupW - (series.length - 1) * innerBarGap) / series.length;
const parts = [];
for (let i = 0; i <= ySteps; i++) {
@@ -1110,12 +1189,6 @@
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${v}</text>`);
}
const series = [
{ key: "pees", label: "Pees", cls: "bar-pee" },
{ key: "poos", label: "Poos", cls: "bar-poo" },
{ key: "meals", label: "Meals", cls: "bar-eat" },
];
const selYmd = ymd(selectedDay());
days.forEach((d, i) => {
const isToday = i === days.length - 1;
@@ -1948,6 +2021,7 @@
renderHourHeatmap(events);
renderTraining(events);
renderWeight(events);
renderNotes(events);
renderHistory(events);
}
@@ -2179,11 +2253,21 @@
function openNoteDialog(type) {
pendingType = type;
noteInput.value = "";
noteTimeEdited = false;
const now = Date.now();
noteDate.value = toDateInput(now);
noteTime.value = toTimeInput(now);
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
const isNote = type === "note";
// A note is about a day, so default it to the day you're viewing (at the
// current clock time). The logging types default to "now"; leaving
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
let base = Date.now();
if (isNote) {
const day = selectedDay();
const now = new Date();
day.setHours(now.getHours(), now.getMinutes(), 0, 0);
base = day.getTime();
}
noteTimeEdited = isNote;
noteDate.value = toDateInput(base);
noteTime.value = toTimeInput(base);
noteTitle.textContent = isNote ? "Add note" : `Log ${EVENT_LABELS[type]}`;
const isWeight = type === "weight";
const isEat = type === "eat";
noteWeightField.hidden = !isWeight;
@@ -2233,6 +2317,10 @@
document.getElementById("note-save").addEventListener("click", async (e) => {
e.preventDefault();
if (!pendingType) { noteDialog.close(); return; }
if (pendingType === "note" && noteInput.value.trim() === "" && notePhotos.length === 0) {
alert("Write something for the note, or add a photo.");
return;
}
let weight;
if (pendingType === "weight") {
weight = parseFloat(noteWeight.value);
@@ -3177,10 +3265,10 @@
snackbarTimer = setTimeout(hideSnackbar, 5000);
}
function quickLog(type, originEl) {
function quickLog(type) {
const ev = addEvent(type, "", Date.now());
showSnackbar(`${EVENT_LABELS[type]} logged`, ev);
if (type === "pee" || type === "poo") pottyConfetti(type, originEl);
if (type === "pee" || type === "poo") pottyConfetti(type);
}
// A little burst of 💧/💩 from the tapped button when a pee/poo is logged.
@@ -3203,28 +3291,27 @@
}
return confettiLayerEl;
}
function pottyConfetti(type, originEl) {
function pottyConfetti(type) {
if (!confettiEnabled()) return;
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const emoji = type === "poo" ? "💩" : "💧";
const layer = confettiLayer();
const r = originEl && originEl.getBoundingClientRect
? originEl.getBoundingClientRect()
: { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 };
const ox = r.left + r.width / 2, oy = r.top + r.height / 2;
for (let i = 0; i < 16; i++) {
const W = window.innerWidth, H = window.innerHeight;
for (let i = 0; i < 30; i++) {
const piece = document.createElement("span");
piece.className = "confetti-piece";
piece.textContent = emoji;
const ang = Math.random() * Math.PI * 2;
const dist = 60 + Math.random() * 130;
piece.style.left = `${ox}px`;
piece.style.top = `${oy}px`;
piece.style.setProperty("--dx", `${Math.round(Math.cos(ang) * dist)}px`);
piece.style.setProperty("--dy", `${Math.round(Math.sin(ang) * dist - 50)}px`); // bias upward
// Launch from a point across the bottom, shoot up to a random peak while
// drifting sideways, slowing and fading out as it reaches the top.
const peakY = -Math.round(H * (0.45 + Math.random() * 0.45));
piece.style.left = `${Math.round(W * (0.1 + Math.random() * 0.8))}px`;
piece.style.top = `${H + 20}px`;
piece.style.setProperty("--peakY", `${peakY}px`);
piece.style.setProperty("--dx", `${Math.round((Math.random() * 2 - 1) * W * 0.22)}px`);
piece.style.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`);
piece.style.fontSize = `${Math.round(14 + Math.random() * 16)}px`;
piece.style.animationDuration = `${Math.round(900 + Math.random() * 600)}ms`;
piece.style.fontSize = `${Math.round(16 + Math.random() * 18)}px`;
piece.style.animationDuration = `${Math.round(1100 + Math.random() * 700)}ms`;
piece.style.animationDelay = `${Math.round(Math.random() * 280)}ms`;
piece.addEventListener("animationend", () => piece.remove());
layer.appendChild(piece);
}
@@ -3286,10 +3373,10 @@
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.type;
// Weigh-ins need a typed value and meals ask for grams, so those two
// keep the full dialog.
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
quickLog(type, btn);
// Weigh-ins need a typed value, meals ask for grams, and a note is all
// free text — so these open the full dialog instead of one-tap logging.
if (type === "weight" || type === "eat" || type === "note") { openNoteDialog(type); return; }
quickLog(type);
});
});
@@ -3297,6 +3384,14 @@
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
});
document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => {
cb.addEventListener("change", () => {
const on = [...document.querySelectorAll("#counts-metrics input[data-metric]:checked")]
.map(el => el.dataset.metric);
setCountsMetrics(on);
});
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls
// past. rAF-throttled: scroll events fire far more often than we can paint.
{
+3 -1
View File
@@ -1,5 +1,7 @@
[
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off a little burst of 💧/💩 confetti from the button — a tiny celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
{ "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" },
{ "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" },
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off 💧/💩 fireworks that shoot up from the bottom of the screen — a little celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
{ "date": "2026-08-01", "text": "Tidied the header on long names and ages — the name now truncates instead of shoving the buttons, and the age reads as a compact \"16 wk · 3 mo 3 wk\"; weight-log rows are a single line again (\"Aug 1 · 16 wk\")" },
{ "date": "2026-07-26", "text": "Added a fan-chart view of the pedigree (toggle it in the header): your dog at the centre with each generation fanning outward as a ring, so many generations fit at once without the tree sprawling sideways — tap a wedge for that dog, and repeated ancestors keep their colour" },
{ "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" },
+11 -4
View File
@@ -113,6 +113,7 @@
<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>
<button class="action note" data-type="note">📝 Note</button>
</div>
</section>
@@ -203,10 +204,10 @@
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend">
<span class="lg pee"><span class="sw"></span>Pees</span>
<span class="lg poo"><span class="sw"></span>Poos</span>
<span class="lg eat"><span class="sw"></span>Meals</span>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
@@ -261,6 +262,12 @@
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</section>
<section class="notes-log" data-panel="notes">
<h2>Notes</h2>
<ul id="notes-list" class="event-list"></ul>
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
</section>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
+29 -11
View File
@@ -11,6 +11,7 @@
--poo: #8a5a3b;
--weight: #2bb3a3;
--training: #b04ecf;
--note: #6f7a90;
--danger: #d64545;
--gain: #2e9e5b;
--border: #e9e6f5;
@@ -266,6 +267,7 @@ 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.action.note { background: var(--note); }
/* Unlikely given the current sleep state (see renderActionHints) dimmed
but fully tappable, so corrections are never blocked. */
button.action.unlikely { opacity: 0.4; }
@@ -435,11 +437,16 @@ textarea { resize: vertical; }
.event[data-type="poo"] .dot { background: var(--poo); }
.event[data-type="weight"] .dot { background: var(--weight); }
.event[data-type="training"] .dot { background: var(--training); }
.event[data-type="note"] .dot { background: var(--note); }
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
.event .label { font-weight: 600; min-width: 110px; }
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
/* Notes log rows: a date instead of a time-of-day, then the note text. */
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
.event .note-text { flex: 1; white-space: pre-wrap; overflow-wrap: anywhere; }
.empty {
color: var(--muted);
text-align: center;
@@ -685,6 +692,15 @@ dialog menu {
.lg.poo .sw { background: var(--poo); }
.lg.eat .sw { background: var(--eat); }
/* Interactive legend: each item is a checkbox that toggles its metric. */
.legend-toggle label.lg { cursor: pointer; user-select: none; }
.legend-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
.legend-toggle label.pee input { accent-color: var(--pee); }
.legend-toggle label.poo input { accent-color: var(--poo); }
.legend-toggle label.eat input { accent-color: var(--eat); }
/* Dim an unchecked item so it's clear its bars are hidden. */
.legend-toggle label.lg:has(input:not(:checked)) { opacity: 0.5; }
/* ---------- auth (login / register) ---------- */
.auth-screen {
position: fixed;
@@ -1361,18 +1377,20 @@ section.collapsed > :not(h2) { display: none; }
position: absolute;
line-height: 1;
will-change: transform, opacity;
animation-name: potty-burst;
animation-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);
animation-fill-mode: forwards;
animation-name: potty-firework;
animation-timing-function: ease-out;
/* `both` so the 0% state (invisible, at the bottom) also applies during the
per-piece launch delay no flash before it takes off. */
animation-fill-mode: both;
}
@keyframes potty-burst {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(0deg); }
12% { opacity: 1; }
100% {
opacity: 0;
/* fly out to (dx, dy) then keep falling (gravity) */
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy) + 150px)) scale(1) rotate(var(--rot));
}
/* Launch up from the bottom, rise to a peak while spreading sideways, slowing
(ease-out) and fading out as it reaches the top a firework fountain.
Distances come from JS custom props. */
@keyframes potty-firework {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5) rotate(0deg); }
10% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--peakY))) scale(1) rotate(var(--rot)); }
}
@media (prefers-reduced-motion: reduce) {
.confetti-piece { display: none; }