Compare commits

..

64 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
Alexander Heldt c2f74e64c8 Gate pedigree behind a dog id set in settings
The pedigree view is now opt-in and tied to your own dog rather than an
always-present free-text search. Add the dog's SKK chip or registration
number in Settings (it rides the synced profile alongside name and
birthday); the 🌳 button stays hidden until one is set, then opens the
page and loads that dog's ancestry directly.

Make repeat opens cheap: memoise the id->hundid resolution server-side so
a cached tree is served without contacting SKK at all, and mirror the
finished tree in localStorage so the page paints instantly and shows the
last-known tree offline.

Adds config.pedigree_id (with an in-place migration for existing DBs).
2026-07-26 09:36:49 +00:00
Alexander Heldt 26ebe3bd86 Add pedigree lookup and ancestry tree page
New 🌳 Pedigree view: enter a dog's ISO chip or SKK registration number
and see its ancestry rendered as a tree. SKK has no public API, so the
server drives SKK Hunddata like a browser: it resolves the input to an
internal hundid via the Hund_sok.aspx/HundData page-method, renders 7
generations per pedigree page, parses the rowspan grid into ahnentafel
positions, and follows each generation's leaves deeper by reading their
hundid out of the __doPostBack response viewstate.

A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
2026-07-26 09:22:14 +00:00
Alexander Heldt 374e630d8f Show the age counter as weeks with a months breakdown
Weeks and calendar months disagreed at a glance ("16 weeks" but only
"3 months"). Now the counter reads "16 weeks (3 months and 3 weeks)
old" so the two units line up, and drops the weeks past 4 months to
show just the months form.
2026-07-24 15:17:09 +00:00
Alexander Heldt 22a5ea76aa Make the Sleep trend chart follow the selected day
The curves were always computed relative to today regardless of the day
picker. Anchor them to selectedDay() instead: a past day shows its full
24h curve against the day before it and the average of the days leading
up to it, with no now-cap and no projection. Legend and tooltip labels
show the actual dates when viewing a past day.
2026-07-23 08:45:26 +00:00
Alexander Heldt 0ebaa11c93 Stretch the Sleep trend y-axis above 10h
The interesting detail sits near the goal, in the upper teens of hours,
so the scale is piecewise: 0–10h shares a compressed 26% of the height
with 2h gridlines, and everything above gets the rest with 1h
gridlines. Falls back to linear while the axis is too short to split.
The caption notes the stretched axis.
2026-07-15 19:06:16 +00:00
Alexander Heldt 897311465c Show the age-based sleep goal as a band in the Sleep trend chart
The puppy's age (from the configured birthday) picks the daily goal —
0–8 weeks 20–22h, 8–16 weeks 18–20h, 16–18h to 6 months, 14–16h to 12
months — drawn as a shaded band behind the curves, with a legend chip
naming the range. The Projected chip gains a ✓/▽ marker for whether
today's projection reaches the goal's lower bound, and the y-axis
always extends to cover the band. No birthday (or an adult dog) means
no band, unchanged from before.
2026-07-15 17:35:55 +00:00
Alexander Heldt 8fe8f9d417 Log training sessions onto the selected day, not always today
The Log button stamps the selected day at the current time of day, so a
forgotten session can be back-filled from that day's view. The snackbar
says which day it landed on when it isn't today; Undo/Add note still
work on it.
2026-07-15 13:26:30 +00:00
Alexander Heldt c24d59e672 Make the Sleep trend yesterday line orange
Gray sank into the dashed grid lines; --eat orange stands apart from
today's blue and the average's teal in both themes.
2026-07-15 13:24:55 +00:00
Alexander Heldt a1a6ec8720 Give the Sleep trend average line its own color
All three blue lines were hard to tell apart; the N-day average is now
teal (the --weight token) and slightly more opaque, while the projected
tail stays blue on purpose — it continues today's line.
2026-07-15 13:18:42 +00:00
Alexander Heldt 9b6ee20b07 Don't scroll to the history when a chart day is tapped
Selecting the day (and the new selected-day highlight) is enough;
the scroll jump lost your place in the charts.
2026-07-15 12:48:23 +00:00
Alexander Heldt 3e3eee678e Show a meal's grams in the history list
Same pattern as weigh-ins: "80 g · note" or just "80 g" when there's
no note.
2026-07-15 12:47:39 +00:00
Alexander Heldt 6d54ecf238 Label every 1h gridline on both sleep charts
Instead of thinning labels on dense axes, keep them all and drop the
label font to 7px past 12 steps (bars) / 16 steps (trend).
2026-07-15 12:45:53 +00:00
Alexander Heldt 83af641a12 Use 1-hour gridlines on the daily Sleep bar chart
niceAxisSleepHours drops from 2-hour to 1-hour steps and is shared with
the trend chart again (the trend-only helper it duplicated is gone).
Labels thin to every other gridline past 12 steps — this chart's plot
area is shorter than the trend's.
2026-07-15 10:56:16 +00:00
Alexander Heldt ba6e2c5e7d Make the Sleep trend chart taller with a finer 1-hour y-axis
220px viewBox (was 160) and 1-hour gridlines via a trend-specific axis
helper, so curves that run close together separate visually. Labels
drop to every other gridline past 16 steps to keep the text readable.
2026-07-15 10:49:27 +00:00
Alexander Heldt 0e24ac989d Show each curve's slept hours in the Sleep trend legend
Today, Yesterday and the N-day average chips now carry their totals
(e.g. "Yesterday 12.8h"), matching the projected chip.
2026-07-15 10:43:22 +00:00
Alexander Heldt ab0e51108c Project today's end-of-day sleep total on the Sleep trend chart
A dashed tail continues today's line from now to midnight by adding the
increments the N-day average curve makes over the same stretch, so the
projection follows the usual daily rhythm instead of extrapolating the
current rate (which overshoots right after a long night). The legend
shows the projected total; with no history there's no average and no
projection.
2026-07-15 10:36:22 +00:00
Alexander Heldt cebe923d68 Highlight the selected day in the per-day charts
A soft accent band behind the selected day's bars/cells (sleep, counts,
food, training heatmap) — visible even when the day's values are zero —
plus an accent-colored day label, and an accent ring on the selected
row of the sleep timeline. The band tracks the day picker, so tapping
into a chart immediately shows which slice you landed on.
2026-07-15 10:24:48 +00:00
Alexander Heldt 8dc4fca4e2 Add a Sleep trend chart: cumulative sleep today vs yesterday and the recent average
Answers "is the puppy behind on sleep right now?": cumulative hours
slept sampled at each hour boundary, today's line ending at the current
moment, with yesterday and the mean of the last N days as reference
curves. N follows the 7/14/30 chart-days picker, and days with no sleep
logged are skipped so a young log doesn't drag the average down.
2026-07-15 10:18:08 +00:00
Alexander Heldt b030cfb72a Dim quick actions that don't fit the current sleep state
Asleep: everything but Sleep end fades; awake: only Sleep end fades; no
sleep history: nothing. Buttons stay clickable — it's a visual nudge for
the thumb, not a lockout, so corrections still work.
2026-07-15 10:17:20 +00:00
Alexander Heldt 7cb59a7a23 Focus a text field when the edit dialog opens, never the date input
showModal() with no explicit focus autofocuses the first control — the
date input — which on iOS pops the native calendar over the form. Focus
weight/grams/note per event type instead, like openNoteDialog does.
2026-07-15 10:16:56 +00:00
Alexander Heldt 131346a7c7 Hide dialog weight/grams fields on event types that don't use them
The JS already sets .hidden per type, but 'dialog label { display: block }'
outranks the UA [hidden] rule, so the fields rendered anyway. Add the
explicit [hidden] override, matching the pattern used elsewhere in the file.
2026-07-15 10:16:29 +00:00
Alexander Heldt b608dfc342 Show a compact year-less date in the day bar so it fits small iPhones
The native date input can't be told to drop the year, so a button face
shows e.g. "Jul 10" and the real input sits hidden behind it, still
holding the value and opening the native picker on tap.
2026-07-15 10:13:19 +00:00
Alexander Heldt cdd701f0b4 Bring back the big timer; the bar pill takes over on scroll
The big asleep/awake card returns below the frozen bar, and the bar
pill becomes its twin: both tick together, but the pill stays
visibility-hidden — slot reserved, so the bar never shifts — while the
card is on screen, appearing only once the card scrolls out of sight
(rAF-throttled scroll check against the bar's bottom edge).
2026-07-13 21:31:29 +00:00
Alexander Heldt 8981214e55 Fit the whole top bar on one row
Timer, day arrows, date picker and Today all share a single non-wrapping
row: the timer slims to 1rem, buttons and gaps tighten, arrows sit
around the picker, and a narrow-phone media query compacts further.
2026-07-13 21:26:07 +00:00
Alexander Heldt b6301156f9 Let the chart window be picked: 7, 14 or 30 days (default 7)
A 7d/14d/30d picker in the charts card sets how many days every
rolling chart covers — daily bars, sleep timeline, hour heatmap and
training consistency grid. The choice is stored per device like the
theme; headers show the current window, day labels thin out and bar
gaps tighten as the window widens, and the sleep timeline grows rows
to fit.
2026-07-13 21:20:31 +00:00
Alexander Heldt 6b12820592 Pin the timer left in the day bar with the slack before the day controls 2026-07-13 20:56:12 +00:00
Alexander Heldt cee4455651 Make the food chart y-axis much finer
The grams axis now targets ~10 segments instead of ~4, so a 240 g day
gets 25 g gridlines; steps stay round numbers at any scale.
2026-07-13 20:52:48 +00:00
Alexander Heldt 695c030f33 Make the timer pill bigger and put it first in the day bar 2026-07-13 20:50:41 +00:00
Alexander Heldt a0e084dae8 Extend the daily charts from 7 to 14 days
Sleep, daily counts and food grams now share a 14-day window like the
pattern charts. Bar gaps tighten to fit 14 columns and the x-axis
labels today plus every second day so labels don't collide; tooltips
and click-through per bar are unchanged.
2026-07-13 20:45:22 +00:00
Alexander Heldt c103082ca5 Replace the big clock with a timer pill in the frozen day bar
The full-width asleep/awake card becomes a compact colored pill next to
the day controls: state emoji plus the live counter, with "since" moved
to the tooltip. One glance at the pinned bar now gives both the day and
the current state.
2026-07-13 20:40:01 +00:00
Alexander Heldt 0009e63d23 Freeze the day picker at the top of the page
The day bar moves above everything else and sticks while scrolling, so
switching days never requires scrolling back up. A fixed strip painted
over the safe-area inset keeps content from peeking through above the
stuck bar on notched phones in standalone mode.
2026-07-13 20:39:06 +00:00
Alexander Heldt a202f3e929 Support multiple photos per event
photoId now holds one or more photo UUIDs, comma-separated. The server
never interprets the field (photos are uploaded and served individually
by UUID), so no schema change is needed and legacy single-photo events
are already valid one-element lists.

Both dialogs let you keep adding photos, previewed as thumbnails with a
per-photo remove button; the file input allows multi-select and no
longer forces the camera, so the gallery is available too. History rows
show every photo, each opening in the lightbox.
2026-07-13 20:34:07 +00:00
Alexander Heldt 3a829161e3 Track food eaten by weight in grams
Logging a meal now opens the dialog (like weigh-ins) with an optional
Amount (g) field; existing meals can get an amount via the edit dialog,
where clearing the field drops it. The overview's Meals tile shows the
day's total grams, and the weekly card gains a Food (grams) chart with
a self-scaling axis that stays hidden until any meal has an amount.

Events carry a new grams field (REAL column, auto-migrated); addEvent's
growing optional parameters are folded into an options object.
2026-07-13 20:28:53 +00:00
Alexander Heldt 68964b55e4 Add a Changelog button at the bottom of the page
Opens a dialog listing the loaded build's full changelog, grouped by
date. Fetched cache-first through the service worker, so the list always
matches the running version; the update banner still covers what a
waiting build adds.
2026-07-12 17:36:44 +00:00
Alexander Heldt c8dceb14d4 Add CLAUDE.md with changelog upkeep reminder 2026-07-12 17:30:41 +00:00
Alexander Heldt 1c4bea51df Increase the daily counts chart y-axis granularity
1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond — previously
anything above 10 was squeezed into five segments.
2026-07-12 17:30:16 +00:00
Alexander Heldt 094af3906d Show a changelog in the update banner
changelog.json is a hand-maintained, newest-first list of user-visible
changes; it is part of the SW asset cache and the build hash, so even a
changelog-only edit rolls a new version.

When the update banner appears, the app fetches changelog.json twice:
the plain URL is answered cache-first by the old controlling worker
(the running build's copy) while a cache-busting query bypasses every
SW cache and hits the network (the waiting build's copy). Entries the
fresh copy has that the cached one lacks are exactly what the update
brings, and are listed under the banner message.
2026-07-12 17:29:32 +00:00
Alexander Heldt 2b4731185c Keep PWA builds coherent by bypassing the HTTP cache
The service worker populated a new build's cache with plain addAll(),
which the browser may satisfy from its HTTP cache — and static assets
were served without Cache-Control, so Safari's heuristic caching could
hold app.js for days. Together that could install a mixed build: a
fresh index.html whose buttons reference listeners a stale app.js never
registers.

Install now fetches assets with cache: "reload", and the server marks
all static assets no-cache (revalidation is a cheap 304; fast/offline
loads are the SW cache's job anyway).
2026-07-12 17:13:13 +00:00
Alexander Heldt ced415c3a5 Make Save the exercise dialog's default button
All three menu buttons were implicit submit buttons, so pressing Enter
(or the iOS keyboard's "Go") triggered implicit form submission against
the first one — the hidden Delete button — which closed the dialog and
silently discarded the typed exercise. Delete and Cancel are now
type="button", leaving Save as the form's default button.
2026-07-12 17:12:59 +00:00
Alexander Heldt c1ece16347 Remove the redundant "Currently" row from the overview
The big clock already shows the asleep/awake state prominently.
2026-07-12 16:54:02 +00:00
Alexander Heldt 2e817a086d Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same
LWW/tombstone contract as events, served by POST /api/exercises/sync.
Training sessions are ordinary events (type "training") referencing an
exercise by id, so they ride the existing event sync unchanged.

The Training panel lists each exercise with last-trained / this-week /
streak stats, expandable instructions, and a one-tap Log button with the
usual undo/add-note snackbar. An exercise-by-day heatmap shows the last
14 days of consistency, and history and the daily overview count
training sessions like any other event.
2026-07-12 16:54:02 +00:00
Alexander Heldt 561b98b64f Make info panels collapsible with remembered state
Each main section with a data-panel key can now be folded by clicking its
heading (rotating chevron, keyboard-accessible via role/tabindex/aria-expanded
and Enter/Space). Collapsed keys are persisted in localStorage and restored on
load, so the layout stays how you left it across reloads.

State is device-global like the theme (a single non-namespaced key, not
per-user data), and only collapsed panels are stored — so any panel added
later defaults to open.
2026-07-10 17:03:29 +00:00
Alexander Heldt f4ce7dcb54 Add sleep timeline and by-hour heatmaps
Two new charts over the last 14 days, in the existing hand-rolled SVG style:

- "When he sleeps": an actogram with one row per day (oldest at top, Today
  at bottom) and a midnight-to-midnight track with sleep shaded. Sleep
  windows are clipped per day so a night crossing midnight splits across two
  rows, and today's open sleep runs to now. Tapping a row selects that day.
- "By hour of day": pee/poo/meal frequency as three 24-cell heatmap rows,
  each cell shaded by how often that event lands in that hour — surfacing
  daily rhythm the median gap can't show.

Both re-render on the one-minute tick so the current day keeps filling in.
2026-07-10 17:03:29 +00:00
Alexander Heldt 16799ad7c7 Render sw.js with a per-build asset hash so updates are detected
The update banner only fires when the browser sees sw.js change, but the
cache name was a hand-bumped constant — so a deploy that touched only
app.js/index.html/style.css left sw.js byte-identical, no new worker
installed, and the banner never showed (and cached assets never refreshed).

Have the server render sw.js at serve time, substituting a __BUILD_HASH__
placeholder with a SHA-256 over the assets the worker caches (index.html,
style.css, app.js, manifest.json, icon.svg). Any asset change now yields a
new cache name and a byte-different sw.js, which is exactly the signal that
makes the browser install a new worker. The hash is memoised and only
recomputed when a file's size/modtime changes, so it needs no server
restart. Served unsubstituted (dev/static host), sw.js stays a valid constant.
2026-07-10 12:55:01 +00:00
Alexander Heldt d267ebef86 Log an event that happened "now" in one tap
Logging a quick pee/poo/meal/sleep boundary took two taps: open the note
dialog, then Save. Make a single tap on a quick-action log the event
immediately at the current time.

Non-weight quick actions now call addEvent(type, "", Date.now()) directly
and show a brief snackbar with Undo (deletes the just-added event) and Add
note (opens the edit dialog for it), so notes, photos and custom times are
never lost. Weigh-ins still open the dialog since they need a value.
addEvent now returns the created event so the snackbar can reference it.
2026-07-10 12:44:41 +00:00
Alexander Heldt 01ad078b2a Stamp "now" events to the second, not the minute
The time input has no seconds and fromDateTimeInputs floors to :00, so an
event logged at "now" was stored up to ~59s in the past — the big-clock
counter would jump to e.g. "0:40" the instant a sleep boundary was logged.

Track whether the user has actually touched the date/time fields (reset in
openNoteDialog and the "Now" button, set on manual change/input). When
untouched, noteDialogAt() returns the exact Date.now(); an explicitly
picked time is still parsed from the inputs (minute precision is fine there).
2026-07-10 12:36:30 +00:00
Alexander Heldt 8f4034ef47 Fix "Currently" row disagreeing with the big clock about sleep state
The "Currently" row derived its state from isCurrentlyAsleep() (ascending
stable sort, then fold) while the big clock used currentSleepState()
(max-by-`at` with a strict >). When two sleep boundary events shared the
same `at`, the two broke the tie differently, so the row could show
"Asleep" during a wake window while the clock said "Awake".

Make both read from the single currentSleepState() source, and give it a
deterministic tie-breaker: for equal `at`, the later updatedAt (most
recently logged boundary) wins. Remove the now-unused isCurrentlyAsleep().
2026-07-10 12:33:00 +00:00
Alexander Heldt dbcac0653e Prompt to reload when a new version is available
The service worker used to skipWaiting() on install and claim clients on
activate, so a new build's assets swapped in silently and a long-open tab
kept running stale JS. Switch to the standard update flow: the worker now
waits until the page sends it a SKIP_WAITING message, and the page shows a
"A new version is available — Reload / Later" banner when a new worker
reaches "installed" while one is already controlling the tab (that
controller check suppresses the first-install prompt).

Reload posts SKIP_WAITING and reloads on controllerchange (guarded against
a reload loop and against the initial clients.claim on a fresh install);
Later dismisses until the next update. Since browsers only auto-check on
navigation, also poll registration.update() hourly and on visibilitychange.
Bump the cache to v9 so the old cache is cleaned up on activate.
2026-07-10 12:30:30 +00:00
Alexander Heldt 9c0427a1ec Add light/dark mode toggle in settings
Settings gains a "Dark mode" switch. Theme preference is device-global
(localStorage), independent of accounts. With no explicit choice the app keeps
following the OS via prefers-color-scheme; picking a mode sets data-theme on
<html>, which the CSS treats as an override (attribute selector beats the media
query). A tiny <head> script applies a saved choice before first paint to avoid
a light/dark flash. Toggling previews live, independent of Save/Cancel.

Bumps the service-worker cache. Verified in a headless-browser run: default
follows OS, enabling dark swaps the palette, the choice persists across reload,
and toggling back restores light.
2026-07-09 19:41:30 +00:00
Alexander Heldt 5c016ca49e Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it
owns. DELETE /api/me re-checks the password (guarding an unattended session),
then wipes the user's events, config, sessions and user row in one transaction
and removes their photos/<user_id>/ directory. The client clears the account's
local cache and returns to the login screen.

Bumps the service-worker cache so clients pick up the new UI.

Verified: wrong password is rejected (401, data intact); correct password
returns 204, invalidates the session, drops all rows to zero and removes the
photo dir; the email can be re-registered afterwards. Confirmed end to end in
a headless-browser run of the Settings → delete flow.
2026-07-09 19:20:40 +00:00
Alexander Heldt acf2931fb4 Add accounts and multi-tenancy
Every event, profile and photo is now scoped to a signed-in account, so
separate people can track separate puppies on one server.

Server:
- users + sessions tables; bcrypt passwords; random session tokens stored
  hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a
  valid session.
- register/login/logout/me endpoints. Registration requires a shared invite
  code (-invite-code / PUPPY_INVITE_CODE); empty disables it.
- events, config and photos are keyed by user_id; the sync upsert guards
  against cross-user overwrites and reads are scoped, so accounts are isolated.
  Photos live under photos/<user_id>/ and are only served to their owner.
- in-place schema migration adds user_id and reshapes config; legacy
  single-tenant data (including imported events.json) is parked ownerless and
  adopted by the first account to register.

Client:
- login/register gate in front of the app; the tracker only boots once the
  session check resolves. localStorage is namespaced per user.
- 401s bounce back to login; an offline reload falls back to the last cached
  session so offline-first still works. Logout clears the session and reloads.

Deployment:
- module.nix gains inviteCodeFile (secret via EnvironmentFile) and
  secureCookies options.

Verified end to end (curl + a headless-browser run of the auth flow):
isolation between accounts, invite enforcement, first-user adoption, photo
ownership, and session persistence across reload.
2026-07-09 18:20:36 +00:00
Alexander Heldt 9207aaa4aa Store events and config in SQLite
Replace the JSON-file event and config stores with a SQLite database
(modernc.org/sqlite, pure-Go so the static build keeps CGO_ENABLED=0).
Last-write-wins now rides on the upsert's WHERE clause rather than a
Go-side map compare; the sync protocol and HTTP handlers are unchanged.

On first start the server auto-imports any legacy events.json/config.json
sitting in the data dir, renaming them to *.imported. The -data flag now
points at puppy.db; photos still live on the filesystem alongside it.
2026-07-09 16:56:35 +00:00
14 changed files with 4738 additions and 265 deletions
+11
View File
@@ -0,0 +1,11 @@
# puppy-tracker — notes for Claude
- **Every user-visible change must add an entry to `src/changelog.json`**
(newest first, `{ "date": "YYYY-MM-DD", "text": "..." }`). The update banner
shows users the entries their new version adds compared to the build they're
running, so a missing entry means the change ships silently. Internal-only
changes (refactors, server plumbing) don't need one.
- Commit messages: no Claude attribution / Co-Authored-By lines.
- Run locally: `nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'`
- Architecture details are in `README.md` (offline-first PWA, LWW sync with
tombstones, per-user data scoping).
+30 -2
View File
@@ -1,7 +1,7 @@
# puppy-tracker # puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
weight. weight, and training.
The browser is the primary client; a small Go server provides a shared The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices. source-of-truth and sync between devices.
@@ -21,6 +21,11 @@ source-of-truth and sync between devices.
`config.json` sitting alongside it, renaming them to `*.imported`. `config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server - Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use. when online; static assets are still cached for offline use.
- Training exercises (name + how-to instructions) are their own synced
collection with the same contract as events (UUIDs, last-write-wins,
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
events (`type: "training"`) referencing an exercise by id, so they ride the
event sync unchanged.
- The puppy's name and birthday are a per-account profile stored on the host - The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead (`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in of being configured per-client. The client caches the last-seen values in
@@ -82,10 +87,33 @@ events, profile and photos.
- **First account adopts existing data.** When accounts are introduced on a DB - **First account adopts existing data.** When accounts are introduced on a DB
that already had single-tenant data (or that imported a legacy `events.json`), that already had single-tenant data (or that imported a legacy `events.json`),
the first account to register inherits all of it — events, profile and photos. the first account to register inherits all of it — events, profile and photos.
- **Self-service deletion.** Settings → *Delete account* removes the signed-in
account and everything it owns (`DELETE /api/me`, re-confirming the password):
events, profile, sessions and the photo directory are all wiped.
- **Serve over HTTPS in production.** Session cookies are only marked `Secure` - **Serve over HTTPS in production.** Session cookies are only marked `Secure`
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear. aren't sent in the clear.
## Pedigree lookup
Set your dog's SKK chip or registration number in **Settings** (it rides the
synced profile, next to name and birthday). Once set, a 🌳 button appears that
opens a page rendering that dog's ancestry as a tree.
- SKK has no public API, so the server drives the interactive site the way a
browser would: it resolves the id to SKK's internal dog id, fetches the
pedigree page (7 generations per request), and follows each generation's leaves
deeper. A lookup returns the first generations immediately and keeps crawling in
the background; the client polls and fills the tree in as ancestors arrive.
- Because a deep crawl is dozens of sequential upstream requests, finished trees
are cached per dog in the `pedigree_cache` table (pedigrees don't change), and
the id→dog resolution is memoised, so a dog is only ever crawled once and repeat
opens hit SKK zero times. The client also mirrors the finished tree in
`localStorage`, so the page paints instantly and shows the last-known tree even
offline.
- The lookup is behind auth like the rest of `/api/*`; the first trace of a new
dog needs to reach SKK, but after that it works from cache (including offline).
## Use it on NixOS ## Use it on NixOS
In your system flake: In your system flake:
+1 -1
View File
@@ -26,7 +26,7 @@
pname = "puppy-tracker-server"; pname = "puppy-tracker-server";
version = "0.2.0"; version = "0.2.0";
src = ./server; src = ./server;
vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU="; vendorHash = "sha256-J1lYhwbaRh2PeAh3SzyB9WgUZa1gCNXBWdaJ5isUedA=";
# Pure-Go build for a tiny static binary. # Pure-Go build for a tiny static binary.
env.CGO_ENABLED = "0"; env.CGO_ENABLED = "0";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
+63
View File
@@ -173,6 +173,9 @@ func (a *Auth) adopt(userID string) error {
if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil { if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err return err
} }
if _, err := a.db.Exec(`UPDATE exercises SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
return a.adoptPhotos(userID) return a.adoptPhotos(userID)
} }
@@ -381,3 +384,63 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
} }
writeUser(w, u) writeUser(w, u)
} }
// checkPassword reports whether password matches the stored hash for userID.
func (a *Auth) checkPassword(userID, password string) bool {
var hash string
if err := a.db.QueryRow(`SELECT password FROM users WHERE id = ?`, userID).Scan(&hash); err != nil {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// deleteAccount removes a user and everything owned by them: events, profile,
// sessions, the user row, and their photo directory. The table wipes run in one
// transaction; photos are best-effort afterwards (orphaned files are harmless).
func (a *Auth) deleteAccount(userID string) error {
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
} {
if _, err := tx.Exec(q, userID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(a.photosDir, userID)); err != nil {
log.Printf("delete photos for %s: %v", userID, err)
}
return nil
}
// handleDeleteAccount deletes the caller's own account after re-checking their
// password (guards against an unattended session). Wrapped in requireUser.
func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
var c credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
uid := userID(r)
if !a.checkPassword(uid, c.Password) {
http.Error(w, "invalid password", http.StatusUnauthorized)
return
}
if err := a.deleteAccount(uid); err != nil {
log.Printf("delete account %s: %v", uid, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -4,6 +4,7 @@ go 1.25.0
require ( require (
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
+2
View File
@@ -16,6 +16,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+102
View File
@@ -0,0 +1,102 @@
package main
// Small helpers over golang.org/x/net/html for walking the SKK pedigree markup.
import (
"strings"
"golang.org/x/net/html"
)
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// findByID returns the first element in the tree with the given id attribute.
func findByID(n *html.Node, id string) *html.Node {
if n.Type == html.ElementNode && attr(n, "id") == id {
return n
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if got := findByID(c, id); got != nil {
return got
}
}
return nil
}
// descendants returns every element with the given tag anywhere under n, in
// document order.
func descendants(n *html.Node, tag string) []*html.Node {
var out []*html.Node
var walk func(*html.Node)
walk = func(x *html.Node) {
for c := x.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == tag {
out = append(out, c)
}
walk(c)
}
}
walk(n)
return out
}
// directChildElements returns the immediate element children of n with the tag.
func directChildElements(n *html.Node, tag string) []*html.Node {
var out []*html.Node
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && c.Data == tag {
out = append(out, c)
}
}
return out
}
// findElement returns the first descendant element with the given tag.
func findElement(n *html.Node, tag string) *html.Node {
els := descendants(n, tag)
if len(els) == 0 {
return nil
}
return els[0]
}
// text concatenates all text under n.
func text(n *html.Node) string {
var sb strings.Builder
var walk func(*html.Node)
walk = func(x *html.Node) {
if x.Type == html.TextNode {
sb.WriteString(x.Data)
}
for c := x.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return sb.String()
}
// normalizeText trims and collapses internal whitespace.
func normalizeText(s string) string {
return strings.TrimSpace(wsRE.ReplaceAllString(s, " "))
}
// findBoldSpan returns the normalized text of the first bold <span> (how subject
// cells carry the registration number), or "".
func findBoldSpan(n *html.Node) string {
for _, sp := range descendants(n, "span") {
if strings.Contains(strings.ReplaceAll(attr(sp, "style"), " ", ""), "font-weight:bold") {
if t := normalizeText(text(sp)); t != "" {
return t
}
}
}
return ""
}
+309 -18
View File
@@ -1,10 +1,14 @@
package main package main
import ( import (
"bytes"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log" "log"
@@ -13,6 +17,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -23,8 +28,22 @@ type Event struct {
Type string `json:"type"` Type string `json:"type"`
At int64 `json:"at"` At int64 `json:"at"`
Note string `json:"note"` Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
// a name plus optional instruction text. Training sessions reference one by
// ExerciseID on the event. Exercises sync exactly like events: UUID ids,
// last-write-wins on UpdatedAt, tombstoned deletes.
type Exercise struct {
ID string `json:"id"`
Name string `json:"name"`
Note string `json:"note"` // instructions / reminder how to do it
UpdatedAt int64 `json:"updatedAt"` UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"` Deleted bool `json:"deleted,omitempty"`
} }
@@ -43,6 +62,9 @@ func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s)
type Config struct { type Config struct {
Name string `json:"name"` Name string `json:"name"`
Birthday string `json:"birthday"` Birthday string `json:"birthday"`
// PedigreeID is the dog's SKK chip or registration number. When set, the app
// unlocks the pedigree view and looks this dog up; empty means no pedigree.
PedigreeID string `json:"pedigreeId"`
UpdatedAt int64 `json:"updatedAt"` UpdatedAt int64 `json:"updatedAt"`
} }
@@ -59,8 +81,8 @@ func (cs *ConfigStore) get(userID string) Config {
// One profile row per user. A missing row is the pre-configuration state, // One profile row per user. A missing row is the pre-configuration state,
// so a zero-value Config is the right answer. // so a zero-value Config is the right answer.
err := cs.db.QueryRow( err := cs.db.QueryRow(
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID, `SELECT name, birthday, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt) ).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err) log.Printf("config get: %v", err)
} }
@@ -70,18 +92,32 @@ func (cs *ConfigStore) get(userID string) Config {
// merge applies an incoming config for one user with last-write-wins by // 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). // UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) { func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only // Name/birthday/updated are last-write-wins: the incoming row replaces the
// replaces the stored one when it is strictly newer. // 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(` _, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, updated) INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated name = excluded.name, birthday = excluded.birthday,
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
updated = excluded.updated
WHERE excluded.updated > config.updated`, WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.UpdatedAt) userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
if err != nil { if err != nil {
return Config{}, err 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 return cs.get(userID), nil
} }
@@ -109,11 +145,12 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// clobber another's row even if a client forges a colliding event ID — // clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them. // the row stays put and, because reads are scoped, stays invisible to them.
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id) INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note, type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight, photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`) AND events.user_id = excluded.user_id`)
@@ -127,7 +164,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
continue continue
} }
if _, err := stmt.Exec( if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID, ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -141,7 +178,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// all returns one user's events, tombstones included. // all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) { func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, updated, deleted `SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
FROM events WHERE user_id = ?`, userID) FROM events WHERE user_id = ?`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -151,7 +188,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() { for rows.Next() {
var e Event var e Event
if err := rows.Scan( if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.UpdatedAt, &e.Deleted, &e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -160,6 +197,72 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err() return out, rows.Err()
} }
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
// UpdatedAt, same user_id guard against cross-user id collisions, same
// tombstone propagation.
type ExerciseStore struct {
db *sql.DB
}
func newExerciseStore(db *sql.DB) *ExerciseStore {
return &ExerciseStore{db: db}
}
func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO exercises (id, name, note, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, note = excluded.note,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > exercises.updated
AND exercises.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, ce := range client {
if ce.ID == "" {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *ExerciseStore) all(userID string) ([]Exercise, error) {
rows, err := s.db.Query(
`SELECT id, name, note, updated, deleted
FROM exercises WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Exercise, 0)
for rows.Next() {
var e Exercise
if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// openDB opens (creating if needed) the SQLite database and ensures the schema // openDB opens (creating if needed) the SQLite database and ensures the schema
// exists. WAL mode plays nicely with concurrent readers during a sync write; // exists. WAL mode plays nicely with concurrent readers during a sync write;
// busy_timeout avoids spurious "database is locked" errors under contention. // busy_timeout avoids spurious "database is locked" errors under contention.
@@ -192,15 +295,27 @@ func openDB(path string) (*sql.DB, error) {
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0, weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0, updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0, deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '' user_id TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id); CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS config ( CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '',
pedigree_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0 updated INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -214,6 +329,13 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
created INTEGER NOT NULL, created INTEGER NOT NULL,
expires INTEGER NOT NULL expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pedigree_cache (
hundid TEXT PRIMARY KEY,
subject TEXT NOT NULL DEFAULT '',
nodes TEXT NOT NULL DEFAULT '',
generations INTEGER NOT NULL DEFAULT 0,
fetched INTEGER NOT NULL DEFAULT 0
);` );`
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
db.Close() db.Close()
@@ -244,6 +366,24 @@ func migrateSchema(db *sql.DB) error {
return err return err
} }
} }
hasExercise, err := columnExists(db, "events", "exercise_id")
if err != nil {
return err
}
if !hasExercise {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasGrams, err := columnExists(db, "events", "grams")
if err != nil {
return err
}
if !hasGrams {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN grams REAL NOT NULL DEFAULT 0`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id") oldConfig, err := columnExists(db, "config", "id")
if err != nil { if err != nil {
return err return err
@@ -269,6 +409,15 @@ func migrateSchema(db *sql.DB) error {
} }
} }
} }
hasPedigree, err := columnExists(db, "config", "pedigree_id")
if err != nil {
return err
}
if !hasPedigree {
if _, err := db.Exec(`ALTER TABLE config ADD COLUMN pedigree_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
return nil return nil
} }
@@ -378,12 +527,89 @@ type syncResponse struct {
ServerNow int64 `json:"serverNow"` ServerNow int64 `json:"serverNow"`
} }
type exerciseSyncRequest struct {
Exercises []Exercise `json:"exercises"`
}
type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type cacheControlFS struct { type cacheControlFS struct {
root http.FileSystem root http.FileSystem
} }
func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) } func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) }
// swVersion computes a short content hash over the static assets the service
// worker caches. The hash is substituted into sw.js at serve time (see
// serveSW), so the served worker changes whenever any asset changes — that byte
// difference is what makes the browser install a new worker and prompt to
// reload. Results are memoised and only recomputed when a file's size or modtime
// changes, so the steady state is a handful of cheap stats.
type swVersion struct {
dir string
files []string
mu sync.Mutex
sig string // signature of (name,size,modtime) across files
hash string
}
func newSWVersion(dir string) *swVersion {
// The assets the SW caches and that actually change between builds. sw.js
// itself is excluded: it carries the placeholder, so hashing it would be
// circular and it never changes except when we edit it here.
return &swVersion{
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg", "changelog.json"},
}
}
func (v *swVersion) hashHex() string {
v.mu.Lock()
defer v.mu.Unlock()
var sb strings.Builder
for _, name := range v.files {
if info, err := os.Stat(filepath.Join(v.dir, name)); err == nil {
fmt.Fprintf(&sb, "%s:%d:%d;", name, info.Size(), info.ModTime().UnixNano())
}
}
sig := sb.String()
if sig == v.sig && v.hash != "" {
return v.hash
}
h := sha256.New()
for _, name := range v.files {
f, err := os.Open(filepath.Join(v.dir, name))
if err != nil {
continue
}
_, _ = io.Copy(h, f)
f.Close()
}
v.sig = sig
v.hash = hex.EncodeToString(h.Sum(nil))[:12]
return v.hash
}
// serveSW renders sw.js with the current build hash substituted for its
// placeholder. no-cache lets the browser refetch and byte-compare on each
// update check; the substituted hash is what actually differs between builds.
func serveSW(w http.ResponseWriter, staticDir string, ver *swVersion) {
data, err := os.ReadFile(filepath.Join(staticDir, "sw.js"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
out := bytes.ReplaceAll(data, []byte("__BUILD_HASH__"), []byte(ver.hashHex()))
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(out)
}
func main() { func main() {
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)") addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
dataPath := flag.String("data", "puppy.db", "path to SQLite database file") dataPath := flag.String("data", "puppy.db", "path to SQLite database file")
@@ -407,6 +633,8 @@ func main() {
store := newStore(db) store := newStore(db)
configStore := newConfigStore(db) configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil { if err := os.MkdirAll(photosDir, 0o755); err != nil {
@@ -423,7 +651,16 @@ func main() {
mux.HandleFunc("/api/register", auth.handleRegister) mux.HandleFunc("/api/register", auth.handleRegister)
mux.HandleFunc("/api/login", auth.handleLogin) mux.HandleFunc("/api/login", auth.handleLogin)
mux.HandleFunc("/api/logout", auth.handleLogout) mux.HandleFunc("/api/logout", auth.handleLogout)
mux.HandleFunc("/api/me", auth.requireUser(auth.handleMe)) mux.HandleFunc("/api/me", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -449,6 +686,29 @@ func main() {
}) })
})) }))
// POST /api/exercises/sync — merge the caller's training exercises, same
// LWW contract as /api/events/sync.
mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req exerciseSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := exerciseStore.sync(userID(r), req.Exercises)
if err != nil {
log.Printf("exercises sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// GET /api/config — return the caller's puppy profile. // GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt). // PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
@@ -470,6 +730,10 @@ func main() {
if len(in.Name) > 100 { if len(in.Name) > 100 {
in.Name = in.Name[:100] in.Name = in.Name[:100]
} }
in.PedigreeID = strings.TrimSpace(in.PedigreeID)
if len(in.PedigreeID) > 64 {
in.PedigreeID = in.PedigreeID[:64]
}
if !validBirthday(in.Birthday) { if !validBirthday(in.Birthday) {
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest) http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
return return
@@ -486,6 +750,24 @@ func main() {
} }
})) }))
// POST /api/pedigree — resolve a dog by chip / registration number / name and
// return its ancestry tree (immediately for the first generations, then a
// background crawl deepens it). GET /api/pedigree/status polls that crawl.
mux.HandleFunc("/api/pedigree", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pedigrees.handleLookup(w, r)
}))
mux.HandleFunc("/api/pedigree/status", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pedigrees.handleStatus(w, r)
}))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
@@ -583,13 +865,22 @@ func main() {
if *staticDir != "" { if *staticDir != "" {
fileServer := http.FileServer(http.Dir(*staticDir)) fileServer := http.FileServer(http.Dir(*staticDir))
swVer := newSWVersion(*staticDir)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// PWA: sw.js and manifest.json must revalidate so updates propagate. // The service worker is rendered with a per-build hash of the static
if r.URL.Path == "/sw.js" || r.URL.Path == "/manifest.json" { // assets, so any asset change yields a byte-different sw.js — that's
w.Header().Set("Cache-Control", "no-cache") // what makes the browser detect an update and show the reload prompt.
if r.URL.Path == "/sw.js" {
serveSW(w, *staticDir, swVer)
return
} }
// SPA fallback: unknown paths -> index.html (so deep links work). // SPA fallback: unknown paths -> index.html (so deep links work).
if !strings.HasPrefix(r.URL.Path, "/api/") { if !strings.HasPrefix(r.URL.Path, "/api/") {
// All static assets revalidate on every request (cheap 304s via
// Last-Modified). Offline/fast loads are the service worker
// cache's job; leaving these to the browser's heuristic HTTP
// caching let a stale app.js pair with a fresh index.html.
w.Header().Set("Cache-Control", "no-cache")
candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path)) candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path))
if r.URL.Path != "/" { if r.URL.Path != "/" {
if info, err := os.Stat(candidate); err != nil || info.IsDir() { if info, err := os.Stat(candidate); err != nil || info.IsDir() {
+912
View File
@@ -0,0 +1,912 @@
package main
// Pedigree lookup: resolve a dog by chip / registration number / name against
// SKK (Svenska Kennelklubben) HUNDDATA, then crawl its ancestry and expose it as
// an ahnentafel-positioned tree. SKK has no public API, so this scrapes the
// interactive ASP.NET WebForms app the same way a browser drives it:
//
// 1. Resolve — POST Hund_sok.aspx/HundData (a JSON page-method) → hundid.
// 2. Fetch — GET Hund_Stamtavla.aspx?hundid=X, then POST ddlGenerationer=7
// to render 7 generations in one page; parse its rowspan grid.
// 3. Deepen — each generation-7 leaf links via __doPostBack; POST that link
// and read the ancestor's hundid out of the response __VIEWSTATE,
// then recurse. BFS terminates when the ancestry runs out.
//
// A deep crawl is dozens of sequential requests, so a lookup returns the first
// 7 generations immediately and keeps crawling in the background; the finished
// tree is cached per hundid (pedigrees don't change) so a dog is crawled once.
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/html"
)
const (
skkBase = "https://hundar.skk.se/hunddata/"
skkUA = "Mozilla/5.0 (X11; Linux x86_64) puppy-tracker pedigree lookup"
skkDelay = 250 * time.Millisecond // politeness between upstream requests
crawlGens = 7 // generations SKK renders per page
maxCrawlPages = 400 // hard caps so a crawl can never run away
maxCrawlRequests = 3000
crawlDeadline = 5 * time.Minute
maxActiveJobs = 4 // concurrent background crawls, total
firstPageWait = 20 * time.Second
)
// pedNode is one dog at an ahnentafel position (1 = subject, sire = 2n, dam = 2n+1).
type pedNode struct {
Reg string `json:"reg,omitempty"`
Name string `json:"name,omitempty"`
Titles string `json:"titles,omitempty"`
Hundid string `json:"hundid,omitempty"`
}
// pedSubject is the looked-up dog's headline info, from the resolver row.
type pedSubject struct {
Hundid string `json:"hundid"`
Reg string `json:"reg"`
Name string `json:"name"`
Breed string `json:"breed"`
Chip string `json:"chip"`
Sex string `json:"sex,omitempty"`
}
// skkClient is a single browser-like session against SKK (cookie jar + UA).
type skkClient struct {
hc *http.Client
}
func newSKKClient() (*skkClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &skkClient{hc: &http.Client{Jar: jar, Timeout: 30 * time.Second}}, nil
}
func (c *skkClient) do(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", skkUA)
return c.hc.Do(req)
}
// warm establishes an ASP.NET session (SessionId + anti-XSRF cookies) that the
// resolver and pedigree pages both require.
func (c *skkClient) warm(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skkBase+"Hund_sok.aspx", nil)
if err != nil {
return err
}
resp, err := c.do(req)
if err != nil {
return err
}
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
return nil
}
// hundDataRow mirrors the fields the resolver page-method returns.
type hundDataRow struct {
Hundid string `json:"hundid"`
Regnr string `json:"Regnr"`
Hundnamn string `json:"hundnamn"`
Chipnr string `json:"chipnr"`
Rastext string `json:"rastext"`
Kon string `json:"Kon"`
IDnummer string `json:"IDnummer"`
Antal string `json:"Antal"`
IsError bool `json:"IsError"`
ErrorText string `json:"ErrorText"`
}
var digitsRE = regexp.MustCompile(`^\d+$`)
// resolve turns a user query (chip number, registration number, or name) into
// matching dogs. The field is chosen by shape: a long all-digit string is a
// chip; anything with a letter or slash is a registration number; otherwise a
// name search (which may return several rows to disambiguate).
func (c *skkClient) resolve(ctx context.Context, q string) ([]hundDataRow, error) {
body := map[string]string{
"txtRegnr": "", "txtIDnummer": "", "txtChipnr": "",
"txtHundnamn": "", "ddlRasIn": "", "ddlKon": "", "txtLicensnr": "",
}
switch {
case digitsRE.MatchString(q) && len(q) >= 10:
body["txtChipnr"] = q
case strings.ContainsAny(q, "/") || strings.IndexFunc(q, isLetter) >= 0:
body["txtRegnr"] = q
default:
body["txtHundnamn"] = q
}
buf, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
skkBase+"Hund_sok.aspx/HundData", bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json;charset=utf-8")
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
resp, err := c.do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("resolver HTTP %d", resp.StatusCode)
}
var wrap struct {
D []hundDataRow `json:"d"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, fmt.Errorf("resolver response: %w", err)
}
// SKK signals "no matches" (and other soft failures like a query needing more
// input) via a single IsError row rather than an HTTP error. Treat it as an
// empty result so the caller reports a clean "not found" instead of a 502.
if len(wrap.D) == 1 && wrap.D[0].IsError {
return nil, nil
}
return wrap.D, nil
}
func isLetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
// fetchPage GETs a dog's pedigree then POSTs ddlGenerationer=7 (with titles on)
// to render 7 generations, returning that page's HTML. The returned HTML is used
// for both grid parsing and the __doPostBack calls that resolve its leaf dogs,
// so its hidden fields (viewstate / event validation) match its ctl ids.
func (c *skkClient) fetchPage(ctx context.Context, hundid string) (string, string, error) {
pageURL := skkBase + "Hund_Stamtavla.aspx?hundid=" + url.QueryEscape(hundid)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return "", pageURL, err
}
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
resp, err := c.do(req)
if err != nil {
return "", pageURL, err
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if err != nil {
return "", pageURL, err
}
first := string(raw)
form := hiddenFields(first)
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
form.Set("ctl00$bodyContent$ddlTitlar", "J")
form.Set("__EVENTTARGET", "ctl00$bodyContent$ddlGenerationer")
form.Set("__EVENTARGUMENT", "")
html7, err := c.postForm(ctx, pageURL, form)
if err != nil {
return "", pageURL, err
}
return html7, pageURL, nil
}
func (c *skkClient) postForm(ctx context.Context, pageURL string, form url.Values) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pageURL,
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", pageURL)
resp, err := c.do(req)
if err != nil {
return "", err
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if err != nil {
return "", err
}
return string(raw), nil
}
var (
viewstateRE = regexp.MustCompile(`name="__VIEWSTATE" id="__VIEWSTATE" value="([^"]+)"`)
// The clicked dog's internal hundid, encoded in the response viewstate as the
// string "hundid", a type byte (\x05), a length byte, then ASCII digits.
vsHundidRE = regexp.MustCompile(`(?s)hundid\x05.(\d+)`)
)
// postbackHundid clicks an ancestor's __doPostBack link on the given page and
// recovers that ancestor's hundid from the response viewstate. The rendered
// pedigree table never re-roots on such a click, but the viewstate carries the
// clicked dog's id — which is exactly the handle needed to fetch its own page.
func (c *skkClient) postbackHundid(ctx context.Context, pageHTML, ctlid, pageURL string) (string, error) {
form := hiddenFields(pageHTML)
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
form.Set("ctl00$bodyContent$ddlTitlar", "J")
form.Set("__EVENTTARGET", ctlid)
form.Set("__EVENTARGUMENT", "")
resp, err := c.postForm(ctx, pageURL, form)
if err != nil {
return "", err
}
m := viewstateRE.FindStringSubmatch(resp)
if m == nil {
return "", nil
}
dec := decodeB64(m[1])
mm := vsHundidRE.FindSubmatch(dec)
if mm == nil {
return "", nil
}
return string(mm[1]), nil
}
func decodeB64(s string) []byte {
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil
}
return b
}
// hiddenFields collects every <input type=hidden> on a page into a form value
// set, so an ASP.NET postback can echo back __VIEWSTATE / __EVENTVALIDATION etc.
func hiddenFields(pageHTML string) url.Values {
vals := url.Values{}
node, err := html.Parse(strings.NewReader(pageHTML))
if err != nil {
return vals
}
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "input" {
var typ, name, val string
for _, a := range n.Attr {
switch a.Key {
case "type":
typ = a.Val
case "name":
name = a.Val
case "value":
val = a.Val
}
}
if typ == "hidden" && name != "" {
vals.Set(name, val)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(node)
return vals
}
// gridCell is a parsed pedigree table cell.
type gridCell struct {
reg, name, titles, ctlid string
occupied bool
}
var doPostBackRE = regexp.MustCompile(`__doPostBack\('([^']+)'`)
var wsRE = regexp.MustCompile(`\s+`)
// parseGrid reconstructs the rowspan-based pedigree table into columns. Column c
// holds 2^c cells top-to-bottom; a cell's index within its column is its
// ahnentafel offset. Returns column index -> ordered cells.
func parseGrid(pageHTML string) map[int][]gridCell {
node, err := html.Parse(strings.NewReader(pageHTML))
if err != nil {
return nil
}
tbl := findByID(node, "bodyContent_tblStamtavla")
if tbl == nil {
return nil
}
occ := map[[2]int]bool{}
type placed struct {
r, c int
cell gridCell
}
var placedCells []placed
r := 0
for _, tr := range descendants(tbl, "tr") {
c := 0
for _, td := range directChildElements(tr, "td") {
for occ[[2]int{r, c}] {
c++
}
rs := 1
if v := attr(td, "rowspan"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
rs = n
}
}
for dr := 0; dr < rs; dr++ {
occ[[2]int{r + dr, c}] = true
}
placedCells = append(placedCells, placed{r, c, parseCell(td)})
c++
}
r++
}
byCol := map[int][]placed{}
for _, p := range placedCells {
byCol[p.c] = append(byCol[p.c], p)
}
out := map[int][]gridCell{}
for col, lst := range byCol {
sort.SliceStable(lst, func(i, j int) bool { return lst[i].r < lst[j].r })
cells := make([]gridCell, len(lst))
for i, p := range lst {
cells[i] = p.cell
}
out[col] = cells
}
return out
}
// parseCell extracts reg / name / titles / ctlid from one <td>, mirroring how
// SKK marks them up: a <a __doPostBack> holds the reg number (subject cells use
// a bold <span> instead), a <font> holds championship titles, and the last plain
// <span> holds the dog's name. "Uppgift saknas" placeholders are left unoccupied.
func parseCell(td *html.Node) gridCell {
var cell gridCell
if a := findElement(td, "a"); a != nil {
if href := attr(a, "href"); strings.Contains(href, "__doPostBack") {
cell.reg = normalizeText(text(a))
if m := doPostBackRE.FindStringSubmatch(html.UnescapeString(href)); m != nil {
cell.ctlid = m[1]
}
}
}
if f := findElement(td, "font"); f != nil {
cell.titles = normalizeText(text(f))
}
// Name: the last <span> whose text isn't the titles string. Subject cells put
// the reg in a leading bold span; if we found no link reg, adopt it.
for _, sp := range descendants(td, "span") {
t := normalizeText(text(sp))
if t == "" || t == cell.titles {
continue
}
cell.name = t
}
if cell.reg == "" {
if b := findBoldSpan(td); b != "" {
cell.reg = b
if cell.name == b {
cell.name = ""
}
}
}
if strings.EqualFold(cell.name, "Uppgift saknas") {
cell.name = ""
}
cell.occupied = cell.reg != "" || cell.name != ""
return cell
}
// crawlProgress is the mutable snapshot a running crawl publishes.
type crawlProgress struct {
Pages int
Distinct int
MaxGen int
Nodes map[string]pedNode
}
// crawl performs the breadth-first ancestry walk from a subject hundid, calling
// report after each page with a fresh snapshot. It places every dog at its global
// ahnentafel position and resolves each generation-7 leaf to a hundid to recurse.
func (c *skkClient) crawl(ctx context.Context, hundid string, report func(crawlProgress)) (map[string]pedNode, error) {
tree := map[string]pedNode{}
edges := map[string]string{} // subjectHundid|ctlid -> ancestor hundid
done := map[string]bool{}
type qitem struct {
hundid string
basePos uint64
}
queue := []qitem{{hundid, 1}}
pages, requests, maxGen := 0, 0, 0
snapshot := func() crawlProgress {
nodes := make(map[string]pedNode, len(tree))
for k, v := range tree {
nodes[k] = v
}
return crawlProgress{Pages: pages, Distinct: countDistinct(tree), MaxGen: maxGen, Nodes: nodes}
}
for len(queue) > 0 {
if err := ctx.Err(); err != nil {
return tree, err
}
if pages >= maxCrawlPages || requests >= maxCrawlRequests {
log.Printf("pedigree crawl %s: hit cap (pages=%d requests=%d)", hundid, pages, requests)
break
}
item := queue[0]
queue = queue[1:]
if done[item.hundid] {
continue
}
done[item.hundid] = true
time.Sleep(skkDelay)
pageHTML, pageURL, err := c.fetchPage(ctx, item.hundid)
requests += 2
if err != nil {
if pages == 0 {
return tree, err // couldn't even fetch the subject
}
log.Printf("pedigree crawl %s: fetch %s failed: %v", hundid, item.hundid, err)
continue
}
pages++
grid := parseGrid(pageHTML)
var frontier []struct {
gpos uint64
ctlid string
}
for col := 0; col <= crawlGens-1; col++ {
cells, ok := grid[col]
if !ok {
continue
}
for pos, cell := range cells {
if !cell.occupied {
continue
}
gpos := item.basePos*(1<<uint(col)) + uint64(pos)
key := strconv.FormatUint(gpos, 10)
node := tree[key]
node.Reg, node.Name, node.Titles = cell.reg, cell.name, cell.titles
if col == 0 {
node.Hundid = item.hundid
}
tree[key] = node
if g := bitsLen(gpos); g > maxGen {
maxGen = g
}
if col == crawlGens-1 && cell.ctlid != "" {
frontier = append(frontier, struct {
gpos uint64
ctlid string
}{gpos, cell.ctlid})
}
}
}
report(snapshot())
for _, f := range frontier {
if err := ctx.Err(); err != nil {
return tree, err
}
if requests >= maxCrawlRequests {
break
}
ekey := item.hundid + "|" + f.ctlid
hid, ok := edges[ekey]
if !ok {
time.Sleep(skkDelay)
hid, err = c.postbackHundid(ctx, pageHTML, f.ctlid, pageURL)
requests++
if err != nil {
continue
}
edges[ekey] = hid
}
if hid != "" {
key := strconv.FormatUint(f.gpos, 10)
node := tree[key]
node.Hundid = hid
tree[key] = node
queue = append(queue, qitem{hid, f.gpos})
}
}
}
report(snapshot())
return tree, nil
}
func bitsLen(x uint64) int {
n := 0
for x > 0 {
n++
x >>= 1
}
return n
}
// ---- job manager ---------------------------------------------------------
type jobState string
const (
jobRunning jobState = "running"
jobDone jobState = "done"
jobError jobState = "error"
)
type pedJob struct {
id string
hundid string
subject pedSubject
firstPage chan struct{} // closed once the subject's own page is parsed
mu sync.Mutex
state jobState
pages int
distinct int
maxGen int
nodes map[string]pedNode
errMsg string
}
func (j *pedJob) apply(p crawlProgress) {
j.mu.Lock()
j.pages, j.distinct, j.maxGen, j.nodes = p.Pages, p.Distinct, p.MaxGen, p.Nodes
j.mu.Unlock()
}
type pedManager struct {
db *sql.DB
mu sync.Mutex
jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
resolveMu sync.Mutex
resolved map[string]string // query -> hundid, so a cache hit skips SKK entirely
}
func newPedManager(db *sql.DB) *pedManager {
return &pedManager{db: db, jobs: map[string]*pedJob{}, resolved: map[string]string{}}
}
func (m *pedManager) rememberResolve(q, hundid string) {
if q == "" || hundid == "" {
return
}
m.resolveMu.Lock()
m.resolved[q] = hundid
m.resolveMu.Unlock()
}
func (m *pedManager) resolvedHundid(q string) string {
m.resolveMu.Lock()
defer m.resolveMu.Unlock()
return m.resolved[q]
}
func (m *pedManager) activeCount() int {
n := 0
for _, j := range m.jobs {
j.mu.Lock()
if j.state == jobRunning {
n++
}
j.mu.Unlock()
}
return n
}
// startOrAttach returns the running/finished job for a hundid, or starts a new
// background crawl. The boolean reports whether a fresh job was created.
func (m *pedManager) startOrAttach(client *skkClient, subject pedSubject) (*pedJob, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if j, ok := m.jobs[subject.Hundid]; ok {
return j, false, nil
}
if m.activeCount() >= maxActiveJobs {
return nil, false, errors.New("busy: too many pedigree lookups in progress, try again shortly")
}
j := &pedJob{
id: subject.Hundid,
hundid: subject.Hundid,
subject: subject,
firstPage: make(chan struct{}),
state: jobRunning,
nodes: map[string]pedNode{},
}
m.jobs[subject.Hundid] = j
go m.run(client, j)
return j, true, nil
}
func (m *pedManager) run(client *skkClient, j *pedJob) {
ctx, cancel := context.WithTimeout(context.Background(), crawlDeadline)
defer cancel()
firstDone := false
report := func(p crawlProgress) {
j.apply(p)
if !firstDone && p.Pages >= 1 {
firstDone = true
close(j.firstPage)
}
}
nodes, err := client.crawl(ctx, j.hundid, report)
if !firstDone {
close(j.firstPage) // unblock waiters even if the very first fetch failed
}
j.mu.Lock()
if err != nil && len(nodes) == 0 {
j.state = jobError
j.errMsg = err.Error()
} else {
j.state = jobDone
}
j.mu.Unlock()
if len(nodes) > 0 {
m.persist(j.subject, nodes)
}
}
// snapshot copies a job's current public state under its lock.
func (j *pedJob) snapshot() (jobState, int, int, int, map[string]pedNode, string) {
j.mu.Lock()
defer j.mu.Unlock()
nodes := make(map[string]pedNode, len(j.nodes))
for k, v := range j.nodes {
nodes[k] = v
}
return j.state, j.pages, j.distinct, j.maxGen, nodes, j.errMsg
}
// ---- persistent cache ----------------------------------------------------
func (m *pedManager) persist(subject pedSubject, nodes map[string]pedNode) {
sj, _ := json.Marshal(subject)
nj, _ := json.Marshal(nodes)
_, err := m.db.Exec(`
INSERT INTO pedigree_cache (hundid, subject, nodes, generations, fetched)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(hundid) DO UPDATE SET
subject = excluded.subject, nodes = excluded.nodes,
generations = excluded.generations, fetched = excluded.fetched`,
subject.Hundid, string(sj), string(nj), maxGenerations(nodes), time.Now().UnixMilli())
if err != nil {
log.Printf("pedigree cache save %s: %v", subject.Hundid, err)
}
}
// cached returns a stored tree for a hundid, if present.
func (m *pedManager) cached(hundid string) (pedSubject, map[string]pedNode, bool) {
var sj, nj string
err := m.db.QueryRow(
`SELECT subject, nodes FROM pedigree_cache WHERE hundid = ?`, hundid,
).Scan(&sj, &nj)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("pedigree cache get %s: %v", hundid, err)
}
return pedSubject{}, nil, false
}
var subject pedSubject
var nodes map[string]pedNode
json.Unmarshal([]byte(sj), &subject)
json.Unmarshal([]byte(nj), &nodes)
return subject, nodes, true
}
func maxGenerations(nodes map[string]pedNode) int {
max := 0
for k := range nodes {
if p, err := strconv.ParseUint(k, 10, 64); err == nil {
if g := bitsLen(p); g > max {
max = g
}
}
}
return max
}
// ---- HTTP handlers -------------------------------------------------------
type pedLookupResponse struct {
Status string `json:"status"` // done | running | choose
JobID string `json:"jobId,omitempty"`
Hundid string `json:"hundid,omitempty"`
Subject *pedSubject `json:"subject,omitempty"`
Generations int `json:"generations,omitempty"`
Nodes map[string]pedNode `json:"nodes,omitempty"`
Matches []hundDataRow `json:"matches,omitempty"`
}
func rowToSubject(r hundDataRow) pedSubject {
return pedSubject{
Hundid: r.Hundid,
Reg: strings.TrimSpace(r.Regnr),
Name: strings.TrimSpace(r.Hundnamn),
Breed: strings.TrimSpace(r.Rastext),
Chip: strings.TrimSpace(r.Chipnr),
Sex: strings.TrimSpace(r.Kon),
}
}
// handleLookup resolves a query and returns a cached tree, a disambiguation list,
// or an immediate 7-generation tree with a background job crawling deeper.
func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
var req struct {
Q string `json:"q"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
q := strings.TrimSpace(req.Q)
if q == "" {
http.Error(w, "empty query", http.StatusBadRequest)
return
}
// Fast path: if we've resolved this query before and its tree is cached, serve
// it without contacting SKK at all (repeat opens of your own dog's pedigree).
if hundid := m.resolvedHundid(q); hundid != "" {
if subj, nodes, ok := m.cached(hundid); ok {
writeJSON(w, pedLookupResponse{
Status: "done", Hundid: subj.Hundid, Subject: &subj,
Generations: maxGenerations(nodes), Nodes: nodes,
})
return
}
}
client, err := newSKKClient()
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
ctx := r.Context()
if err := client.warm(ctx); err != nil {
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
rows, err := client.resolve(ctx, q)
if err != nil {
http.Error(w, "lookup failed: "+err.Error(), http.StatusBadGateway)
return
}
rows = withHundid(rows)
switch {
case len(rows) == 0:
http.Error(w, "no dog found for "+q, http.StatusNotFound)
return
case len(rows) > 1:
writeJSON(w, pedLookupResponse{Status: "choose", Matches: rows})
return
}
subject := rowToSubject(rows[0])
m.rememberResolve(q, subject.Hundid)
if subj, nodes, ok := m.cached(subject.Hundid); ok {
writeJSON(w, pedLookupResponse{
Status: "done", Hundid: subj.Hundid, Subject: &subj,
Generations: maxGenerations(nodes), Nodes: nodes,
})
return
}
job, _, err := m.startOrAttach(client, subject)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
select {
case <-job.firstPage:
case <-time.After(firstPageWait):
case <-ctx.Done():
return
}
state, _, _, gen, nodes, msg := job.snapshot()
if state == jobError {
http.Error(w, "pedigree fetch failed: "+msg, http.StatusBadGateway)
return
}
status := "running"
if state == jobDone {
status = "done"
}
writeJSON(w, pedLookupResponse{
Status: status, JobID: job.id, Hundid: subject.Hundid,
Subject: &subject, Generations: gen, Nodes: nodes,
})
}
type pedStatusResponse struct {
Status string `json:"status"`
Pages int `json:"pages"`
Distinct int `json:"distinct"`
Generations int `json:"generations"`
Nodes map[string]pedNode `json:"nodes,omitempty"`
Error string `json:"error,omitempty"`
}
// handleStatus returns a running crawl's current partial tree so the client can
// fill the view in progressively, and the final tree when it finishes.
func (m *pedManager) handleStatus(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("job")
m.mu.Lock()
job := m.jobs[id]
m.mu.Unlock()
if job == nil {
// A finished job may have been evicted, but the cache still has the tree.
if _, nodes, ok := m.cached(id); ok {
writeJSON(w, pedStatusResponse{
Status: "done", Generations: maxGenerations(nodes),
Distinct: countDistinct(nodes), Nodes: nodes, Pages: 0,
})
return
}
http.Error(w, "unknown job", http.StatusNotFound)
return
}
state, pages, distinct, gen, nodes, msg := job.snapshot()
writeJSON(w, pedStatusResponse{
Status: string(state), Pages: pages, Distinct: distinct,
Generations: gen, Nodes: nodes, Error: msg,
})
}
// countDistinct counts unique ancestors, keyed by registration number (falling
// back to name). Pedigree collapse means one dog can fill many positions, so
// this is smaller than the number of occupied positions.
func countDistinct(nodes map[string]pedNode) int {
seen := map[string]bool{}
for _, n := range nodes {
k := n.Reg
if k == "" {
k = n.Name
}
if k != "" {
seen[k] = true
}
}
return len(seen)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(v)
}
// withHundid drops resolver rows lacking a usable hundid (defensive).
func withHundid(rows []hundDataRow) []hundDataRow {
out := rows[:0]
for _, r := range rows {
if strings.TrimSpace(r.Hundid) != "" {
out = append(out, r)
}
}
return out
}
+2154 -163
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
[
{ "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" },
{ "date": "2026-07-15", "text": "The Sleep trend y-axis is stretched above 10h, giving the hours around the sleep goal most of the chart" },
{ "date": "2026-07-15", "text": "The Sleep trend chart shows the sleep goal for your puppy's age as a shaded band — the projection chip gets a ✓ when today is on track" },
{ "date": "2026-07-15", "text": "Logging a training session while viewing another day puts it on that day (the snackbar tells you where it went)" },
{ "date": "2026-07-15", "text": "The Sleep trend yesterday line is orange instead of gray, which was hard to see" },
{ "date": "2026-07-15", "text": "The Sleep trend average line is teal now, so it doesn't blend in with today's blue line and its projection" },
{ "date": "2026-07-15", "text": "Tapping a day in a chart selects it without jumping down to the history" },
{ "date": "2026-07-15", "text": "Meals with an amount logged show their grams in the day's history" },
{ "date": "2026-07-15", "text": "Every 1-hour gridline on the sleep charts now shows its hour mark" },
{ "date": "2026-07-15", "text": "The Sleep chart in the last-N-days card now has 1-hour gridlines too" },
{ "date": "2026-07-15", "text": "The Sleep trend chart is taller with 1-hour gridlines, so nearby lines are easier to tell apart" },
{ "date": "2026-07-15", "text": "The Sleep trend legend shows the hours for each line — Today, Yesterday and the average, next to the projection" },
{ "date": "2026-07-15", "text": "The Sleep trend chart projects where today will land by midnight — a dashed tail continues today's line following the average day's rhythm" },
{ "date": "2026-07-15", "text": "The charts highlight the selected day, so it's easy to spot which bars, rows and cells you're looking at" },
{ "date": "2026-07-15", "text": "New Sleep trend chart: today's running sleep total through the day, against yesterday and the average over your chart window (7/14/30 days)" },
{ "date": "2026-07-15", "text": "Quick actions that don't fit right now are dimmed (asleep → everything but Sleep end; awake → Sleep end) — still tappable for corrections" },
{ "date": "2026-07-15", "text": "Editing an event no longer pops the date picker over the whole screen on iPhone" },
{ "date": "2026-07-15", "text": "Weight and amount fields no longer show up on events that don't use them (e.g. editing a pee)" },
{ "date": "2026-07-15", "text": "The date in the top bar is shorter (no year), so the whole bar fits on smaller iPhones" },
{ "date": "2026-07-13", "text": "The big timer is back at the top — scroll past it and it hops into the frozen bar instead" },
{ "date": "2026-07-13", "text": "The top bar fits on one row: timer, day arrows, date picker and Today" },
{ "date": "2026-07-13", "text": "Pick how many days the charts cover — 7, 14 or 30 (default 7)" },
{ "date": "2026-07-13", "text": "Much finer y-axis on the food chart" },
{ "date": "2026-07-13", "text": "The awake/asleep timer is bigger and sits first in the top bar" },
{ "date": "2026-07-13", "text": "The sleep, daily counts and food charts now cover the last 14 days instead of 7" },
{ "date": "2026-07-13", "text": "The awake/asleep timer lives in the frozen top bar" },
{ "date": "2026-07-13", "text": "The day picker is a bar frozen at the top of the page" },
{ "date": "2026-07-13", "text": "Attach multiple photos to an event — the photo picker now also offers the gallery with multi-select" },
{ "date": "2026-07-13", "text": "Track food by weight: logging a meal asks for grams (optional), with a daily total in the overview and a weekly chart" },
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
{ "date": "2026-07-12", "text": "The update banner now lists what changed in the new version" },
{ "date": "2026-07-12", "text": "Fixed adding an exercise on iPhone: stale app updates, and the keyboard's Go key discarding the input" },
{ "date": "2026-07-12", "text": "Training: define exercises with how-to reminders, log sessions in one tap, and follow a 14-day consistency view" }
]
+235 -26
View File
@@ -9,8 +9,34 @@
<link rel="icon" href="icon.svg" type="image/svg+xml" /> <link rel="icon" href="icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="icon.svg" /> <link rel="apple-touch-icon" href="icon.svg" />
<link rel="stylesheet" href="style.css" /> <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> </head>
<body> <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 <!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. --> (#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden> <div id="auth-screen" class="auth-screen" hidden>
@@ -44,6 +70,7 @@
<div id="puppy-age" class="puppy-age" hidden></div> <div id="puppy-age" class="puppy-age" hidden></div>
</div> </div>
<div class="header-actions"> <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="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> <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 id="online-status" class="status-pill"></div>
@@ -51,6 +78,26 @@
</header> </header>
<main> <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> <section id="big-clock" class="big-clock" hidden>
<div class="bc-label" id="bc-label"></div> <div class="bc-label" id="bc-label"></div>
<div class="bc-time" id="bc-time">0:00</div> <div class="bc-time" id="bc-time">0:00</div>
@@ -66,17 +113,23 @@
<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>
<section class="day-bar"> <section class="training" data-panel="training">
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button> <h2>Training</h2>
<input type="date" id="day-picker" /> <ul id="training-list" class="training-list"></ul>
<button type="button" id="day-today" class="ghost">Today</button> <p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button> <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>
<section class="overview"> <section class="overview" data-panel="overview">
<h2 id="overview-title">Today's overview</h2> <h2 id="overview-title">Today's overview</h2>
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
@@ -90,6 +143,7 @@
<div class="stat"> <div class="stat">
<div class="stat-label">Meals</div> <div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div> <div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Pees</div> <div class="stat-label">Pees</div>
@@ -99,6 +153,10 @@
<div class="stat-label">Poos</div> <div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div> <div class="stat-value" id="stat-poos">0</div>
</div> </div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div> </div>
<div class="lasts"> <div class="lasts">
@@ -106,11 +164,10 @@
<div class="last-row"><span>Last poo</span><span id="last-poo"></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 meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div> <div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row" id="currently-row" hidden><span>Currently</span><span id="currently"></span></div>
</div> </div>
</section> </section>
<section class="timing"> <section class="timing" data-panel="timing">
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2> <h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
<div class="lasts"> <div class="lasts">
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee"></span></div> <div class="last-row"><span>Typical time between pees</span><span id="gap-pee"></span></div>
@@ -121,36 +178,70 @@
<p class="muted-note timing-hint" id="timing-hint"></p> <p class="muted-note timing-hint" id="timing-hint"></p>
</section> </section>
<section class="sleep"> <section class="sleep" data-panel="sleep-windows">
<h2>Sleep windows</h2> <h2>Sleep windows</h2>
<ul id="sleep-list" class="wake-list"></ul> <ul id="sleep-list" class="wake-list"></ul>
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p> <p id="sleep-empty" class="empty">No sleep windows yet for this day.</p>
</section> </section>
<section class="wake"> <section class="wake" data-panel="wake-windows">
<h2>Wake windows</h2> <h2>Wake windows</h2>
<ul id="wake-list" class="wake-list"></ul> <ul id="wake-list" class="wake-list"></ul>
<p id="wake-empty" class="empty">No wake windows yet for this day.</p> <p id="wake-empty" class="empty">No wake windows yet for this day.</p>
</section> </section>
<section class="weekly"> <section class="weekly" data-panel="weekly">
<h2>Last 7 days</h2> <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">
<div class="chart-title">Sleep (hours)</div> <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 for the last 7 days"></svg> <svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
</div> </div>
<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 for the last 7 days"></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-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>
<section class="weight"> <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> <h2>Weight</h2>
<div class="weight-summary"> <div class="weight-summary">
<div class="stat"> <div class="stat">
@@ -171,14 +262,68 @@
<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="history"> <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> <h2>History</h2>
<ul id="event-list" class="event-list"></ul> <ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p> <p id="empty-state" class="empty">No events logged for this day.</p>
</section> </section>
</main> </main>
<footer class="app-footer">
<button type="button" id="changelog-btn" class="linklike">Changelog</button>
</footer>
</div><!-- /#app --> </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>
<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">
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>
<p id="pedigree-caption" class="pedigree-caption" hidden></p>
</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"> <dialog id="settings-dialog">
<form method="dialog" id="settings-form"> <form method="dialog" id="settings-form">
<h3>Puppy settings</h3> <h3>Puppy settings</h3>
@@ -188,10 +333,63 @@
<label>Birthday <label>Birthday
<input type="date" id="settings-birthday" /> <input type="date" id="settings-birthday" />
</label> </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>
<label class="toggle-row">
<span>Pee/poo confetti 💩</span>
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
</label>
<menu> <menu>
<button value="cancel" class="ghost">Cancel</button> <button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button> <button value="save" id="settings-save">Save</button>
</menu> </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> </form>
</dialog> </dialog>
@@ -208,13 +406,15 @@
<label id="note-weight-field" hidden>Weight (kg) <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" /> <input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
</label> </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 <label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea> <textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label> </label>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="note-photo-input" accept="image/*" multiple hidden />
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="note-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="note-photo-preview" class="photo-preview" hidden></div> <div id="note-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
@@ -236,13 +436,15 @@
<label id="edit-weight-field" hidden>Weight (kg) <label id="edit-weight-field" hidden>Weight (kg)
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" /> <input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
</label> </label>
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<label>Note <label>Note
<textarea id="edit-note" rows="4"></textarea> <textarea id="edit-note" rows="4"></textarea>
</label> </label>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="edit-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="edit-photo-preview" class="photo-preview" hidden></div> <div id="edit-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
@@ -258,6 +460,13 @@
<img id="lightbox-img" alt="" /> <img id="lightbox-img" alt="" />
</dialog> </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> <script src="app.js"></script>
</body> </body>
</html> </html>
+809 -13
View File
@@ -10,6 +10,8 @@
--pee: #ffd23f; --pee: #ffd23f;
--poo: #8a5a3b; --poo: #8a5a3b;
--weight: #2bb3a3; --weight: #2bb3a3;
--training: #b04ecf;
--note: #6f7a90;
--danger: #d64545; --danger: #d64545;
--gain: #2e9e5b; --gain: #2e9e5b;
--border: #e9e6f5; --border: #e9e6f5;
@@ -17,8 +19,12 @@
--shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05); --shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05);
} }
/* Dark palette. Two triggers, same values (CSS has no way to share them):
system prefers dark and the user hasn't forced light, OR the user picked
dark via the Settings toggle. The [data-theme] attribute (set from JS) beats
the media query on specificity, so an explicit choice always wins. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--bg: #14131a; --bg: #14131a;
--surface: #1f1e28; --surface: #1f1e28;
--text: #ebeaf2; --text: #ebeaf2;
@@ -29,6 +35,16 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3); --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
} }
} }
:root[data-theme="dark"] {
--bg: #14131a;
--surface: #1f1e28;
--text: #ebeaf2;
--muted: #9c9bab;
--accent: #a690ff;
--accent-soft: #2a2640;
--border: #2c2a3a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -63,13 +79,24 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
/* Truncate the name so a long one never runs into the action buttons. Scoped to
the header title so the auth-screen heading is unaffected. */
#app-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.puppy-age { .puppy-age {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); color: var(--muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
/* One line in the common case; on a very narrow screen it wraps rather than
truncating, so the age is never cut off. The name (#app-title) is what
truncates to keep the buttons clear. */
} }
.header-actions { .header-actions {
@@ -107,30 +134,85 @@ section {
padding: 16px; padding: 16px;
} }
.big-clock { /* Paints over the notch/status-bar strip so content scrolling behind the
text-align: center; sticky day bar never peeks through above it. Zero-height where there is no
padding: 24px 16px; safe-area inset. */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
height: env(safe-area-inset-top, 0px);
background: var(--bg);
z-index: 65;
} }
.day-bar { .day-bar {
position: sticky;
top: env(safe-area-inset-top, 0px);
z-index: 60;
display: flex; display: flex;
gap: 8px; gap: 6px;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
padding: 8px 10px; padding: 8px 10px;
} }
.day-bar input[type="date"] { /* The face button shows the short date; the real input sits invisibly behind
flex: 1; it so its native picker still anchors here (and .showPicker() has a target). */
min-width: 0; .day-date {
position: relative;
flex-shrink: 0;
display: inline-flex;
}
.day-date input[type="date"] {
position: absolute;
inset: 0;
width: 100%;
opacity: 0;
pointer-events: none;
}
#day-date-face {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.day-bar button { .day-bar button {
padding: 8px 14px; padding: 8px 10px;
flex-shrink: 0; flex-shrink: 0;
} }
/* Keep the single row intact on narrow phones. */
@media (max-width: 370px) {
.day-bar { gap: 4px; }
.day-bar button { padding: 8px 7px; }
.bar-clock { font-size: 0.9rem; padding: 6px 8px; gap: 5px; }
}
.day-bar button:disabled { .day-bar button:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.bar-clock {
display: flex;
align-items: center;
gap: 6px;
margin-right: auto; /* pin left; the flexible gap sits between it and the day controls */
padding: 7px 10px;
border-radius: 999px;
font-weight: 700;
font-size: 1rem;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.bar-clock[hidden] { display: none; }
/* Big timer still on screen: keep the pill's slot but show nothing. */
.bar-clock.standby { visibility: hidden; }
.bar-clock.asleep { background: color-mix(in srgb, var(--sleep) 18%, var(--surface)); color: var(--sleep); }
.bar-clock.awake { background: color-mix(in srgb, var(--accent) 18%, var(--surface)); color: var(--accent); }
.big-clock {
text-align: center;
padding: 24px 16px;
}
.big-clock .bc-label { .big-clock .bc-label {
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
@@ -185,6 +267,10 @@ 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
but fully tappable, so corrections are never blocked. */
button.action.unlikely { opacity: 0.4; }
button.ghost { button.ghost {
background: transparent; background: transparent;
@@ -217,6 +303,12 @@ button.danger { background: var(--danger); }
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.stat-sub {
color: var(--muted);
font-size: 0.7rem;
margin-top: 2px;
}
.stat-value { .stat-value {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
@@ -245,6 +337,7 @@ button.danger { background: var(--danger); }
font-weight: normal; font-weight: normal;
} }
.timing-hint { margin: 10px 4px 0; line-height: 1.4; } .timing-hint { margin: 10px 4px 0; line-height: 1.4; }
.settings-hint { color: var(--muted); font-size: 0.8rem; margin: -4px 0 4px; line-height: 1.4; }
.history-controls { .history-controls {
display: flex; display: flex;
@@ -343,11 +436,17 @@ textarea { resize: vertical; }
.event[data-type="pee"] .dot { background: var(--pee); } .event[data-type="pee"] .dot { background: var(--pee); }
.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="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;
@@ -389,6 +488,9 @@ dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog h3 { margin: 0 0 12px; } dialog h3 { margin: 0 0 12px; }
dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; } dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; }
/* The display rule above beats the UA [hidden] rule, so hide explicitly —
otherwise the weight/grams fields show on event types that don't use them. */
dialog label[hidden] { display: none; }
dialog label input, dialog label textarea { margin-top: 4px; } dialog label input, dialog label textarea { margin-top: 4px; }
dialog menu { dialog menu {
@@ -410,13 +512,33 @@ dialog menu {
.photo-preview { .photo-preview {
margin-top: 8px; margin-top: 8px;
width: 100%; width: 100%;
display: flex;
flex-wrap: wrap;
gap: 10px;
} }
.photo-preview img { .photo-preview[hidden] { display: none; }
.photo-thumb { position: relative; }
.photo-thumb img {
display: block; display: block;
max-width: 100%; width: 76px;
max-height: 240px; height: 76px;
object-fit: cover;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--accent-soft);
}
.photo-thumb-remove {
position: absolute;
top: -7px;
right: -7px;
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
background: var(--danger);
color: #fff;
font-size: 14px;
line-height: 1;
} }
.event .thumb { .event .thumb {
@@ -468,6 +590,21 @@ dialog menu {
.time-row input { flex: 1 1 120px; min-width: 0; } .time-row input { flex: 1 1 120px; min-width: 0; }
.time-row button { padding: 8px 12px; } .time-row button { padding: 8px 12px; }
.chart-days-picker {
display: flex;
gap: 6px;
margin: -4px 0 14px;
}
.chart-days-picker button {
padding: 5px 12px;
font-size: 0.8rem;
}
.chart-days-picker button.active {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.chart { margin-bottom: 16px; } .chart { margin-bottom: 16px; }
.chart:last-child { margin-bottom: 0; } .chart:last-child { margin-bottom: 0; }
.chart-title { .chart-title {
@@ -479,12 +616,24 @@ dialog menu {
} }
.chart-svg { display: block; width: 100%; height: auto; } .chart-svg { display: block; width: 100%; height: auto; }
.chart-svg text { fill: var(--muted); font-size: 9px; } .chart-svg text { fill: var(--muted); font-size: 9px; }
/* Dense 1h y-axes: smaller labels so every gridline can carry one. */
.chart-svg text.y-dense { font-size: 7px; }
.chart-svg .grid { .chart-svg .grid {
stroke: var(--border); stroke: var(--border);
stroke-dasharray: 2 3; stroke-dasharray: 2 3;
} }
.chart-svg .bar { cursor: pointer; } .chart-svg .bar { cursor: pointer; }
.chart-svg .bar-faded { opacity: 0.7; } .chart-svg .bar-faded { opacity: 0.7; }
/* The selected day's slice in every per-day chart: a soft accent band behind
the bars/cells (visible even when the day's values are all zero) plus an
accent-colored day label. */
.chart-svg .day-highlight {
fill: var(--accent);
fill-opacity: 0.08;
stroke: var(--accent);
stroke-opacity: 0.45;
}
.chart-svg text.day-label-sel { fill: var(--accent); font-weight: 600; }
.chart-svg .bar-sleep { fill: var(--sleep); } .chart-svg .bar-sleep { fill: var(--sleep); }
.chart-svg .bar-pee { fill: var(--pee); } .chart-svg .bar-pee { fill: var(--pee); }
.chart-svg .bar-poo { fill: var(--poo); } .chart-svg .bar-poo { fill: var(--poo); }
@@ -513,7 +662,16 @@ dialog menu {
.weight-summary .stat-value.up { color: var(--gain); } .weight-summary .stat-value.up { color: var(--gain); }
.weight-summary .stat-value.down { color: var(--danger); } .weight-summary .stat-value.down { color: var(--danger); }
.ww.weight-ww { cursor: pointer; } .ww.weight-ww { cursor: pointer; }
.ww.weight-ww .ww-dur { text-align: right; } /* Keep each weight row to one line: the date/age column shrinks and ellipses,
the weight value stays a fixed size, right-aligned and always visible. */
.ww.weight-ww .ww-range {
min-width: 0;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ww.weight-ww .ww-dur { flex: 0 0 auto; text-align: right; }
.legend { .legend {
display: flex; display: flex;
@@ -534,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;
@@ -599,3 +766,632 @@ button.linklike {
cursor: pointer; cursor: pointer;
} }
button.linklike:hover { text-decoration: underline; filter: none; } button.linklike:hover { text-decoration: underline; filter: none; }
/* ---------- delete account ---------- */
.settings-sep {
border: none;
border-top: 1px solid var(--border);
margin: 18px 0 12px;
}
.danger-block { width: 100%; }
.danger-text {
font-size: 0.9rem;
color: var(--muted);
margin: 0 0 14px;
line-height: 1.4;
}
.danger-text strong { color: var(--danger); }
/* ---------- settings toggle switch ---------- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-row > span { color: var(--text); font-size: 0.95rem; }
input.switch {
appearance: none;
-webkit-appearance: none;
width: 44px;
height: 26px;
margin: 0;
border-radius: 13px;
background: var(--border);
position: relative;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s ease;
}
input.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s ease;
}
input.switch:checked { background: var(--accent); }
input.switch:checked::after { transform: translateX(18px); }
/* ---------- update banner ---------- */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 70;
padding: calc(10px + env(safe-area-inset-top, 0)) 16px 10px;
background: var(--accent);
color: #fff;
box-shadow: var(--shadow);
}
.update-banner[hidden] { display: none; }
.update-banner-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.update-changelog {
max-width: 480px;
margin: 6px auto 0;
padding: 0 0 0 18px;
font-size: 0.8rem;
line-height: 1.45;
opacity: 0.92;
}
.update-changelog[hidden] { display: none; }
.app-footer {
text-align: center;
padding: 4px 0 24px;
}
.app-footer .linklike { color: var(--muted); font-size: 0.8rem; }
.changelog-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
max-height: 60vh;
overflow-y: auto;
}
.changelog-date {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 10px 0 4px;
}
.changelog-date:first-child { margin-top: 0; }
.changelog-entry {
font-size: 0.9rem;
line-height: 1.45;
padding: 3px 0 3px 14px;
position: relative;
}
.changelog-entry::before {
content: "•";
position: absolute;
left: 2px;
color: var(--accent);
}
.update-banner-msg { font-size: 0.9rem; font-weight: 600; }
.update-banner-actions { display: flex; gap: 8px; }
.update-banner-btn {
background: #fff;
color: var(--accent);
font-weight: 700;
padding: 6px 14px;
}
.update-banner-btn.ghostish {
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.6);
}
.update-banner-btn:hover { filter: brightness(0.97); }
/* ---------- quick-log snackbar ---------- */
.snackbar {
position: fixed;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom, 0));
transform: translate(-50%, 12px);
z-index: 60;
display: flex;
align-items: center;
gap: 8px;
max-width: calc(100% - 32px);
padding: 8px 8px 8px 16px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.snackbar[hidden] { display: none; }
.snackbar.show {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
.snackbar-msg {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.snackbar-action {
background: transparent;
color: var(--accent);
font-weight: 700;
padding: 6px 10px;
flex-shrink: 0;
}
.snackbar-action:hover { filter: none; background: var(--accent-soft); }
/* ---------- pattern charts (sleep timeline + hour heatmap) ---------- */
.chart-svg .stl-track {
fill: var(--bg);
stroke: var(--border);
stroke-width: 0.5;
}
.chart-svg .stl-sleep { fill: var(--sleep); }
.chart-svg .stl-today { fill: var(--accent); font-weight: 600; }
/* Selected day: accent ring on the row's track + accent label. */
.chart-svg .stl-track.stl-selected {
stroke: var(--accent);
stroke-width: 1.2;
stroke-opacity: 0.8;
}
.chart-svg .stl-sel { fill: var(--accent); }
.chart-svg .stl-hit { fill: transparent; cursor: pointer; }
.chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; }
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
.chart-svg .trend-today {
stroke: var(--sleep);
stroke-width: 2.5;
stroke-linejoin: round;
fill: none;
}
.chart-svg .trend-yesterday {
stroke: var(--eat); /* orange — the gray it had before sank into the grid */
stroke-width: 1.5;
stroke-linejoin: round;
fill: none;
opacity: 0.85;
}
.chart-svg .trend-avg {
stroke: var(--weight); /* teal — keeps it apart from today's blue and its blue projected tail */
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linejoin: round;
fill: none;
opacity: 0.85;
}
/* Today's projected tail: same weight/color as today so it reads as its
continuation, dashed + faded because it hasn't happened yet. */
.chart-svg .trend-projected {
stroke: var(--sleep);
stroke-width: 2.5;
stroke-dasharray: 2 4;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
opacity: 0.55;
}
.chart-svg .trend-goal { fill: var(--sleep); fill-opacity: 0.12; }
.lg.trend-goal .sw { background: color-mix(in srgb, var(--sleep) 20%, var(--surface)); }
.lg.trend-today .sw { background: var(--sleep); }
.lg.trend-projected .sw { background: color-mix(in srgb, var(--sleep) 40%, var(--surface)); }
.lg.trend-yesterday .sw { background: var(--eat); }
.lg.trend-avg .sw { background: var(--weight); }
.lg[hidden] { display: none; }
.chart-svg .hm-cell { stroke: none; }
.chart-svg .hm-pee { fill: var(--pee); }
.chart-svg .hm-poo { fill: var(--poo); }
.chart-svg .hm-eat { fill: var(--eat); }
.chart-svg .hm-training { fill: var(--training); }
/* ---------- training ---------- */
.training-list {
list-style: none;
margin: 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.training-list:empty { margin: 0; }
.exercise {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
}
.ex-row {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.ex-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ex-name { font-weight: 600; }
.ex-meta { color: var(--muted); font-size: 0.8rem; }
button.ex-log {
background: var(--training);
padding: 8px 14px;
flex-shrink: 0;
}
.ex-detail { display: none; }
.exercise.expanded .ex-detail {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.ex-note {
flex: 1;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-wrap;
}
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
.training-add { width: 100%; }
.training-chart { margin-top: 16px; }
/* ---------- collapsible panels ---------- */
section.collapsible > h2 {
cursor: pointer;
position: relative;
padding-right: 20px;
-webkit-user-select: none;
user-select: none;
}
section.collapsible > h2::after {
content: "▾";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
transition: transform 0.15s ease;
color: var(--muted);
font-size: 0.85em;
}
section.collapsed > h2::after { transform: translateY(-50%) rotate(-90deg); }
section.collapsed > h2 { margin-bottom: 0; }
section.collapsed > :not(h2) { display: none; }
/* ---------- pedigree lookup ---------- */
.pedigree-screen {
padding-top: 12px;
}
.pedigree-header {
display: flex;
align-items: center;
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;
font-size: 0.9rem;
color: var(--muted);
}
.pedigree-status.busy::before {
content: "";
display: inline-block;
width: 12px; height: 12px;
margin-right: 8px;
vertical-align: -1px;
border: 2px solid var(--accent);
border-top-color: transparent;
border-radius: 50%;
animation: ped-spin 0.8s linear infinite;
}
@keyframes ped-spin { to { transform: rotate(360deg); } }
.pedigree-status.error { color: var(--danger); }
.pedigree-status.done { color: var(--gain); }
/* disambiguation list */
.pedigree-choose {
display: grid;
gap: 8px;
margin: 8px 0 16px;
}
.ped-match {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.ped-match:hover { border-color: var(--accent); }
.ped-match-name { font-weight: 600; }
.ped-match-meta { font-size: 0.8rem; color: var(--muted); }
/* looked-up dog */
.pedigree-subject {
margin: 6px 0 14px;
padding: 12px 14px;
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
border-radius: var(--radius);
background: var(--surface);
}
.ped-subject-name { font-size: 1.15rem; font-weight: 700; }
.ped-subject-meta { font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
/* 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: 8px 0 28px;
}
.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;
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.85rem; line-height: 1.2; }
.ped-name.ped-unknown { color: var(--muted); font-weight: 500; font-style: italic; }
.ped-titles { font-size: 0.66rem; color: var(--accent); margin-top: 2px; line-height: 1.2; }
.ped-reg {
font-size: 0.66rem;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
margin-top: 1px;
}
/* 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: 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); }
/* 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: 5px;
top: 5px;
width: 18px; height: 18px;
padding: 0;
font-size: 0.9rem;
line-height: 1;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--muted);
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; }
}
+22 -3
View File
@@ -1,4 +1,10 @@
const CACHE = "puppy-tracker-v6"; // BUILD is substituted per-deploy by the server with a hash of the static
// assets (see serveSW in server/main.go), so the cache name — and therefore the
// bytes of this file — change whenever any asset changes. That byte difference
// is what makes the browser install a new worker and surface the update prompt.
// Served unsubstituted (dev / a plain static host) it stays a valid constant.
const BUILD = "__BUILD_HASH__";
const CACHE = `puppy-tracker-${BUILD}`;
const PHOTO_CACHE = "puppy-tracker-photos-v1"; const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [ const ASSETS = [
"./", "./",
@@ -7,13 +13,26 @@ const ASSETS = [
"./app.js", "./app.js",
"./manifest.json", "./manifest.json",
"./icon.svg", "./icon.svg",
"./changelog.json",
]; ];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
// cache: "reload" bypasses the browser's HTTP cache, so a new build always
// caches assets fetched fresh from the server. Without it, addAll could mix
// a fresh index.html with a heuristically-cached stale app.js and install a
// build whose markup references listeners the old script never registers.
event.waitUntil( event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS)) caches.open(CACHE).then((cache) =>
cache.addAll(ASSETS.map((u) => new Request(u, { cache: "reload" })))
)
); );
self.skipWaiting(); // No skipWaiting() here: a new worker stays in "waiting" while an old one is
// controlling a tab, so the page can prompt before swapping assets out from
// under it. The page tells us to activate via a SKIP_WAITING message.
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {