Compare commits
6 Commits
69e312175b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9894abfc9 | |||
| 09d9d38c12 | |||
| a44c75d2d4 | |||
| e2f99590f1 | |||
| 52c50c97b1 | |||
| 1ed325b834 |
+182
-18
@@ -21,6 +21,7 @@
|
||||
"poo": "Poo",
|
||||
"weight": "Weigh-in",
|
||||
"training": "Training",
|
||||
"note": "Note",
|
||||
};
|
||||
|
||||
// ---------- photos: IndexedDB store ----------
|
||||
@@ -291,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:
|
||||
// 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.
|
||||
@@ -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 ----------
|
||||
const lightbox = document.getElementById("lightbox");
|
||||
const lightboxImg = document.getElementById("lightbox-img");
|
||||
@@ -909,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();
|
||||
@@ -920,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 ----------
|
||||
@@ -1076,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++) {
|
||||
@@ -1092,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;
|
||||
@@ -1669,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");
|
||||
@@ -1888,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
|
||||
@@ -1930,6 +2021,7 @@
|
||||
renderHourHeatmap(events);
|
||||
renderTraining(events);
|
||||
renderWeight(events);
|
||||
renderNotes(events);
|
||||
renderHistory(events);
|
||||
}
|
||||
|
||||
@@ -2161,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;
|
||||
@@ -2215,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);
|
||||
@@ -2404,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", () => {
|
||||
@@ -2416,6 +2523,7 @@
|
||||
settingsBirthday.value = cfg.birthday;
|
||||
settingsPedigree.value = cfg.pedigreeId;
|
||||
settingsTheme.checked = effectiveTheme() === "dark";
|
||||
settingsConfetti.checked = confettiEnabled();
|
||||
settingsDialog.showModal();
|
||||
setTimeout(() => settingsName.focus(), 50);
|
||||
}
|
||||
@@ -2431,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();
|
||||
@@ -3159,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", () => {
|
||||
@@ -3217,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);
|
||||
});
|
||||
});
|
||||
@@ -3228,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.
|
||||
{
|
||||
|
||||
@@ -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 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" },
|
||||
|
||||
+15
-4
@@ -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>
|
||||
@@ -335,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>
|
||||
|
||||
+68
-1
@@ -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;
|
||||
@@ -1328,3 +1364,34 @@ section.collapsed > :not(h2) { display: none; }
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- pee/poo confetti ---- */
|
||||
#confetti-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
}
|
||||
.confetti-piece {
|
||||
position: absolute;
|
||||
line-height: 1;
|
||||
will-change: transform, opacity;
|
||||
animation-name: potty-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