diff --git a/src/app.js b/src/app.js index 9242346..db1ffd7 100644 --- a/src/app.js +++ b/src/app.js @@ -2422,6 +2422,7 @@ const settingsBirthday = document.getElementById("settings-birthday"); const settingsPedigree = document.getElementById("settings-pedigree"); const settingsTheme = document.getElementById("settings-theme"); + const settingsConfetti = document.getElementById("settings-confetti"); // Apply live so the toggle previews immediately (independent of Save/Cancel). settingsTheme.addEventListener("change", () => { @@ -2434,6 +2435,7 @@ settingsBirthday.value = cfg.birthday; settingsPedigree.value = cfg.pedigreeId; settingsTheme.checked = effectiveTheme() === "dark"; + settingsConfetti.checked = confettiEnabled(); settingsDialog.showModal(); setTimeout(() => settingsName.focus(), 50); } @@ -2449,6 +2451,7 @@ updatedAt: Date.now(), }; saveConfig(cfg); // cache locally for instant + offline paint + setConfettiEnabled(settingsConfetti.checked); // device-local, not synced renderHeader(); refreshPedigreeButton(); settingsDialog.close(); @@ -3174,9 +3177,57 @@ snackbarTimer = setTimeout(hideSnackbar, 5000); } - function quickLog(type) { + function quickLog(type, originEl) { const ev = addEvent(type, "", Date.now()); showSnackbar(`${EVENT_LABELS[type]} logged`, ev); + if (type === "pee" || type === "poo") pottyConfetti(type, originEl); + } + + // A little burst of ๐Ÿ’ง/๐Ÿ’ฉ from the tapped button when a pee/poo is logged. + // Pure DOM + CSS; particles remove themselves when their animation ends. + // Skipped when disabled in Settings or the user prefers reduced motion. + // Device-local preference (like the theme), on by default. + const CONFETTI_KEY = "puppy-tracker:confetti:v1"; + function confettiEnabled() { + try { return localStorage.getItem(CONFETTI_KEY) !== "off"; } catch { return true; } + } + function setConfettiEnabled(on) { + try { localStorage.setItem(CONFETTI_KEY, on ? "on" : "off"); } catch { /* ignore */ } + } + let confettiLayerEl = null; + function confettiLayer() { + if (!confettiLayerEl) { + confettiLayerEl = document.createElement("div"); + confettiLayerEl.id = "confetti-layer"; + document.body.appendChild(confettiLayerEl); + } + return confettiLayerEl; + } + function pottyConfetti(type, originEl) { + 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 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 + 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.addEventListener("animationend", () => piece.remove()); + layer.appendChild(piece); + } } snackbarUndo.addEventListener("click", () => { @@ -3238,7 +3289,7 @@ // 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); + quickLog(type, btn); }); }); diff --git a/src/changelog.json b/src/changelog.json index 6c68b13..2ca2318 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "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-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" }, diff --git a/src/index.html b/src/index.html index ee46acf..005f2ce 100644 --- a/src/index.html +++ b/src/index.html @@ -335,6 +335,10 @@ Dark mode + diff --git a/src/style.css b/src/style.css index 1544d90..b41bfcc 100644 --- a/src/style.css +++ b/src/style.css @@ -1348,3 +1348,32 @@ section.collapsed > :not(h2) { display: none; } color: var(--muted); text-align: center; } + +/* ---- pee/poo confetti ---- */ +#confetti-layer { + position: fixed; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 9999; +} +.confetti-piece { + 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; +} +@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)); + } +} +@media (prefers-reduced-motion: reduce) { + .confetti-piece { display: none; } +}