Compare commits
6 Commits
69e312175b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9894abfc9 | |||
| 09d9d38c12 | |||
| a44c75d2d4 | |||
| e2f99590f1 | |||
| 52c50c97b1 | |||
| 1ed325b834 |
+182
-18
@@ -21,6 +21,7 @@
|
|||||||
"poo": "Poo",
|
"poo": "Poo",
|
||||||
"weight": "Weigh-in",
|
"weight": "Weigh-in",
|
||||||
"training": "Training",
|
"training": "Training",
|
||||||
|
"note": "Note",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- photos: IndexedDB store ----------
|
// ---------- photos: IndexedDB store ----------
|
||||||
@@ -291,6 +292,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.
|
||||||
@@ -876,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 ----------
|
// ---------- lightbox ----------
|
||||||
const lightbox = document.getElementById("lightbox");
|
const lightbox = document.getElementById("lightbox");
|
||||||
const lightboxImg = document.getElementById("lightbox-img");
|
const lightboxImg = document.getElementById("lightbox-img");
|
||||||
@@ -909,6 +975,27 @@
|
|||||||
render();
|
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.
|
// Sync every "(last N days)" header and the picker's active button.
|
||||||
function renderChartWindow() {
|
function renderChartWindow() {
|
||||||
const n = chartDays();
|
const n = chartDays();
|
||||||
@@ -920,6 +1007,10 @@
|
|||||||
document.querySelectorAll(".chart-days-picker button").forEach(b => {
|
document.querySelectorAll(".chart-days-picker button").forEach(b => {
|
||||||
b.classList.toggle("active", Number(b.dataset.days) === n);
|
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 ----------
|
// ---------- daily charts ----------
|
||||||
@@ -1076,13 +1167,19 @@
|
|||||||
const innerW = W - ML - MR;
|
const innerW = W - ML - MR;
|
||||||
const innerH = H - MT - MB;
|
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 { yMax, steps: ySteps } = niceAxis(rawMax);
|
||||||
|
|
||||||
const groupGap = days.length > 14 ? 2 : 4;
|
const groupGap = days.length > 14 ? 2 : 4;
|
||||||
const innerBarGap = days.length > 14 ? 0.5 : 1.5;
|
const innerBarGap = days.length > 14 ? 0.5 : 1.5;
|
||||||
const groupW = (innerW - (days.length - 1) * groupGap) / days.length;
|
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 = [];
|
const parts = [];
|
||||||
for (let i = 0; i <= ySteps; i++) {
|
for (let i = 0; i <= ySteps; i++) {
|
||||||
@@ -1092,12 +1189,6 @@
|
|||||||
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${v}</text>`);
|
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());
|
const selYmd = ymd(selectedDay());
|
||||||
days.forEach((d, i) => {
|
days.forEach((d, i) => {
|
||||||
const isToday = i === days.length - 1;
|
const isToday = i === days.length - 1;
|
||||||
@@ -1669,7 +1760,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 +1979,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
|
||||||
@@ -1930,6 +2021,7 @@
|
|||||||
renderHourHeatmap(events);
|
renderHourHeatmap(events);
|
||||||
renderTraining(events);
|
renderTraining(events);
|
||||||
renderWeight(events);
|
renderWeight(events);
|
||||||
|
renderNotes(events);
|
||||||
renderHistory(events);
|
renderHistory(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2161,11 +2253,21 @@
|
|||||||
function openNoteDialog(type) {
|
function openNoteDialog(type) {
|
||||||
pendingType = type;
|
pendingType = type;
|
||||||
noteInput.value = "";
|
noteInput.value = "";
|
||||||
noteTimeEdited = false;
|
const isNote = type === "note";
|
||||||
const now = Date.now();
|
// A note is about a day, so default it to the day you're viewing (at the
|
||||||
noteDate.value = toDateInput(now);
|
// current clock time). The logging types default to "now"; leaving
|
||||||
noteTime.value = toTimeInput(now);
|
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
|
||||||
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
|
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 isWeight = type === "weight";
|
||||||
const isEat = type === "eat";
|
const isEat = type === "eat";
|
||||||
noteWeightField.hidden = !isWeight;
|
noteWeightField.hidden = !isWeight;
|
||||||
@@ -2215,6 +2317,10 @@
|
|||||||
document.getElementById("note-save").addEventListener("click", async (e) => {
|
document.getElementById("note-save").addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!pendingType) { noteDialog.close(); return; }
|
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;
|
let weight;
|
||||||
if (pendingType === "weight") {
|
if (pendingType === "weight") {
|
||||||
weight = parseFloat(noteWeight.value);
|
weight = parseFloat(noteWeight.value);
|
||||||
@@ -2404,6 +2510,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 +2523,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 +2539,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();
|
||||||
@@ -3159,6 +3268,53 @@
|
|||||||
function quickLog(type) {
|
function quickLog(type) {
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (!confettiEnabled()) return;
|
||||||
|
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
const emoji = type === "poo" ? "💩" : "💧";
|
||||||
|
const layer = confettiLayer();
|
||||||
|
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;
|
||||||
|
// 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(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
snackbarUndo.addEventListener("click", () => {
|
snackbarUndo.addEventListener("click", () => {
|
||||||
@@ -3217,9 +3373,9 @@
|
|||||||
document.querySelectorAll("button.action").forEach(btn => {
|
document.querySelectorAll("button.action").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const type = btn.dataset.type;
|
const type = btn.dataset.type;
|
||||||
// Weigh-ins need a typed value and meals ask for grams, so those two
|
// Weigh-ins need a typed value, meals ask for grams, and a note is all
|
||||||
// keep the full dialog.
|
// free text — so these open the full dialog instead of one-tap logging.
|
||||||
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
|
if (type === "weight" || type === "eat" || type === "note") { openNoteDialog(type); return; }
|
||||||
quickLog(type);
|
quickLog(type);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3228,6 +3384,14 @@
|
|||||||
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
|
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
|
// 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.
|
// past. rAF-throttled: scroll events fire far more often than we can paint.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{ "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 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" },
|
||||||
|
|||||||
+15
-4
@@ -113,6 +113,7 @@
|
|||||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||||
|
<button class="action note" data-type="note">📝 Note</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -203,10 +204,10 @@
|
|||||||
<div class="chart">
|
<div class="chart">
|
||||||
<div class="chart-title">Daily counts</div>
|
<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>
|
<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">
|
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
|
||||||
<span class="lg pee"><span class="sw"></span>Pees</span>
|
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
|
||||||
<span class="lg poo"><span class="sw"></span>Poos</span>
|
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
|
||||||
<span class="lg eat"><span class="sw"></span>Meals</span>
|
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart" id="grams-chart-wrap" hidden>
|
<div class="chart" id="grams-chart-wrap" hidden>
|
||||||
@@ -261,6 +262,12 @@
|
|||||||
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
||||||
</section>
|
</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">
|
<section class="history" data-panel="history">
|
||||||
<h2>History</h2>
|
<h2>History</h2>
|
||||||
<ul id="event-list" class="event-list"></ul>
|
<ul id="event-list" class="event-list"></ul>
|
||||||
@@ -335,6 +342,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>
|
||||||
|
|||||||
+68
-1
@@ -11,6 +11,7 @@
|
|||||||
--poo: #8a5a3b;
|
--poo: #8a5a3b;
|
||||||
--weight: #2bb3a3;
|
--weight: #2bb3a3;
|
||||||
--training: #b04ecf;
|
--training: #b04ecf;
|
||||||
|
--note: #6f7a90;
|
||||||
--danger: #d64545;
|
--danger: #d64545;
|
||||||
--gain: #2e9e5b;
|
--gain: #2e9e5b;
|
||||||
--border: #e9e6f5;
|
--border: #e9e6f5;
|
||||||
@@ -78,13 +79,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 {
|
||||||
@@ -255,6 +267,7 @@ button.action.eat { background: var(--eat); }
|
|||||||
button.action.pee { background: var(--pee); color: #2b240a; }
|
button.action.pee { background: var(--pee); color: #2b240a; }
|
||||||
button.action.poo { background: var(--poo); }
|
button.action.poo { background: var(--poo); }
|
||||||
button.action.weight { background: var(--weight); }
|
button.action.weight { background: var(--weight); }
|
||||||
|
button.action.note { background: var(--note); }
|
||||||
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
|
/* Unlikely given the current sleep state (see renderActionHints) — dimmed
|
||||||
but fully tappable, so corrections are never blocked. */
|
but fully tappable, so corrections are never blocked. */
|
||||||
button.action.unlikely { opacity: 0.4; }
|
button.action.unlikely { opacity: 0.4; }
|
||||||
@@ -424,11 +437,16 @@ textarea { resize: vertical; }
|
|||||||
.event[data-type="poo"] .dot { background: var(--poo); }
|
.event[data-type="poo"] .dot { background: var(--poo); }
|
||||||
.event[data-type="weight"] .dot { background: var(--weight); }
|
.event[data-type="weight"] .dot { background: var(--weight); }
|
||||||
.event[data-type="training"] .dot { background: var(--training); }
|
.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 .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
|
||||||
.event .label { font-weight: 600; min-width: 110px; }
|
.event .label { font-weight: 600; min-width: 110px; }
|
||||||
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
|
.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 {
|
.empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -644,7 +662,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;
|
||||||
@@ -665,6 +692,15 @@ dialog menu {
|
|||||||
.lg.poo .sw { background: var(--poo); }
|
.lg.poo .sw { background: var(--poo); }
|
||||||
.lg.eat .sw { background: var(--eat); }
|
.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 (login / register) ---------- */
|
||||||
.auth-screen {
|
.auth-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1328,3 +1364,34 @@ 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-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;
|
||||||
|
}
|
||||||
|
/* 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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user