Compare commits

...

12 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
Alexander Heldt 52c50c97b1 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.
2026-08-01 18:18:26 +00:00
Alexander Heldt 1ed325b834 Keep the header and weight rows tidy when the age is long
The age counter can read "16 weeks (3 months and 3 weeks) old", which broke
two tight layouts: the header title never truncated, so a long name (or the
wide status pill) collided with the action buttons and the age wrapped to
three lines; and each weight-log row embedded the full age, wrapping to two
lines.

Add compact age formatters and use them where space is tight: formatAgeShort
("16 wk · 3 mo 3 wk") in the header, formatAgeWeeks ("16 wk") in weight rows;
the verbose form stays on the roomy weight-chart caption. Make the header
robust — the title flexes and the name ellipsis-truncates so the buttons are
never pushed, while the age wraps rather than truncating so it's never cut
off. Weight rows keep the date/age on one line with the value pinned right.
2026-08-01 18:11:03 +00:00
Alexander Heldt 69e312175b Add a radial fan-chart view of the pedigree
Add a Fan / Tree toggle in the pedigree header. The fan places the dog in a
centre disc with each generation as a ring fanning outward, so up to nine
generations fit in one compact circle instead of a tree that doubles in
width every generation. Inner rings are labelled; tap any wedge for that
dog's details. Pedigree-collapse dogs keep their stable hue, and tapping one
lights up every wedge of that dog. The existing zoom applies to the fan too.

Render dispatch is factored into renderPedigree(); the collapse-highlight
now works on any element (tree card or fan wedge) carrying a data-dogkey.
2026-07-26 11:13:16 +00:00
Alexander Heldt a2c9aa9716 Add a collapse/expand-all toggle to the pedigree
A header button folds the whole tree down to just the dog or opens every
branch at once, alongside the per-dog toggles. Grouped with the zoom
controls, which the header now wraps on narrow screens.
2026-07-26 10:50:12 +00:00
Alexander Heldt 12a5bd0548 Make the pedigree tree zoomable
Add zoom to the pedigree: −/reset/+ buttons in the header, ctrl/⌘ + wheel,
and two-finger pinch. Scaling uses the CSS `zoom` property (not transform)
so the container reflows and every part stays reachable by scrolling at any
level. Clamped to 40–160% and the level is remembered per device.
2026-07-26 10:49:54 +00:00
Alexander Heldt 01d64b682b Flag pedigree collapse with repeat badges
When a dog fills more than one position in the pedigree (pedigree collapse),
mark each copy with a ×N badge and a stable per-dog colour, and let tapping
any copy highlight every place that dog appears. Identity is the registration
number (falling back to name), so the same ancestor at different positions is
treated as one. The summary line also reports how many ancestors recur.
2026-07-26 10:39:51 +00:00
Alexander Heldt 8157e95066 Render the pedigree top-down as a family tree
Replace the indented ancestry list with a top-down chart: the dog on top,
sire (left) and dam (right) branching below, connected by lines drawn with
each node's CSS ::before/::after (a nested <ul>/<li> tree). It shows three
generations by default and each dog expands to trace the line further back;
deeper levels get wide, so the view scrolls horizontally.
2026-07-26 10:39:28 +00:00
Alexander Heldt 86e51bb851 Make the pedigree id sticky across profile sync
The dog id in the synced profile shares one last-write-wins timestamp with
name and birthday, and that timestamp comes from each device's own clock.
A device whose profile was stamped later (clock skew, or a more recent
name/birthday edit) would refuse to adopt a newer server profile that had
just gained an id, or even push its empty id back over the stored one — so
the pedigree id set on one device never reached the others.

