Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52c50c97b1 | |||
| 1ed325b834 |
+73
-4
@@ -291,6 +291,24 @@
|
|||||||
return `${wk} (${monthPhrase}) old`;
|
return `${wk} (${monthPhrase}) old`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact one-line age for space-tight spots (header): keeps the weeks + months
|
||||||
|
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
|
||||||
|
function formatAgeShort(birthday, at) {
|
||||||
|
const a = ageParts(birthday, at);
|
||||||
|
if (!a) return "";
|
||||||
|
const wk = `${a.weeks} wk`;
|
||||||
|
if (a.months < 1) return wk;
|
||||||
|
const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`;
|
||||||
|
if (a.months >= 4) return mo;
|
||||||
|
return `${wk} · ${mo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weeks-only age for dense lists (weight rows): "16 wk".
|
||||||
|
function formatAgeWeeks(birthday, at) {
|
||||||
|
const a = ageParts(birthday, at);
|
||||||
|
return a ? `${a.weeks} wk` : "";
|
||||||
|
}
|
||||||
|
|
||||||
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
|
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
|
||||||
// 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to
|
// 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to
|
||||||
// 12 months. No birthday (or an adult dog) → no goal.
|
// 12 months. No birthday (or an adult dog) → no goal.
|
||||||
@@ -1669,7 +1687,7 @@
|
|||||||
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";
|
||||||
const age = formatAge(birthday, w.at);
|
const age = formatAgeWeeks(birthday, w.at);
|
||||||
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
|
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
|
||||||
const val = document.createElement("span");
|
const val = document.createElement("span");
|
||||||
@@ -1888,7 +1906,7 @@
|
|||||||
const ageEl = document.getElementById("puppy-age");
|
const ageEl = document.getElementById("puppy-age");
|
||||||
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker";
|
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker";
|
||||||
document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker";
|
document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker";
|
||||||
const ageText = formatAge(cfg.birthday);
|
const ageText = formatAgeShort(cfg.birthday);
|
||||||
ageEl.textContent = ageText;
|
ageEl.textContent = ageText;
|
||||||
ageEl.hidden = !ageText;
|
ageEl.hidden = !ageText;
|
||||||
// Use the puppy's name in the sleep-timeline heading rather than assuming a
|
// Use the puppy's name in the sleep-timeline heading rather than assuming a
|
||||||
@@ -2404,6 +2422,7 @@
|
|||||||
const settingsBirthday = document.getElementById("settings-birthday");
|
const settingsBirthday = document.getElementById("settings-birthday");
|
||||||
const settingsPedigree = document.getElementById("settings-pedigree");
|
const settingsPedigree = document.getElementById("settings-pedigree");
|
||||||
const settingsTheme = document.getElementById("settings-theme");
|
const settingsTheme = document.getElementById("settings-theme");
|
||||||
|
const settingsConfetti = document.getElementById("settings-confetti");
|
||||||
|
|
||||||
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
||||||
settingsTheme.addEventListener("change", () => {
|
settingsTheme.addEventListener("change", () => {
|
||||||
@@ -2416,6 +2435,7 @@
|
|||||||
settingsBirthday.value = cfg.birthday;
|
settingsBirthday.value = cfg.birthday;
|
||||||
settingsPedigree.value = cfg.pedigreeId;
|
settingsPedigree.value = cfg.pedigreeId;
|
||||||
settingsTheme.checked = effectiveTheme() === "dark";
|
settingsTheme.checked = effectiveTheme() === "dark";
|
||||||
|
settingsConfetti.checked = confettiEnabled();
|
||||||
settingsDialog.showModal();
|
settingsDialog.showModal();
|
||||||
setTimeout(() => settingsName.focus(), 50);
|
setTimeout(() => settingsName.focus(), 50);
|
||||||
}
|
}
|
||||||
@@ -2431,6 +2451,7 @@
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
saveConfig(cfg); // cache locally for instant + offline paint
|
saveConfig(cfg); // cache locally for instant + offline paint
|
||||||
|
setConfettiEnabled(settingsConfetti.checked); // device-local, not synced
|
||||||
renderHeader();
|
renderHeader();
|
||||||
refreshPedigreeButton();
|
refreshPedigreeButton();
|
||||||
settingsDialog.close();
|
settingsDialog.close();
|
||||||
@@ -3156,9 +3177,57 @@
|
|||||||
snackbarTimer = setTimeout(hideSnackbar, 5000);
|
snackbarTimer = setTimeout(hideSnackbar, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickLog(type) {
|
function quickLog(type, originEl) {
|
||||||
const ev = addEvent(type, "", Date.now());
|
const ev = addEvent(type, "", Date.now());
|
||||||
showSnackbar(`${EVENT_LABELS[type]} logged`, ev);
|
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", () => {
|
snackbarUndo.addEventListener("click", () => {
|
||||||
@@ -3220,7 +3289,7 @@
|
|||||||
// Weigh-ins need a typed value and meals ask for grams, so those two
|
// Weigh-ins need a typed value and meals ask for grams, so those two
|
||||||
// keep the full dialog.
|
// keep the full dialog.
|
||||||
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
|
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
|
||||||
quickLog(type);
|
quickLog(type, btn);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
[
|
[
|
||||||
|
{ "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 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" },
|
{ "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" },
|
||||||
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/− buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
|
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/− buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
|
||||||
|
|||||||
@@ -335,6 +335,10 @@
|
|||||||
<span>Dark mode</span>
|
<span>Dark mode</span>
|
||||||
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>Pee/poo confetti 💩</span>
|
||||||
|
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
|
||||||
|
</label>
|
||||||
<menu>
|
<menu>
|
||||||
<button value="cancel" class="ghost">Cancel</button>
|
<button value="cancel" class="ghost">Cancel</button>
|
||||||
<button value="save" id="settings-save">Save</button>
|
<button value="save" id="settings-save">Save</button>
|
||||||
|
|||||||
+50
-1
@@ -78,13 +78,24 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* Truncate the name so a long one never runs into the action buttons. Scoped to
|
||||||
|
the header title so the auth-screen heading is unaffected. */
|
||||||
|
#app-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.puppy-age {
|
.puppy-age {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
/* One line in the common case; on a very narrow screen it wraps rather than
|
||||||
|
truncating, so the age is never cut off. The name (#app-title) is what
|
||||||
|
truncates to keep the buttons clear. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -644,7 +655,16 @@ dialog menu {
|
|||||||
.weight-summary .stat-value.up { color: var(--gain); }
|
.weight-summary .stat-value.up { color: var(--gain); }
|
||||||
.weight-summary .stat-value.down { color: var(--danger); }
|
.weight-summary .stat-value.down { color: var(--danger); }
|
||||||
.ww.weight-ww { cursor: pointer; }
|
.ww.weight-ww { cursor: pointer; }
|
||||||
.ww.weight-ww .ww-dur { text-align: right; }
|
/* Keep each weight row to one line: the date/age column shrinks and ellipses,
|
||||||
|
the weight value stays a fixed size, right-aligned and always visible. */
|
||||||
|
.ww.weight-ww .ww-range {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ww.weight-ww .ww-dur { flex: 0 0 auto; text-align: right; }
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1328,3 +1348,32 @@ section.collapsed > :not(h2) { display: none; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user