01d64b682b
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.
452 lines
22 KiB
HTML
452 lines
22 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<meta name="theme-color" content="#7c5cff" />
|
||
<title>Puppy Tracker</title>
|
||
<link rel="manifest" href="manifest.json" />
|
||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||
<link rel="apple-touch-icon" href="icon.svg" />
|
||
<link rel="stylesheet" href="style.css" />
|
||
<script>
|
||
// Apply a saved theme before first paint so there's no light/dark flash.
|
||
// No saved choice → leave it to the prefers-color-scheme media query.
|
||
(function () {
|
||
try {
|
||
var t = localStorage.getItem("puppy-tracker:theme");
|
||
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
|
||
} catch (e) {}
|
||
})();
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
|
||
it and refreshes onto the new assets; "Later" dismisses until next time.
|
||
Suppressed on the very first install (see app.js). -->
|
||
<div id="update-banner" class="update-banner" hidden role="status" aria-live="polite">
|
||
<div class="update-banner-row">
|
||
<span class="update-banner-msg">A new version is available</span>
|
||
<span class="update-banner-actions">
|
||
<button type="button" id="update-reload" class="update-banner-btn">Reload</button>
|
||
<button type="button" id="update-later" class="update-banner-btn ghostish">Later</button>
|
||
</span>
|
||
</div>
|
||
<!-- What the waiting build adds over the running one; filled by app.js
|
||
from the diff between the cached and the fresh changelog.json. -->
|
||
<ul id="update-changelog" class="update-changelog" hidden></ul>
|
||
</div>
|
||
|
||
<!-- Login / register gate. Shown until the session check succeeds; the app
|
||
(#app) stays hidden behind it so no puppy data paints while logged out. -->
|
||
<div id="auth-screen" class="auth-screen" hidden>
|
||
<div class="auth-card">
|
||
<h1>🐶 Puppy Tracker</h1>
|
||
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
|
||
<form id="auth-form">
|
||
<label>Email
|
||
<input type="email" id="auth-email" autocomplete="username" required />
|
||
</label>
|
||
<label>Password
|
||
<input type="password" id="auth-password" autocomplete="current-password" required minlength="8" />
|
||
</label>
|
||
<label id="auth-invite-field" hidden>Invite code
|
||
<input type="text" id="auth-invite" autocomplete="off" placeholder="Ask the owner for this" />
|
||
</label>
|
||
<p id="auth-error" class="auth-error" hidden></p>
|
||
<button type="submit" id="auth-submit">Sign in</button>
|
||
</form>
|
||
<p class="auth-toggle">
|
||
<span id="auth-toggle-text">No account yet?</span>
|
||
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="app" hidden>
|
||
<header>
|
||
<div class="title">
|
||
<h1 id="app-title">🐶 Puppy Tracker</h1>
|
||
<div id="puppy-age" class="puppy-age" hidden></div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree" hidden>🌳</button>
|
||
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
|
||
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
|
||
<div id="online-status" class="status-pill"></div>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="day-bar">
|
||
<!-- Compact twin of the big timer below: invisible (but keeping its
|
||
slot) while the big card is on screen, shown once it scrolls
|
||
away. Hidden entirely until a sleep event exists. -->
|
||
<div id="bar-clock" class="bar-clock" hidden>
|
||
<span id="bar-clock-icon" aria-hidden="true"></span>
|
||
<span id="bar-clock-time"></span>
|
||
</div>
|
||
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
||
<!-- A browser won't let us shorten the text a native date input shows,
|
||
so the face button carries a compact year-less date and the real
|
||
input stays (visually hidden) as the value + native picker. -->
|
||
<span class="day-date">
|
||
<button type="button" id="day-date-face" class="ghost"></button>
|
||
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
|
||
</span>
|
||
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
||
<button type="button" id="day-today" class="ghost">Today</button>
|
||
</section>
|
||
|
||
<section id="big-clock" class="big-clock" hidden>
|
||
<div class="bc-label" id="bc-label">—</div>
|
||
<div class="bc-time" id="bc-time">0:00</div>
|
||
<div class="bc-since" id="bc-since"></div>
|
||
</section>
|
||
|
||
<section class="quick-actions">
|
||
<h2>Log event</h2>
|
||
<div class="grid">
|
||
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
|
||
<button class="action sleep" data-type="sleep-end">⏰ Sleep end</button>
|
||
<button class="action eat" data-type="eat">🍽️ Ate</button>
|
||
<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>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="training" data-panel="training">
|
||
<h2>Training</h2>
|
||
<ul id="training-list" class="training-list"></ul>
|
||
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
|
||
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
|
||
<div class="chart training-chart" id="training-chart-wrap" hidden>
|
||
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
|
||
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
|
||
<p class="muted-note">Darker = more sessions that day. Tap a cell to open that day.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="overview" data-panel="overview">
|
||
<h2 id="overview-title">Today's overview</h2>
|
||
<div class="stats">
|
||
<div class="stat">
|
||
<div class="stat-label">Sleep</div>
|
||
<div class="stat-value" id="stat-sleep">0h 0m</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Awake</div>
|
||
<div class="stat-value" id="stat-awake">0h 0m</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Meals</div>
|
||
<div class="stat-value" id="stat-meals">0</div>
|
||
<div class="stat-sub" id="stat-meals-grams" hidden></div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Pees</div>
|
||
<div class="stat-value" id="stat-pees">0</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Poos</div>
|
||
<div class="stat-value" id="stat-poos">0</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Training</div>
|
||
<div class="stat-value" id="stat-training">0</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lasts">
|
||
<div class="last-row"><span>Last pee</span><span id="last-pee">—</span></div>
|
||
<div class="last-row"><span>Last poo</span><span id="last-poo">—</span></div>
|
||
<div class="last-row"><span>Last meal</span><span id="last-eat">—</span></div>
|
||
<div class="last-row"><span>Last sleep</span><span id="last-sleep">—</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="timing" data-panel="timing">
|
||
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
|
||
<div class="lasts">
|
||
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee">—</span></div>
|
||
<div class="last-row"><span>Shortest between pees</span><span id="gap-pee-min">—</span></div>
|
||
<div class="last-row"><span>Typical time between poos</span><span id="gap-poo">—</span></div>
|
||
<div class="last-row"><span>Shortest between poos</span><span id="gap-poo-min">—</span></div>
|
||
</div>
|
||
<p class="muted-note timing-hint" id="timing-hint"></p>
|
||
</section>
|
||
|
||
<section class="sleep" data-panel="sleep-windows">
|
||
<h2>Sleep windows</h2>
|
||
<ul id="sleep-list" class="wake-list"></ul>
|
||
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p>
|
||
</section>
|
||
|
||
<section class="wake" data-panel="wake-windows">
|
||
<h2>Wake windows</h2>
|
||
<ul id="wake-list" class="wake-list"></ul>
|
||
<p id="wake-empty" class="empty">No wake windows yet for this day.</p>
|
||
</section>
|
||
|
||
<section class="weekly" data-panel="weekly">
|
||
<h2 id="daily-charts-title">Last 7 days</h2>
|
||
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
|
||
<button type="button" class="ghost" data-days="7">7d</button>
|
||
<button type="button" class="ghost" data-days="14">14d</button>
|
||
<button type="button" class="ghost" data-days="30">30d</button>
|
||
</div>
|
||
<div class="chart">
|
||
<div class="chart-title">Sleep (hours)</div>
|
||
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
|
||
</div>
|
||
<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>
|
||
</div>
|
||
<div class="chart" id="grams-chart-wrap" hidden>
|
||
<div class="chart-title">Food (grams)</div>
|
||
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="patterns" data-panel="sleep-timeline">
|
||
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
|
||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
|
||
</section>
|
||
|
||
<section class="patterns" data-panel="sleep-trend">
|
||
<h2>Sleep trend</h2>
|
||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||
<div class="legend">
|
||
<span class="lg trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
|
||
</div>
|
||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
|
||
</section>
|
||
|
||
<section class="patterns" data-panel="hour-heatmap">
|
||
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
||
<p class="muted-note">Darker = happens more often at that hour.</p>
|
||
</section>
|
||
|
||
<section class="weight" data-panel="weight">
|
||
<h2>Weight</h2>
|
||
<div class="weight-summary">
|
||
<div class="stat">
|
||
<div class="stat-label">Latest</div>
|
||
<div class="stat-value" id="weight-latest">—</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Since last</div>
|
||
<div class="stat-value" id="weight-change">—</div>
|
||
</div>
|
||
</div>
|
||
<div class="chart">
|
||
<div class="chart-title">Weight (kg)</div>
|
||
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
|
||
<p id="weight-point-info" class="muted-note weight-point-info"></p>
|
||
</div>
|
||
<ul id="weight-list" class="wake-list"></ul>
|
||
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
||
</section>
|
||
|
||
<section class="history" data-panel="history">
|
||
<h2>History</h2>
|
||
<ul id="event-list" class="event-list"></ul>
|
||
<p id="empty-state" class="empty">No events logged for this day.</p>
|
||
</section>
|
||
</main>
|
||
|
||
<footer class="app-footer">
|
||
<button type="button" id="changelog-btn" class="linklike">Changelog</button>
|
||
</footer>
|
||
</div><!-- /#app -->
|
||
|
||
<!-- Pedigree lookup. A distinct full-screen view (hides #app while open)
|
||
that resolves a dog by chip / registration number / name against SKK
|
||
and renders its ancestry as a tree. Online-only. -->
|
||
<div id="pedigree-screen" class="pedigree-screen" hidden>
|
||
<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>
|
||
</header>
|
||
<main class="pedigree-main">
|
||
<p class="muted-note pedigree-hint">
|
||
Your dog's ancestry from <strong>SKK Hunddata</strong>, traced from the
|
||
ID set in Settings. The first generations show at once, then the line
|
||
fills in further back.
|
||
<button type="button" id="pedigree-refresh" class="linklike">Refresh</button>
|
||
</p>
|
||
<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>
|
||
</main>
|
||
</div><!-- /#pedigree-screen -->
|
||
|
||
<dialog id="changelog-dialog">
|
||
<form method="dialog" id="changelog-form">
|
||
<h3>Changelog</h3>
|
||
<ul id="changelog-list" class="changelog-list"></ul>
|
||
<p id="changelog-empty" class="empty" hidden>No changelog available.</p>
|
||
<menu>
|
||
<button value="close">Close</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="settings-dialog">
|
||
<form method="dialog" id="settings-form">
|
||
<h3>Puppy settings</h3>
|
||
<label>Name
|
||
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
|
||
</label>
|
||
<label>Birthday
|
||
<input type="date" id="settings-birthday" />
|
||
</label>
|
||
<label>Pedigree ID
|
||
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
|
||
placeholder="SKK chip or reg. number (optional)" />
|
||
</label>
|
||
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
|
||
<label class="toggle-row">
|
||
<span>Dark mode</span>
|
||
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
||
</label>
|
||
<menu>
|
||
<button value="cancel" class="ghost">Cancel</button>
|
||
<button value="save" id="settings-save">Save</button>
|
||
</menu>
|
||
<hr class="settings-sep" />
|
||
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="delete-account-dialog">
|
||
<form method="dialog" id="delete-account-form">
|
||
<h3>Delete account</h3>
|
||
<p class="danger-text">
|
||
This permanently deletes your account and <strong>all its data</strong> —
|
||
every event, photo and your puppy profile. This can't be undone.
|
||
</p>
|
||
<label>Confirm your password
|
||
<input type="password" id="delete-account-password" autocomplete="current-password" />
|
||
</label>
|
||
<p id="delete-account-error" class="auth-error" hidden></p>
|
||
<menu>
|
||
<button value="cancel" class="ghost">Cancel</button>
|
||
<button type="button" id="delete-account-confirm" class="danger">Delete forever</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="exercise-dialog">
|
||
<form method="dialog" id="exercise-form">
|
||
<h3 id="exercise-title">Add exercise</h3>
|
||
<label>Name
|
||
<input type="text" id="exercise-name" placeholder="e.g. Sit" autocomplete="off" />
|
||
</label>
|
||
<label>How to do it
|
||
<textarea id="exercise-note" rows="5" placeholder="Reminder for how to train it, e.g. lure with a treat, mark the moment the butt touches the ground, reward"></textarea>
|
||
</label>
|
||
<menu>
|
||
<!-- type="button" keeps Save the form's default button, so Enter /
|
||
the iOS keyboard's "Go" saves instead of silently hitting the
|
||
(hidden) Delete button via implicit form submission. -->
|
||
<button type="button" value="delete" id="exercise-delete" class="danger" hidden>Delete</button>
|
||
<button type="button" value="cancel" class="ghost">Cancel</button>
|
||
<button value="save" id="exercise-save">Save</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="note-dialog">
|
||
<form method="dialog" id="note-form">
|
||
<h3 id="note-title">Add note</h3>
|
||
<label>Time
|
||
<div class="time-row">
|
||
<input type="date" id="note-date" />
|
||
<input type="time" id="note-time" lang="en-GB" />
|
||
<button type="button" id="note-time-now" class="ghost">Now</button>
|
||
</div>
|
||
</label>
|
||
<label id="note-weight-field" hidden>Weight (kg)
|
||
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
|
||
</label>
|
||
<label id="note-grams-field" hidden>Amount (g)
|
||
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
|
||
</label>
|
||
<label>Note
|
||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||
</label>
|
||
<div class="photo-field">
|
||
<input type="file" id="note-photo-input" accept="image/*" multiple hidden />
|
||
<button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
|
||
<div id="note-photo-preview" class="photo-preview" hidden></div>
|
||
</div>
|
||
<menu>
|
||
<button value="cancel" class="ghost">Cancel</button>
|
||
<button value="save" id="note-save">Save</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="edit-dialog">
|
||
<form method="dialog" id="edit-form">
|
||
<h3>Edit event</h3>
|
||
<label>Time
|
||
<div class="time-row">
|
||
<input type="date" id="edit-date" />
|
||
<input type="time" id="edit-time" lang="en-GB" />
|
||
</div>
|
||
</label>
|
||
<label id="edit-weight-field" hidden>Weight (kg)
|
||
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
|
||
</label>
|
||
<label id="edit-grams-field" hidden>Amount (g)
|
||
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
|
||
</label>
|
||
<label>Note
|
||
<textarea id="edit-note" rows="4"></textarea>
|
||
</label>
|
||
<div class="photo-field">
|
||
<input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
|
||
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
|
||
<div id="edit-photo-preview" class="photo-preview" hidden></div>
|
||
</div>
|
||
<menu>
|
||
<button value="delete" id="edit-delete" class="danger">Delete</button>
|
||
<button value="cancel" class="ghost">Cancel</button>
|
||
<button value="save">Save</button>
|
||
</menu>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="lightbox">
|
||
<button type="button" id="lightbox-close" class="lightbox-close" aria-label="Close">×</button>
|
||
<img id="lightbox-img" alt="" />
|
||
</dialog>
|
||
|
||
<!-- Brief confirmation after a one-tap quick log, with Undo / Add note. -->
|
||
<div id="snackbar" class="snackbar" hidden role="status" aria-live="polite">
|
||
<span id="snackbar-msg" class="snackbar-msg"></span>
|
||
<button type="button" id="snackbar-note" class="snackbar-action">Add note</button>
|
||
<button type="button" id="snackbar-undo" class="snackbar-action">Undo</button>
|
||
</div>
|
||
|
||
<script src="app.js"></script>
|
||
</body>
|
||
</html>
|