Make the id sticky on both sides of sync: an empty value never clears a set
one, and a device/server adopts an id it is missing regardless of the blob
timestamp. When both sides have an id, the newer profile still wins, so the
dog can still be changed. Name and birthday keep plain last-write-wins.
2026-07-26 10:19:01 +00:00
5 changed files with 802 additions and 80 deletions
+16 -3
View File
@@ -92,19 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer.
// Name/birthday/updated are last-write-wins: the incoming row replaces the
// stored one only when strictly newer. The pedigree id is stickier — an empty
// incoming value never clears a stored one, so a clock race between devices
// can't drop it; when both are set, the newer profile's id wins with the rest.
_, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday,
pedigree_id = excluded.pedigree_id, updated = excluded.updated
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
updated = excluded.updated
WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
if err != nil {
return Config{}, err
}
// Adopt a pedigree id the server is missing even from an older-stamped profile,
// so a device that set it isn't blocked by another device's newer name/birthday
// edit. (A set id is only ever changed by a newer profile that also sets one.)
if in.PedigreeID != "" {
if _, err := cs.db.Exec(
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
in.PedigreeID, userID); err != nil {
return Config{}, err
}
}
return cs.get(userID), nil
}
+513 -44
View File
@@ -21,6 +21,7 @@
"poo": "Poo",
"weight": "Weigh-in",
"training": "Training",
"note": "Note",
};
// ---------- photos: IndexedDB store ----------
@@ -230,6 +231,28 @@
localStorage.setItem(configKey(), JSON.stringify(cfg));
}
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
// never loses to an empty one — so it can't be dropped by a clock race between
// devices — and when both are set the newer profile's id wins with the rest.
// (The server merge mirrors this, so a set id is only changed, never cleared,
// by sync.)
function reconcileConfig(local, server) {
const base = server.updatedAt >= local.updatedAt ? server : local;
return {
name: base.name,
birthday: base.birthday,
pedigreeId: (local.pedigreeId && server.pedigreeId)
? base.pedigreeId
: (local.pedigreeId || server.pedigreeId),
updatedAt: base.updatedAt,
};
}
function sameConfig(a, b) {
return a.name === b.name && a.birthday === b.birthday
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
// at that point). Returns null for a missing/invalid birthday or a date before it.
@@ -269,6 +292,24 @@
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:
// 08 weeks 2022h, 816 weeks 1820h, then 1618h to 6 months and 1416h to
// 12 months. No birthday (or an adult dog) → no goal.
@@ -854,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");
@@ -887,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();
@@ -898,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 ----------
@@ -1054,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++) {
@@ -1070,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;
@@ -1647,7 +1760,7 @@
li.className = "ww weight-ww";
const date = document.createElement("span");
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" });
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
const val = document.createElement("span");
@@ -1866,7 +1979,7 @@
const ageEl = document.getElementById("puppy-age");
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 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.hidden = !ageText;
// Use the puppy's name in the sleep-timeline heading rather than assuming a
@@ -1908,6 +2021,7 @@
renderHourHeatmap(events);
renderTraining(events);
renderWeight(events);
renderNotes(events);
renderHistory(events);
}
@@ -2040,12 +2154,14 @@
pedigreeId: body.pedigreeId || "",
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
};
if (server.updatedAt > local.updatedAt) {
saveConfig(server);
const merged = reconcileConfig(local, server);
if (!sameConfig(merged, local)) {
saveConfig(merged);
renderHeader();
refreshPedigreeButton();
} else if (local.updatedAt > server.updatedAt) {
await pushConfig(local);
}
if (!sameConfig(merged, server)) {
await pushConfig(merged);
}
} catch (err) {
console.warn("config sync failed:", err);
@@ -2137,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;
@@ -2191,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);
@@ -2380,6 +2510,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", () => {
@@ -2392,6 +2523,7 @@
settingsBirthday.value = cfg.birthday;
settingsPedigree.value = cfg.pedigreeId;
settingsTheme.checked = effectiveTheme() === "dark";
settingsConfetti.checked = confettiEnabled();
settingsDialog.showModal();
setTimeout(() => settingsName.focus(), 50);
}
@@ -2407,6 +2539,7 @@
updatedAt: Date.now(),
};
saveConfig(cfg); // cache locally for instant + offline paint
setConfettiEnabled(settingsConfetti.checked); // device-local, not synced
renderHeader();
refreshPedigreeButton();
settingsDialog.close();
@@ -2434,11 +2567,89 @@
const pedTree = document.getElementById("pedigree-tree");
const pedBtn = document.getElementById("pedigree-btn");
const pedRefresh = document.getElementById("pedigree-refresh");
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
const PED_OPEN_DEPTH = 4; // generations shown expanded by default; deeper collapse
const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
let pedPollTimer = null;
let pedNodes = {}; // latest ancestry map, for the progress count
// ---- zoom ----
// The tree gets very wide when expanded, so it's zoomable: buttons, ctrl/⌘ +
// wheel, and pinch. We scale via the CSS `zoom` property (not transform) so the
// scroll container reflows and every part stays reachable. The level persists.
const pedZoomOut = document.getElementById("ped-zoom-out");
const pedZoomIn = document.getElementById("ped-zoom-in");
const pedZoomReset = document.getElementById("ped-zoom-reset");
const PED_ZOOM_MIN = 0.4, PED_ZOOM_MAX = 1.6;
const pedZoomKey = () => `puppy-tracker:${currentUser.id}:pedigree-zoom:v1`;
let pedZoom = 1;
function applyPedZoom() {
pedZoom = Math.min(PED_ZOOM_MAX, Math.max(PED_ZOOM_MIN, Math.round(pedZoom * 100) / 100));
pedTree.style.zoom = pedZoom;
pedZoomReset.textContent = Math.round(pedZoom * 100) + "%";
try { localStorage.setItem(pedZoomKey(), String(pedZoom)); } catch { /* ignore */ }
}
function pedZoomBy(delta) { pedZoom += delta; applyPedZoom(); }
pedZoomIn.addEventListener("click", () => pedZoomBy(0.2));
pedZoomOut.addEventListener("click", () => pedZoomBy(-0.2));
pedZoomReset.addEventListener("click", () => { pedZoom = 1; applyPedZoom(); });
// ---- collapse / expand all ----
const pedFoldAll = document.getElementById("ped-foldall");
function pedSetAll(collapsed) {
pedTree.querySelectorAll("li").forEach((li) => {
if (!li.querySelector(":scope > ul")) return; // no ancestors to fold
li.classList.toggle("collapsed", collapsed);
const t = li.querySelector(":scope > .ped-card > .ped-toggle");
if (t) t.textContent = collapsed ? "+" : "";
});
pedFoldAll.textContent = collapsed ? "Expand all" : "Collapse all";
}
pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C"));
// ---- view toggle: top-down tree vs radial fan ----
const pedViewBtn = document.getElementById("ped-view");
const pedCaption = document.getElementById("pedigree-caption");
const pedViewKey = () => `puppy-tracker:${currentUser.id}:pedigree-view:v1`;
let pedView = "tree";
try { const v = localStorage.getItem(pedViewKey()); if (v === "fan" || v === "tree") pedView = v; } catch { /* ignore */ }
// The fan has no per-branch folding, so hide that control in fan mode; the
// toggle always offers the *other* view.
function updatePedViewControls() {
pedViewBtn.textContent = pedView === "fan" ? "Tree view" : "Fan view";
pedFoldAll.hidden = pedView === "fan";
if (pedView !== "fan" && pedCaption) { pedCaption.hidden = true; }
}
pedViewBtn.addEventListener("click", () => {
pedView = pedView === "fan" ? "tree" : "fan";
try { localStorage.setItem(pedViewKey(), pedView); } catch { /* ignore */ }
renderPedigree(pedNodes);
});
// Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
pedTree.addEventListener("wheel", (e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
pedZoomBy(e.deltaY < 0 ? 0.1 : -0.1);
}, { passive: false });
// Touch: two-finger pinch.
let pinchDist = 0, pinchZoom = 1;
const touchDist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
pedTree.addEventListener("touchstart", (e) => {
if (e.touches.length === 2) { pinchDist = touchDist(e.touches); pinchZoom = pedZoom; }
}, { passive: true });
pedTree.addEventListener("touchmove", (e) => {
if (e.touches.length === 2 && pinchDist > 0) {
e.preventDefault();
pedZoom = pinchZoom * (touchDist(e.touches) / pinchDist);
applyPedZoom();
}
}, { passive: false });
pedTree.addEventListener("touchend", (e) => { if (e.touches.length < 2) pinchDist = 0; });
// The looked-up tree is cached locally per dog id, so reopening the page paints
// instantly and still shows the last-known tree offline. The server caches it
// too (per dog, permanently); this is just the client-side mirror.
@@ -2459,6 +2670,9 @@
const id = loadConfig().pedigreeId;
appEl.hidden = true;
pedScreen.hidden = false;
const saved = parseFloat(localStorage.getItem(pedZoomKey()));
pedZoom = Number.isFinite(saved) ? saved : 1;
applyPedZoom();
if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; }
lookupPedigree(id);
}
@@ -2489,7 +2703,7 @@
const cached = loadPedCache(q);
if (cached && cached.nodes) {
renderSubject(cached.subject);
renderTree(cached.nodes);
renderPedigree(cached.nodes);
}
if (!navigator.onLine) {
setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.",
@@ -2522,7 +2736,7 @@
const data = await res.json();
if (data.status === "choose") { renderChoose(data.matches || []); return; }
renderSubject(data.subject);
renderTree(data.nodes || {});
renderPedigree(data.nodes || {});
if (data.status === "done") {
savePedCache(q, data.subject, data.nodes || {});
setPedDone();
@@ -2541,7 +2755,7 @@
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
const data = await res.json();
renderTree(data.nodes || {});
renderPedigree(data.nodes || {});
if (data.status === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; }
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
@@ -2556,20 +2770,24 @@
// fills many positions); "generations back" is the depth of the deepest
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
function pedCounts() {
const seen = new Set();
const counts = new Map();
let maxPos = 1;
for (const k in pedNodes) {
const n = pedNodes[k];
const key = n.reg || n.name;
if (key) seen.add(key);
if (key) counts.set(key, (counts.get(key) || 0) + 1);
const p = Number(k);
if (p > maxPos) maxPos = p;
}
return { distinct: seen.size, gens: Math.floor(Math.log2(maxPos)) };
let repeated = 0;
counts.forEach((c) => { if (c > 1) repeated++; });
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
}
function pedCountText() {
const { distinct: a, gens: g } = pedCounts();
return `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
const { distinct: a, repeated: r, gens: g } = pedCounts();
let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
if (r > 0) s += `, ${r} appearing more than once`;
return s;
}
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
function setPedStatus(text, kind) {
@@ -2622,12 +2840,192 @@
}
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
// 2n+1. We build recursively (sire above dam) and collapse below PED_OPEN_DEPTH.
function renderTree(nodes) {
// 2n+1. We render it top-down like a family tree — the dog on top, parents
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
// A dog's identity for spotting pedigree collapse: its registration number,
// or its name when it has none. Ancestors sharing a key are the same dog.
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
// Stable hue per repeated dog so its badge/highlight colour is consistent
// everywhere it appears.
function hueFor(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
// Render the ancestry in whichever view is active (top-down tree or radial
// fan). Shared prep — cache the nodes and count pedigree collapse — lives here.
function renderPedigree(nodes) {
pedNodes = nodes;
pedRepeat = {};
for (const k in nodes) {
const id = dogKey(nodes[k]);
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
}
if (pedRepeatNote) pedRepeatNote.hidden = !Object.values(pedRepeat).some((c) => c > 1);
pedTree.textContent = "";
if (pedView === "fan") renderFan(nodes); else renderTree(nodes);
updatePedViewControls();
}
function renderTree(nodes) {
const root = buildPedNode(nodes, 1, 0);
if (root) pedTree.append(root);
if (!root) return;
const ul = document.createElement("ul");
ul.className = "ped-tree-h";
ul.append(root);
pedTree.append(ul);
if (pedFoldAll) pedFoldAll.textContent = "Collapse all"; // fresh tree starts partly open
}
// ---- radial fan chart ----
// The dog sits in a centre disc; each generation is a ring fanning outward.
// A position p is at generation g = floor(log2 p); within that ring it takes
// the wedge (idx=p-2^g) of 2^g equal slices, which nests each dog's parents
// directly outside it. Labels only fit on the inner rings; deeper wedges are
// colour only, with details on tap. Repeated dogs (pedigree collapse) carry
// their stable hue so tapping one lights up every wedge of that dog.
const FAN_MAX_GEN = 9;
const SVGNS = "http://www.w3.org/2000/svg";
const fanPolar = (r, deg) => {
const a = (deg - 90) * Math.PI / 180;
return [r * Math.cos(a), r * Math.sin(a)];
};
const fanNum = (n) => Math.round(n * 100) / 100;
function renderFan(nodes) {
let maxGen = 0;
for (const k in nodes) { const g = Math.floor(Math.log2(Number(k))); if (g > maxGen) maxGen = g; }
maxGen = Math.min(maxGen, FAN_MAX_GEN);
const r0 = 46;
const radii = [r0];
for (let g = 1; g <= maxGen; g++) radii[g] = radii[g - 1] + Math.max(24, 50 - g * 3);
const R = radii[maxGen] || r0;
const pad = 4;
const box = (R + pad) * 2;
const svg = document.createElementNS(SVGNS, "svg");
svg.setAttribute("class", "ped-fan");
svg.setAttribute("viewBox", `${-R - pad} ${-R - pad} ${box} ${box}`);
svg.setAttribute("width", box);
svg.setAttribute("height", box);
for (let g = 1; g <= maxGen; g++) {
const count = 2 ** g, degPer = 360 / count, ri = radii[g - 1], ro = radii[g];
for (let idx = 0; idx < count; idx++) {
const n = nodes[String(count + idx)];
if (!n) continue;
const a0 = idx * degPer, a1 = a0 + degPer, large = (a1 - a0) > 180 ? 1 : 0;
const [x1, y1] = fanPolar(ri, a0), [x2, y2] = fanPolar(ro, a0);
const [x3, y3] = fanPolar(ro, a1), [x4, y4] = fanPolar(ri, a1);
const path = document.createElementNS(SVGNS, "path");
path.setAttribute("d",
`M${fanNum(x2)} ${fanNum(y2)}A${fanNum(ro)} ${fanNum(ro)} 0 ${large} 1 ${fanNum(x3)} ${fanNum(y3)}` +
`L${fanNum(x4)} ${fanNum(y4)}A${fanNum(ri)} ${fanNum(ri)} 0 ${large} 0 ${fanNum(x1)} ${fanNum(y1)}Z`);
path.setAttribute("class", "ped-wedge");
path.style.setProperty("--gen", g);
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
path.dataset.dogkey = key;
path.style.setProperty("--repeat-hue", hueFor(key));
path.classList.add("ped-wedge-repeat");
}
const title = document.createElementNS(SVGNS, "title");
title.textContent = fanTitle(n, key);
path.append(title);
path.addEventListener("click", () => selectFan(count + idx));
svg.append(path);
if (degPer >= 20) fanLabel(svg, n, (a0 + a1) / 2, ri, ro, degPer);
}
}
// centre disc = the dog
const c = document.createElementNS(SVGNS, "circle");
c.setAttribute("r", r0);
c.setAttribute("class", "ped-fan-center");
c.addEventListener("click", () => selectFan(1));
svg.append(c);
fanCenterLabel(svg, nodes["1"], r0);
pedTree.append(svg);
if (pedCaption) {
pedCaption.hidden = false;
pedCaption.textContent = "Tap a wedge for its dog. Zoom to read the outer rings.";
}
}
function fanTitle(n, key) {
let t = n.name || "(unnamed)";
if (n.reg) t += " — " + n.reg;
if (key && pedRepeat[key] > 1) t += " (×" + pedRepeat[key] + ")";
return t;
}
function fanLabel(svg, n, midA, ri, ro, degPer) {
let rot = midA - 90;
if (rot > 90 && rot < 270) rot -= 180; // keep upright
const [px, py] = fanPolar((ri + ro) / 2, midA);
const t = document.createElementNS(SVGNS, "text");
t.setAttribute("class", "ped-wedge-label");
t.setAttribute("transform", `translate(${fanNum(px)} ${fanNum(py)}) rotate(${fanNum(rot)})`);
const room = Math.floor((ro - ri) / 6.2); // chars that fit along the ring
t.textContent = fanTrunc(n.name || (n.reg || "?"), Math.max(6, room));
svg.append(t);
}
function fanCenterLabel(svg, n, r0) {
if (!n) return;
const words = (n.name || "Dog").split(" ");
const lines = [];
let line = "";
for (const w of words) {
if ((line + " " + w).trim().length > 12) { if (line) lines.push(line); line = w; }
else line = (line ? line + " " : "") + w;
}
if (line) lines.push(line);
const shown = lines.slice(0, 3);
const t = document.createElementNS(SVGNS, "text");
t.setAttribute("class", "ped-fan-center-label");
t.setAttribute("text-anchor", "middle");
const lh = 12, y0 = -(shown.length - 1) * lh / 2;
shown.forEach((ln, i) => {
const ts = document.createElementNS(SVGNS, "tspan");
ts.setAttribute("x", "0");
ts.setAttribute("y", fanNum(y0 + i * lh));
ts.textContent = ln;
t.append(ts);
});
svg.append(t);
}
function fanTrunc(s, max) { return s.length > max ? s.slice(0, max - 1) + "…" : s; }
function selectFan(pos) {
const n = pedNodes[String(pos)];
if (!n || !pedCaption) return;
const key = dogKey(n);
const bits = [];
if (n.reg) bits.push(n.reg);
if (n.titles) bits.push(n.titles);
if (key && pedRepeat[key] > 1) bits.push("appears ×" + pedRepeat[key]);
pedCaption.hidden = false;
pedCaption.textContent = (n.name || "(unnamed)") + (bits.length ? " — " + bits.join(" · ") : "");
togglePedHighlight(key && pedRepeat[key] > 1 ? key : ""); // clears if not a repeat
}
// Highlight (or unhighlight) every element (tree card or fan wedge) that is the
// same dog as `key`.
function togglePedHighlight(key) {
const els = pedTree.querySelectorAll("[data-dogkey]");
let lit = false;
els.forEach((c) => {
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
});
els.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) els.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
}
function buildPedNode(nodes, pos, depth) {
@@ -2636,8 +3034,7 @@
const hasDam = !!nodes[String(pos * 2 + 1)];
if (!n && !hasSire && !hasDam) return null;
const wrap = document.createElement("div");
wrap.className = "ped-node";
const li = document.createElement("li");
const card = document.createElement("div");
card.className = "ped-card";
@@ -2659,31 +3056,48 @@
card.append(r);
}
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
// shows how many times, a stable colour ties the copies together, and tapping
// the card lights up every place this dog appears.
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
card.classList.add("ped-repeat");
card.dataset.dogkey = key;
card.style.setProperty("--repeat-hue", hueFor(key));
const badge = document.createElement("span");
badge.className = "ped-repeat-badge";
badge.textContent = "×" + pedRepeat[key];
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
card.append(badge);
card.addEventListener("click", (e) => {
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
togglePedHighlight(key);
});
}
li.append(card);
if (hasSire || hasDam) {
const kids = document.createElement("div");
kids.className = "ped-children";
const kids = document.createElement("ul");
const s = buildPedNode(nodes, pos * 2, depth + 1);
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
if (s) { s.classList.add("ped-sire"); kids.append(s); }
if (d) { d.classList.add("ped-dam"); kids.append(d); }
const collapsed = depth >= PED_OPEN_DEPTH;
if (collapsed) wrap.classList.add("collapsed");
if (collapsed) li.classList.add("collapsed");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ped-toggle";
toggle.setAttribute("aria-label", "Toggle ancestors");
toggle.textContent = collapsed ? "+" : "";
toggle.addEventListener("click", () => {
const nowCollapsed = wrap.classList.toggle("collapsed");
const nowCollapsed = li.classList.toggle("collapsed");
toggle.textContent = nowCollapsed ? "+" : "";
});
card.prepend(toggle);
wrap.append(card, kids);
} else {
wrap.append(card);
li.append(kids);
}
return wrap;
return li;
}
// ---------- changelog dialog ----------
@@ -2854,6 +3268,53 @@
function quickLog(type) {
const ev = addEvent(type, "", Date.now());
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", () => {
@@ -2912,9 +3373,9 @@
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; }
// 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);
});
});
@@ -2923,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.
{
+10
View File
@@ -1,4 +1,14 @@
[
{ "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" },
{ "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": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
+26 -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>
@@ -280,6 +287,15 @@
<header class="pedigree-header">
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back"></button>
<h1>🌳 Pedigree</h1>
<div class="ped-controls">
<button type="button" id="ped-view" class="ghost">Fan view</button>
<button type="button" id="ped-foldall" class="ghost">Collapse all</button>
<div class="ped-zoom" role="group" aria-label="Zoom">
<button type="button" id="ped-zoom-out" class="ghost icon-btn" aria-label="Zoom out" title="Zoom out"></button>
<button type="button" id="ped-zoom-reset" class="ghost" aria-label="Reset zoom" title="Reset zoom">100%</button>
<button type="button" id="ped-zoom-in" class="ghost icon-btn" aria-label="Zoom in" title="Zoom in">+</button>
</div>
</div>
</header>
<main class="pedigree-main">
<p class="muted-note pedigree-hint">
@@ -291,7 +307,9 @@
<p id="pedigree-status" class="pedigree-status" hidden></p>
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
<div id="pedigree-tree" class="pedigree-tree"></div>
<p id="pedigree-caption" class="pedigree-caption" hidden></p>
</main>
</div><!-- /#pedigree-screen -->
@@ -324,6 +342,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>
+237 -29
View File
@@ -11,6 +11,7 @@
--poo: #8a5a3b;
--weight: #2bb3a3;
--training: #b04ecf;
--note: #6f7a90;
--danger: #d64545;
--gain: #2e9e5b;
--border: #e9e6f5;
@@ -78,13 +79,24 @@ h1 {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
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 {
font-size: 0.8rem;
color: var(--muted);
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 {
@@ -255,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; }
@@ -424,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;
@@ -644,7 +662,16 @@ dialog menu {
.weight-summary .stat-value.up { color: var(--gain); }
.weight-summary .stat-value.down { color: var(--danger); }
.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 {
display: flex;
@@ -665,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;
@@ -1065,9 +1101,34 @@ section.collapsed > :not(h2) { display: none; }
gap: 10px;
padding: 12px 4px 8px;
}
.pedigree-header { flex-wrap: wrap; }
.pedigree-header h1 { font-size: 1.4rem; }
.ped-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#ped-foldall { padding: 5px 10px; font-size: 0.8rem; white-space: nowrap; }
.ped-zoom {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.ped-zoom .icon-btn { padding: 4px 9px; font-size: 1.1rem; }
#ped-zoom-reset {
padding: 5px 8px;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
min-width: 48px;
}
.pedigree-hint { margin: 4px 0 12px; }
/* The tree scales with the CSS `zoom` property, which reflows so the container
still scrolls to reach the edges at any zoom. */
.pedigree-tree { touch-action: pan-x pan-y; }
.pedigree-status {
margin: 10px 0;
@@ -1123,59 +1184,132 @@ section.collapsed > :not(h2) { display: none; }
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
/* the tree */
/* Top-down family tree: the dog on top, parents branching below, connected by
lines drawn with each <li>'s ::before/::after (the classic CSS tree). Wider
than the screen once expanded, so the container scrolls horizontally. */
.pedigree-tree {
overflow-x: auto;
padding-bottom: 24px;
padding: 8px 0 28px;
}
.ped-node { position: relative; }
.ped-tree-h, .ped-tree-h ul {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.ped-tree-h {
/* "safe" centers when the tree fits and falls back to start-aligned (no
clipped/unreachable left edge) once it's wider than the screen. */
justify-content: safe center;
min-width: max-content;
padding: 4px 16px 12px;
}
.ped-tree-h ul {
justify-content: center;
position: relative;
padding-top: 22px; /* room for the connector from the parent above */
}
.ped-tree-h li {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 22px 6px 0;
}
/* Elbow from each child up to the horizontal bar shared by its siblings. */
.ped-tree-h li::before,
.ped-tree-h li::after {
content: "";
position: absolute;
top: 0;
width: 50%;
height: 22px;
border-top: 2px solid var(--border);
}
.ped-tree-h li::before { right: 50%; }
.ped-tree-h li::after { left: 50%; border-left: 2px solid var(--border); }
/* Vertical drop from a parent card down to its children's bar. */
.ped-tree-h ul::before {
content: "";
position: absolute;
top: 0;
left: 50%;
height: 22px;
border-left: 2px solid var(--border);
}
/* A lone parent connects with a straight line, no elbow. */
.ped-tree-h li:only-child::before,
.ped-tree-h li:only-child::after { display: none; }
/* Trim the outer half-lines at the ends of a sibling row. */
.ped-tree-h li:first-child::before,
.ped-tree-h li:last-child::after { border: 0 none; }
.ped-tree-h li:last-child::before { border-right: 2px solid var(--border); }
/* The dog sits on top with no connector above it. */
.ped-tree-h > li { padding-top: 0; }
.ped-tree-h > li::before,
.ped-tree-h > li::after { display: none; }
/* Collapsed: hide the ancestry below this dog (and its connectors go with it). */
.ped-tree-h li.collapsed > ul { display: none; }
.ped-card {
position: relative;
display: inline-block;
padding: 6px 24px 6px 28px;
margin: 3px 0;
width: 140px;
box-sizing: border-box;
padding: 7px 18px 7px 20px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
box-shadow: var(--shadow);
text-align: center;
}
.ped-name { font-weight: 600; font-size: 0.95rem; }
.ped-name { font-weight: 600; font-size: 0.85rem; line-height: 1.2; }
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
.ped-titles { font-size: 0.72rem; color: var(--accent); margin-top: 1px; }
.ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
.ped-reg {
font-size: 0.72rem;
font-size: 0.66rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
margin-top: 1px;
}
/* lineage spine: children indented under the card */
.ped-children {
margin-left: 16px;
padding-left: 14px;
border-left: 2px solid var(--border);
}
.ped-node.collapsed > .ped-children { display: none; }
/* sire ♂ (blue) above dam ♀ (purple) markers on the parent-role cards */
.ped-sire > .ped-card { border-left: 3px solid var(--sleep); }
.ped-dam > .ped-card { border-left: 3px solid var(--training); }
/* sire ♂ (blue) / dam ♀ (purple) accents on the parent cards */
.ped-sire > .ped-card { border-top: 3px solid var(--sleep); }
.ped-dam > .ped-card { border-top: 3px solid var(--training); }
.ped-sire > .ped-card::after,
.ped-dam > .ped-card::after {
position: absolute;
right: 7px;
top: 6px;
font-size: 0.75rem;
right: 6px;
top: 5px;
font-size: 0.72rem;
}
.ped-sire > .ped-card::after { content: "♂"; color: var(--sleep); }
.ped-dam > .ped-card::after { content: "♀"; color: var(--training); }
/* expand/collapse toggle */
/* pedigree collapse: a dog filling more than one position gets a ×N badge, a
stable hue, and lights up (with every copy) when tapped. */
.ped-card.ped-repeat { cursor: pointer; }
.ped-repeat-badge {
position: absolute;
right: 5px;
bottom: 5px;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
padding: 2px 5px;
border-radius: 999px;
color: #fff;
background: hsl(var(--repeat-hue, 0), 58%, 48%);
}
.ped-card.ped-lit {
border-color: hsl(var(--repeat-hue, 0), 70%, 50%);
box-shadow: 0 0 0 2px hsl(var(--repeat-hue, 0), 70%, 50%), var(--shadow);
}
.pedigree-repeat-note { margin: 0 0 10px; }
/* expand/collapse toggle (top-left of the card) */
.ped-toggle {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
left: 5px;
top: 5px;
width: 18px; height: 18px;
padding: 0;
font-size: 0.9rem;
@@ -1187,3 +1321,77 @@ section.collapsed > :not(h2) { display: none; }
cursor: pointer;
}
.ped-toggle:hover { border-color: var(--accent); color: var(--accent); }
/* ---- radial fan chart ---- */
.ped-fan { display: block; margin: 0 auto; max-width: none; }
.ped-wedge {
cursor: pointer;
stroke: var(--border);
stroke-width: 1;
/* outer rings tint gradually darker for depth */
fill: color-mix(in srgb, var(--accent-soft) calc(var(--gen, 1) * 7%), var(--surface));
transition: filter 0.1s ease;
}
.ped-wedge:hover { filter: brightness(0.95); }
/* a dog that appears more than once is filled with its stable hue */
.ped-wedge-repeat { fill: hsl(var(--repeat-hue, 0), 60%, 80%); stroke: hsl(var(--repeat-hue, 0), 45%, 60%); }
.ped-wedge.ped-lit {
fill: hsl(var(--repeat-hue, 0), 72%, 62%);
stroke: hsl(var(--repeat-hue, 0), 72%, 38%);
stroke-width: 2;
}
.ped-wedge-label {
font-size: 8px;
fill: var(--text);
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
}
.ped-fan-center { fill: var(--accent); stroke: none; cursor: pointer; }
.ped-fan-center-label {
fill: #fff;
font-size: 9px;
font-weight: 600;
dominant-baseline: central;
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .ped-wedge-label { fill: var(--text); }
}
.pedigree-caption {
margin: 10px 0 0;
font-size: 0.85rem;
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-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; }
}