Celebrate a logged pee/poo with emoji confetti

Tapping the pee or poo quick-log button now sets off a short burst of 💧/💩
particles from the button — pure DOM + CSS, pointer-events:none so it never
blocks taps, particles self-remove on animation end. It honours
prefers-reduced-motion, and there's a "Pee/poo confetti" switch in Settings
(device-local, on by default) to turn it off.
This commit is contained in:
Alexander Heldt
2026-08-01 18:18:26 +00:00
parent 1ed325b834
commit 52c50c97b1
4 changed files with 87 additions and 2 deletions
+53 -2
View File
@@ -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);
});
});
+1
View File
@@ -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" },
+4
View File
@@ -335,6 +335,10 @@
<span>Dark mode</span>
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
</label>
<label class="toggle-row">
<span>Pee/poo confetti 💩</span>
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
</label>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
+29
View File
@@ -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; }
}