Compare commits
4 Commits
52c50c97b1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9894abfc9 | |||
| 09d9d38c12 | |||
| a44c75d2d4 | |||
| e2f99590f1 |
+128
-33
@@ -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 ----------
|
||||||
@@ -894,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");
|
||||||
@@ -927,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();
|
||||||
@@ -938,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 ----------
|
||||||
@@ -1094,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++) {
|
||||||
@@ -1110,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;
|
||||||
@@ -1948,6 +2021,7 @@
|
|||||||
renderHourHeatmap(events);
|
renderHourHeatmap(events);
|
||||||
renderTraining(events);
|
renderTraining(events);
|
||||||
renderWeight(events);
|
renderWeight(events);
|
||||||
|
renderNotes(events);
|
||||||
renderHistory(events);
|
renderHistory(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2179,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;
|
||||||
@@ -2233,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);
|
||||||
@@ -3177,10 +3265,10 @@
|
|||||||
snackbarTimer = setTimeout(hideSnackbar, 5000);
|
snackbarTimer = setTimeout(hideSnackbar, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickLog(type, originEl) {
|
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, originEl);
|
if (type === "pee" || type === "poo") pottyConfetti(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A little burst of 💧/💩 from the tapped button when a pee/poo is logged.
|
// A little burst of 💧/💩 from the tapped button when a pee/poo is logged.
|
||||||
@@ -3203,28 +3291,27 @@
|
|||||||
}
|
}
|
||||||
return confettiLayerEl;
|
return confettiLayerEl;
|
||||||
}
|
}
|
||||||
function pottyConfetti(type, originEl) {
|
function pottyConfetti(type) {
|
||||||
if (!confettiEnabled()) return;
|
if (!confettiEnabled()) return;
|
||||||
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
const emoji = type === "poo" ? "💩" : "💧";
|
const emoji = type === "poo" ? "💩" : "💧";
|
||||||
const layer = confettiLayer();
|
const layer = confettiLayer();
|
||||||
const r = originEl && originEl.getBoundingClientRect
|
const W = window.innerWidth, H = window.innerHeight;
|
||||||
? originEl.getBoundingClientRect()
|
for (let i = 0; i < 30; i++) {
|
||||||
: { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 };
|
|
||||||
const ox = r.left + r.width / 2, oy = r.top + r.height / 2;
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
const piece = document.createElement("span");
|
const piece = document.createElement("span");
|
||||||
piece.className = "confetti-piece";
|
piece.className = "confetti-piece";
|
||||||
piece.textContent = emoji;
|
piece.textContent = emoji;
|
||||||
const ang = Math.random() * Math.PI * 2;
|
// Launch from a point across the bottom, shoot up to a random peak while
|
||||||
const dist = 60 + Math.random() * 130;
|
// drifting sideways, slowing and fading out as it reaches the top.
|
||||||
piece.style.left = `${ox}px`;
|
const peakY = -Math.round(H * (0.45 + Math.random() * 0.45));
|
||||||
piece.style.top = `${oy}px`;
|
piece.style.left = `${Math.round(W * (0.1 + Math.random() * 0.8))}px`;
|
||||||
piece.style.setProperty("--dx", `${Math.round(Math.cos(ang) * dist)}px`);
|
piece.style.top = `${H + 20}px`;
|
||||||
piece.style.setProperty("--dy", `${Math.round(Math.sin(ang) * dist - 50)}px`); // bias upward
|
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.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`);
|
||||||
piece.style.fontSize = `${Math.round(14 + Math.random() * 16)}px`;
|
piece.style.fontSize = `${Math.round(16 + Math.random() * 18)}px`;
|
||||||
piece.style.animationDuration = `${Math.round(900 + Math.random() * 600)}ms`;
|
piece.style.animationDuration = `${Math.round(1100 + Math.random() * 700)}ms`;
|
||||||
|
piece.style.animationDelay = `${Math.round(Math.random() * 280)}ms`;
|
||||||
piece.addEventListener("animationend", () => piece.remove());
|
piece.addEventListener("animationend", () => piece.remove());
|
||||||
layer.appendChild(piece);
|
layer.appendChild(piece);
|
||||||
}
|
}
|
||||||
@@ -3286,10 +3373,10 @@
|
|||||||
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, btn);
|
quickLog(type);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3297,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.
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
[
|
[
|
||||||
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off a little burst of 💧/💩 confetti from the button — a tiny celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
|
{ "date": "2026-08-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-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" },
|
||||||
|
|||||||
+11
-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>
|
||||||
|
|||||||
+29
-11
@@ -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;
|
||||||
@@ -266,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; }
|
||||||
@@ -435,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;
|
||||||
@@ -685,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;
|
||||||
@@ -1361,18 +1377,20 @@ section.collapsed > :not(h2) { display: none; }
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
animation-name: potty-burst;
|
animation-name: potty-firework;
|
||||||
animation-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);
|
animation-timing-function: ease-out;
|
||||||
animation-fill-mode: forwards;
|
/* `both` so the 0% state (invisible, at the bottom) also applies during the
|
||||||
}
|
per-piece launch delay — no flash before it takes off. */
|
||||||
@keyframes potty-burst {
|
animation-fill-mode: both;
|
||||||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(0deg); }
|
|
||||||
12% { opacity: 1; }
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
/* fly out to (dx, dy) then keep falling (gravity) */
|
|
||||||
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy) + 150px)) scale(1) rotate(var(--rot));
|
|
||||||
}
|
}
|
||||||
|
/* 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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.confetti-piece { display: none; }
|
.confetti-piece { display: none; }
|
||||||
|
|||||||
Reference in New Issue
Block a user