Compare commits

..
2 Commits
Author SHA1 Message Date
Alexander Heldt 3a9f822998 Remove the redundant "Currently" row from the overview
The big clock already shows the asleep/awake state prominently.
2026-07-12 16:21:46 +00:00
Alexander Heldt 55667f7165 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:21:46 +00:00
34 changed files with 658 additions and 11820 deletions
-11
View File
@@ -1,11 +0,0 @@
# 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).
+5 -287
View File
@@ -1,7 +1,7 @@
# puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, walks, meals, pees,
poos, weight, and training.
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
weight, and training.
The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices.
@@ -26,16 +26,6 @@ source-of-truth and sync between devices.
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.
- Food kinds (`Dry`, `Fresh`) are a third synced collection with the same
contract, via `POST /api/foodkinds/sync`; a meal references one by
`foodKindId`. Empty means **no kind**, which is what every meal logged before
kinds existed carries — so nothing needed migrating and nobody is made to
classify their food. Which kind a new meal starts on is a flag on the kind
itself rather than a profile field: the profile is last-write-wins across the
whole row (see the `pedigree_id` special case below), and per-item LWW lets
two devices that each chose a default resolve to the newer instead of
fighting. Each kind also keeps a fixed `colorIndex`, so deleting one never
repaints the charts of the ones around it.
- 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
of being configured per-client. The client caches the last-seen values in
@@ -45,72 +35,10 @@ source-of-truth and sync between devices.
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
per user so two accounts on one browser never mix.
- A day can be marked **not counted** (see [Days that don't
count](#days-that-dont-count)). The mark is itself an event
(`type: "day-excluded"`, timestamped at noon), so it syncs and un-marks by
tombstone like everything else.
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
`sync error` / `offline`. Tap it to force-sync.
## On screen
The panels are grouped into five tabs — **Today**, **Sleep**, **Walks**,
**Habits** (pee/poo/meal timing and counts) and **Growth** (weight, training,
notes) — so each screen holds one subject instead of all fourteen panels in one
column. The day bar and the log buttons sit above the tabs and stay put on all
of them, because logging has to be one tap from wherever you are.
- The day bar carries up to two timers, each of which logs the boundary that
ends what it is counting when tapped: the asleep/awake one, and — only while
a walk is running — the walk. They are frozen at the top on every tab, and
they are the only place a timer appears. There used to be a big card at the
top of Today as well, with the pills standing by until it scrolled out of
sight; a timer you have to scroll to, on one tab in five, is not doing the
job a timer is for. Having one place to render them is also what lets them
carry seconds — they are the display now, not a summary of one.
- **The day picker is a month grid of the app's own**, not the browser's. The
native one is a sheet covering the screen, and the reason to change day is to
see what the figures did on it — so this is a small panel under the bar, with
the overview still visible and updating as you move. `←` and `→` stay in the
bar for the common ±1 day; the grid handles jumps and carries *Today*, which
is what freed the width to fit two timers on one row. The hidden
`<input type="date">` remains the value everything reads; only its own picker
is no longer opened.
- The bar can still wrap, and does below about 300px. Its height changes when
it does and the tab bar sticks to that height, which is why `--day-bar-h` is
kept current by a `ResizeObserver` rather than measured once.
- **Long-press two event rows to measure between them.** "How long after eating
did he poo?" is answerable from the log, but only by reading two times off the
screen and subtracting — and the pair is often on different days, so it is
rarely on screen together. A bar along the bottom holds the gap until you
clear it, so changing day mid-measurement is fine. Any row that is one event
at one moment can be picked: history, notes, weigh-ins. Sleep and walk rows
cannot, being spans rather than moments. A third pick is refused while two are
held; pressing a picked row unpicks it. The picks live in a variable rather
than `localStorage` — a measurement is a question you are asking now, not a
setting — but being module-level is what carries them through the re-render a
background sync causes every minute. Long-press has no keyboard equivalent, so
this is touch and mouse only.
- Each tab is a `.tab-panel` wrapper around the existing sections. The
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
exists, and hiding them directly would clobber it.
- `render()` still draws every panel on every pass, including the tabs you
can't see. Nothing measures layout — the charts scale through their `viewBox`
— so drawing into a hidden wrapper is safe, and it means a tab is never
briefly stale when you arrive on it.
- Tapping a panel's heading still folds it away, remembered across reloads, and
composes with tabs: tabs group, folding tunes what shows within a group. The
chosen tab is remembered the same way (device-global, like the theme).
- **Back returns to Today**, from any tab, in one press; a second press leaves
the app. Exactly one history entry is ever live — armed on leaving Today and
spent on returning, whether that return came from the back button or from
tapping the tab. An entry per switch is what a browser does unaided, and is
why tabbed apps get a reputation for trapping you: flick between tabs fifteen
times and it takes fifteen presses to escape. Two presses, always, from
anywhere.
## Layout
```
@@ -121,60 +49,16 @@ puppy-tracker/
│ ├── go.mod
│ ├── go.sum
│ ├── main.go # SQLite store, LWW sync, static file serving
── auth.go # accounts, sessions, invite-gated registration, guest links
│ ├── reminders.go # reminder rules, the evaluation loop, push subscriptions
│ ├── webpush.go # VAPID + RFC 8291/8188 message encryption
│ ├── pedigree.go # SKK lookup, background crawl, per-dog cache
│ └── htmlutil.go # scraping helpers for the pedigree crawl
├── checks/ # frontend checks (see Checks); `node checks/run.mjs`
│ ├── run.mjs # runs every suite, exits non-zero on a failure
│ ├── extract.mjs # pulls declarations out of app.js so checks run real code
│ ├── assert.mjs
│ ├── excluded-days.mjs
│ ├── calendar.mjs
│ └── layout.mjs
── auth.go # accounts, sessions, invite-gated registration
└── src/ # the web app
├── index.html
├── app.js
├── style.css
├── sw.js
├── manifest.json
── changelog.json
├── icon.svg
└── icon-180.png, icon-192.png, icon-512.png
── icon.svg
```
## Checks
```sh
nix develop -c sh -c 'cd server && go test ./...' # the server
nix develop -c node checks/run.mjs # the frontend
```
The server has `go test`; `checks/` is the other half. The frontend has no
build step and no test framework, so these are plain scripts with no
dependencies beyond the `nodejs` already in the devShell. They cover the things
that are invisible until they bite:
- **Arithmetic behind the charts** — what a day marked *not counted* does and
does not take out of the numbers, and the month grid's week starts, leap
years and month boundaries.
- **Structure** — that every panel still sits in exactly one tab (losing one
while shuffling tabs is silent), that the stylesheet's braces and comments
balance, that the global `[hidden]` rule is still there.
- **Width budgets** — whether the tab labels and the two day-bar timers still
fit the phones people use.
Two things worth knowing about them. They **extract the real functions out of
`src/app.js`** rather than copying them, so a check cannot quietly go on
testing a stale copy — rename a function and `checks/extract.mjs` throws by
name. And the width figures are **estimates, not measurements**: there is no
browser in the loop, so the layout numbers are read out of `style.css` and the
text is sized from per-character advances, with a couple of pixels of headroom
demanded because the estimate is only good to a few percent. They will catch a
sixth tab or a longer label; they will not settle a two-pixel question. A real
phone is still the arbiter of anything visual.
## Run locally
```sh
@@ -210,167 +94,6 @@ events, profile and photos.
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear.
## Days that don't count
Not every logged day is equally trustworthy. A day someone else had the puppy —
a sitter who forgets half the pees, a stay at kennels — leaves a thin record
that reads exactly like a real one, and then drags the averages down and puts a
misleading trough in every chart. **Not counted**, in the overview panel's
heading, takes the day you're looking at out of the aggregates.
- **Nothing is deleted or hidden.** The day's overview, history and sleep/wake
list are unchanged — just dimmed and labelled. Navigate to it and it is all
still there.
- **What stops counting** is the behaviour: sleep hours, timeline and trend,
walk minutes and patterns, pee/poo/meal counts, food, by-hour, the training
grid, and the Timing panel's typical gaps.
- **Marking a day is not the same as deleting its events**, and only two panels
are handed a filtered list (by-hour and training, which bucket individual
events and care about neither day boundaries nor spans). The rest take the
whole log and exclude days themselves, because three things break if the
events simply go: "how long since the last pee" is a question about *now* and
answered from the wrong event; the trend curve for the day you are *looking
at* collapses to zero; and a nap from 23:00 on a marked day to 07:00 on the
next loses its `sleep-start`, leaving a dangling `sleep-end` and costing the
next day — which isn't marked — seven hours it really slept.
- **What keeps counting** is weight and notes. A weigh-in and a vet note are
records of fact, not behaviour a sparse logger distorts, so they stay on the
weight curve and in the Notes log.
- **Charts keep the day's slot**, drawn as a hatch rather than a bar. Dropping
it would make consecutive bars stop being consecutive days, and an empty bar
would read as "the puppy barely slept" — the exact misreading being fixed.
- **Gaps that reach across a marked day are discarded, not measured.** With the
day's events gone, Tuesday's last pee sits next to Thursday's first, and
subtracting invents a thirty-hour gap that would blow out the Timing panel's
"longest" far worse than the sparse day did. Sleep and walk durations need no
such care — `sleepMsInRange` / `walkMsInRange` already clip to the day being
measured, so a nap running in from a marked day contributes only its counted
part.
- **Owner-only.** A guest can't decide their own thin day shouldn't count, nor
take a good one out of the averages; the server drops `day-excluded` events
arriving on a guest session and the client hides the control.
## Guest links
A dog sitter needs to log a pee; they do not need your password. **Settings →
Guest access** mints a link that does exactly the first thing.
- **It is a session, not an account.** Opening `/guest/<token>` mints an ordinary
session row against *your* `user_id`, tagged with the link it came from. Every
data path downstream — sync, photos, the profile — is scoped by `user_id` as
before, so a guest simply is you as far as the data is concerned. Only the
capability checks differ.
- **What a guest gets.** The whole app to read: every panel, every chart, all
history. They can log new events freely, and edit or delete the ones they
logged themselves. What they don't get is anything under Settings that belongs
to the account — the puppy profile, the pedigree id, reminders, other guest
links, and deleting the account. Those routes are behind `requireOwner` and
403 for a guest; the client hides the matching UI. The two device-local
preferences (dark mode, confetti) stay, since they are the guest's own browser
and not your account.
- **A guest cannot change your logs.** The upsert in `Store.sync` only lets a
guest update rows carrying their own link's id, so a sitter can fix up their
own entries and cannot rewrite or delete a single one of yours — including
everything logged before guest links existed. The check is on the link *id*,
not its label, because two links can easily both be called "Sitter". You keep
full control either way and can edit anything on your own account, theirs
included. The exercise library is the owner's for the same reason: a guest
logs training sessions against it but the server drops any exercise a guest
sends. In the app a guest opening someone else's entry gets a read-only view
rather than a form that would throw away what they typed.
- **It expires, and you can revoke it.** You pick the last day the link should
work; it stops at the end of that day in your own timezone. Sessions minted
from a link are capped at the link's own expiry, so one can never outlive it,
and every request re-checks that the link is still live — so revoking kicks
whoever is already using it out on their very next request, not whenever their
session happens to lapse. Revoking also deletes those session rows outright.
- **The URL stays available.** Settings lists each live link by label, expiry
and when it was last used, with the URL and a *Copy* button, so a link can be
re-sent without minting a new one and stranding whoever holds the old. That
means the token is stored, not just its hash — a deliberate trade, and not the
one you would make for a password or a session token: a guest link grants a
subset of what the same database already holds in plaintext, so whoever can
read `puppy.db` gains little from it, and it expires and can be revoked
besides. The lookup column stays a hash; the secret sits beside it.
- **Events say who logged them.** An event created through a link carries that
link's label (badged in the History log) and its id (which is what authorises
changes). The server stamps both from the session on insert and never reads
them off the wire, so neither can be forged; both are left out of the update
path, so a later edit by anyone keeps the original attribution.
- **A link is a bearer token — serve over HTTPS.** Anyone holding the URL can
redeem it until it expires. Send it over something private, and run behind TLS
(`-secure-cookies`) as above. When a link ends, the guest's browser drops its
cached copy of your history rather than keeping it around.
## Reminders
Opt-in push notifications for the two things that are easy to lose track of:
"time to sleep" and "nothing logged for a while". Turn them on per rule in
**Settings**.
- **Evaluated on the server.** A closed PWA has no timers, so the browser cannot
remind you of anything on its own. The server already holds the event log
(clients sync on every mutation), so a goroutine re-checks every enabled rule
once a minute and pushes the ones that have come due.
- **Two rule shapes.** `sleep` measures from the last `sleep-end` and fires only
while the puppy is awake. `pee` / `poo` / `eat` measure from the newest event
of that type. Each rule has its own interval and repeats at that interval while
it stays overdue.
- **Quiet while the puppy sleeps.** The event rules are suppressed whenever the
latest sleep boundary says "asleep", which is what keeps them from nagging all
night — and means an overdue rule fires promptly on waking instead. The server
derives sleep state exactly the way `currentSleepState()` does in `app.js`,
tie-break included, so both sides always agree.
- **One notification per rule.** Every push carries a `tag`, so a repeat replaces
the previous notification instead of stacking another one on the lock screen.
- **Late syncs cancel a reminder retroactively.** Rules measure from the event's
own timestamp, not from when the server heard about it, so a pee logged offline
at 03:10 and synced at 03:40 resets the clock as if it had arrived on time.
- **Web Push is implemented directly** (`server/webpush.go`): RFC 8291 message
encryption in the RFC 8188 `aes128gcm` content encoding, authorized with an
RFC 8292 VAPID token. It is stdlib-only, and checked against the RFC 8291
test vector in `webpush_test.go`. Subscriptions the push service reports as
`404`/`410` are deleted.
### Requirements
- **HTTPS.** Push needs a secure context — the same reverse proxy you need for
`secureCookies`.
- **On iOS the app must be added to the Home Screen** (16.4+). Safari tabs have
no `PushManager` at all; the app detects this and says so instead of showing a
toggle that cannot work. iOS also drops subscriptions periodically, so the
client re-subscribes and re-registers its endpoint on every launch.
- **A VAPID key.** Generated into `vapid.json` next to `puppy.db` on first start,
or supplied via `-vapid-key` / `PUPPY_VAPID_KEY`. Browsers pin this key at
subscribe time: replacing it invalidates every existing subscription. If no key
can be established the server logs a warning and comes up without reminders —
the `/api/push/*` and `/api/reminders` routes are simply not registered, which
is also how the client knows to hide the UI.
Settings has a *Send a test notification* button, which is the only practical way
to tell "never subscribed" apart from "subscribed but not delivering" — push
failures are invisible from the browser side, especially on iOS.
## 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
In your system flake:
@@ -392,10 +115,6 @@ In your system flake:
# Registration secret, kept out of the Nix store. The file holds:
# PUPPY_INVITE_CODE=some-shared-secret
inviteCodeFile = "/run/secrets/puppy-invite-code";
# Optional. Without it the server generates and keeps its own Web Push
# key in /var/lib/puppy-tracker. The file holds:
# PUPPY_VAPID_KEY=base64url-p256-private-key
vapidKeyFile = "/run/secrets/puppy-vapid-key";
# Enable once you terminate TLS in front of the service.
secureCookies = false;
};
@@ -408,8 +127,7 @@ In your system flake:
The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite
database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos
alongside it under `photos/`, and a generated `vapid.json` if no `vapidKeyFile`
is set).
alongside it under `photos/`).
## Notes
-28
View File
@@ -1,28 +0,0 @@
// The smallest thing that will do. Checks print a line per assertion so a
// failure says what was expected of the code, not just which line threw.
let fails = 0;
let current = "";
export function suite(name) {
current = name;
console.log(`\n== ${name} ==`);
}
export function eq(got, want, what) {
const g = JSON.stringify(got), w = JSON.stringify(want);
if (g === w) { console.log(` ok ${what}`); return; }
console.log(` FAIL ${what}\n got ${g}\n want ${w}`);
fails++;
}
export function ok(cond, what) {
eq(Boolean(cond), true, what);
}
export function failed() { return fails; }
export function report(file) {
if (fails === 0) console.log(`\n${file}: all passed`);
else console.log(`\n${file}: ${fails} FAILED`);
return fails;
}
-62
View File
@@ -1,62 +0,0 @@
// The month grid behind the date button. Off-by-one week starts and month
// boundaries are how a hand-rolled calendar goes wrong, and none of it shows
// until somebody opens the month that breaks.
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({ names: ["ymd", "calendarGridStart"] });
const MONDAY = 1, SUNDAY = 0;
const cells = (year, monthIdx, start) => {
const gs = app.calendarGridStart(new Date(year, monthIdx, 1), start);
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(gs);
d.setDate(gs.getDate() + i);
return d;
});
};
suite("the grid starts on the locale's first weekday");
for (const [name, start] of [["Monday", MONDAY], ["Sunday", SUNDAY]]) {
const everyMonth = Array.from({ length: 12 }, (_, m) => cells(2026, m, start)[0].getDay());
eq(everyMonth.every(day => day === start), true,
`${name} start: all twelve months of 2026 open on a ${name}`);
}
suite("six rows, covering the month exactly once");
{
let alwaysSix = true, coversAll = true;
for (let m = 0; m < 12; m++) {
const cs = cells(2026, m, MONDAY);
if (cs.length !== 42) alwaysSix = false;
const inMonth = cs.filter(d => d.getMonth() === m).length;
if (inMonth !== new Date(2026, m + 1, 0).getDate()) coversAll = false;
}
eq(alwaysSix, true, "42 cells every month, so the panel never changes height");
eq(coversAll, true, "every day of every month appears exactly once");
}
suite("the months that catch people out");
{
// 1 Feb 2026 is a Sunday: under Monday weeks that needs six leading days from
// January, which is precisely what a naive `1 - getDay()` gets wrong.
const feb = cells(2026, 1, MONDAY);
eq(app.ymd(feb[0]), "2026-01-26", "Feb 2026 starts Sunday: the grid opens on 26 Jan");
eq(app.ymd(feb[6]), "2026-02-01", "…putting the 1st in the last column of row one");
eq(app.ymd(cells(2026, 1, SUNDAY)[0]), "2026-02-01",
"the same month under Sunday weeks needs no leading days at all");
eq(cells(2026, 7, MONDAY).filter(d => d.getMonth() === 7).length, 31,
"a 31-day month starting late in the week still fits");
eq(cells(2024, 1, MONDAY).filter(d => d.getMonth() === 1).length, 29, "Feb 2024 shows 29 days");
eq(cells(2026, 1, MONDAY).filter(d => d.getMonth() === 1).length, 28, "Feb 2026 shows 28");
}
suite("year boundaries");
{
eq(app.ymd(cells(2026, 0, MONDAY)[0]).startsWith("2025"), true,
"January's leading cells come from the previous December");
eq(app.ymd(cells(2026, 11, MONDAY)[41]).startsWith("2027"), true,
"December's trailing cells run into January");
}
export default report("calendar");
-106
View File
@@ -1,106 +0,0 @@
// A day marked "not counted" leaves the averages but stays on the record. The
// subtlety is that marking a day is not the same as deleting its events, and
// three panels break if you treat it that way — see the notes in render().
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({
names: [
"ymd", "startOfDay", "EXCLUDED_TYPE", "ALWAYS_COUNTS", "excludedSet",
"excludedDays", "isExcluded", "countedEvents", "spansExcluded",
"pairWindows", "sleepWindows", "sleepMsInRange", "lastEventOfType",
"gapsBetween",
],
lets: ["excludedSet"],
// gapsBetween defaults its window to the chart picker; the checks pass it in.
stubs: { chartDays: () => 7 },
});
const H = 3600_000;
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
const NOW = at(20, 12);
const mark = (day) => ({ id: `x${day}`, type: app.EXCLUDED_TYPE, at: at(day, 12) });
const marking = (events) => { app.set.excludedSet(app.excludedDays(events)); return events; };
suite("what a marked day takes out of the numbers");
{
const evs = marking([
{ id: "a", type: "pee", at: at(18, 9) },
{ id: "b", type: "pee", at: at(19, 9) },
{ id: "c", type: "pee", at: at(20, 9) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [],
"a gap reaching across a marked day is discarded rather than measured");
}
{
const evs = marking([
{ id: "d", type: "pee", at: at(18, 8) },
{ id: "e", type: "pee", at: at(18, 12) },
{ id: "f", type: "pee", at: at(20, 8) },
{ id: "g", type: "pee", at: at(20, 12) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [4 * H, 4 * H],
"…while the gaps either side of it survive");
}
{
const evs = marking([
{ id: "a", type: "pee", at: at(19, 9) },
{ id: "b", type: "pee", at: at(20, 9) },
{ id: "w", type: "weight", at: at(19, 10) },
{ id: "n", type: "note", at: at(19, 11) },
mark(19),
]);
// The marker sits on the day it marks, so it is filtered out with the rest;
// harmless, since excludedSet was read off the full list beforehand.
eq(app.countedEvents(evs).map(e => e.id).sort(), ["b", "n", "w"],
"behaviour on a marked day is dropped; a weigh-in and a note are not");
app.set.excludedSet(new Set());
eq(app.countedEvents(evs).length, evs.length, "nothing marked means nothing filtered");
}
suite("spansExcluded");
{
marking([mark(19)]);
eq(app.spansExcluded(at(19, 1), at(19, 5)), true, "wholly inside a marked day");
eq(app.spansExcluded(at(18, 23), at(20, 1)), true, "straddling one");
eq(app.spansExcluded(at(20, 1), at(20, 9)), false, "clear of one");
app.set.excludedSet(new Set());
eq(app.spansExcluded(at(18, 1), at(24, 1)), false, "nothing marked, so nothing spans");
}
// These three were live bugs: the panels were handed a list with the marked
// day's events removed, which answers a different question from the one each
// of them is asking.
suite("what a marked day must NOT take out");
{
const evs = marking([
{ id: "p1", type: "pee", at: at(19, 9) },
{ id: "p2", type: "pee", at: at(20, 10) }, // on the marked day
mark(20),
]);
const last = app.lastEventOfType(evs, "pee");
eq((NOW - last.at) / H, 2,
"Timing: 'since the last pee' is about now, so it finds the one on the marked day");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(20, 1) },
{ id: "s2", type: "sleep-end", at: at(20, 3) },
mark(20),
]);
eq(app.sleepMsInRange(evs, at(20, 0), NOW) / H, 2,
"Sleep trend: the curve for the day you are looking at shows its real hours");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(19, 23) }, // marked day
{ id: "s2", type: "sleep-end", at: at(20, 7) }, // the next, unmarked
mark(19),
]);
eq(app.sleepMsInRange(evs, at(20, 0), at(20, 24)) / H, 7,
"a nap crossing midnight still counts for the unmarked day it ends on");
}
export default report("excluded-days");
-103
View File
@@ -1,103 +0,0 @@
// Pulls named declarations out of src/app.js and evaluates them, so a check
// exercises the code that ships rather than a copy of it.
//
// The app is one long IIFE with nothing exported — it has no build step and no
// module system, and adding either to make it testable would be a large change
// in service of a small one. Reading the source back is the cheaper trade: the
// checks stay honest, and the app stays a file you can open in a browser.
//
// If a declaration is renamed or removed, load() throws by name. That is the
// point: a check that quietly tested a stale copy would be worse than no check.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "app.js");
// Blanks out comments and string bodies so brace counting can't be fooled by a
// `}` inside one. Positions are preserved, so offsets into the result are valid
// offsets into the original.
function mask(src) {
const out = src.split("");
let i = 0;
const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; };
while (i < src.length) {
const c = src[i], next = src[i + 1];
if (c === "/" && next === "/") {
const end = src.indexOf("\n", i); const stop = end === -1 ? src.length : end;
blank(i, stop); i = stop; continue;
}
if (c === "/" && next === "*") {
const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2;
blank(i, stop); i = stop; continue;
}
if (c === '"' || c === "'" || c === "`") {
let k = i + 1;
while (k < src.length) {
if (src[k] === "\\") { k += 2; continue; }
if (src[k] === c) break;
k++;
}
blank(i + 1, Math.min(k, src.length)); i = Math.min(k + 1, src.length); continue;
}
i++;
}
return out.join("");
}
// The source of one top-level declaration, brace-matched from its opening line.
function declaration(src, masked, name) {
const patterns = [
new RegExp(`^ {2}(?:async )?function ${name}\\b`, "m"),
new RegExp(`^ {2}(?:const|let) ${name}\\b`, "m"),
];
for (const re of patterns) {
const m = re.exec(masked);
if (!m) continue;
const start = m.index;
// A function runs to its matching close brace; a const/let to the newline
// after the statement that balances its own brackets.
let depth = 0, seen = false, i = start;
for (; i < masked.length; i++) {
const ch = masked[i];
if (ch === "{" || ch === "(" || ch === "[") { depth++; seen = true; }
else if (ch === "}" || ch === ")" || ch === "]") {
depth--;
if (depth === 0 && seen && ch === "}" && /function/.test(m[0])) return src.slice(start, i + 1);
} else if (ch === ";" && depth === 0 && !/function/.test(m[0])) {
return src.slice(start, i + 1);
}
}
}
throw new Error(
`checks/extract: could not find "${name}" in src/app.js.\n` +
`It was probably renamed or removed — update the check that asks for it.`);
}
/**
* load({ names, lets, stubs }) → { ...declarations, set: { <let>: fn } }
*
* names declarations to pull across, in dependency order
* lets of those, the mutable ones a check needs to reach. Each gets a setter
* and a getter: the plain export is the value at load time, so a binding
* the code reassigns (rather than mutates) would go stale and a check
* would quietly assert against a snapshot.
* stubs names the extracted code calls but which are not worth extracting —
* DOM lookups, chartDays(), and so on
*/
export function load({ names, lets = [], stubs = {} }) {
const src = readFileSync(SRC, "utf8");
const masked = mask(src);
const body = names.map(n => declaration(src, masked, n)).join("\n\n");
const stubNames = Object.keys(stubs);
const exported = names.map(n => n.replace(/^.*\s/, ""));
const setters = lets.map(n => `${n}: (v) => { ${n} = v; }`).join(", ");
const getters = lets.map(n => `${n}: () => ${n}`).join(", ");
const factory = new Function(...stubNames, `
${body}
return { ${exported.join(", ")}, set: { ${setters} }, get: { ${getters} } };
`);
return factory(...stubNames.map(n => stubs[n]));
}
export const appSource = () => readFileSync(SRC, "utf8");
-167
View File
@@ -1,167 +0,0 @@
// Kinds of food: a library of user-named labels a meal can carry. The rules
// worth holding still are the ones that decide what happens to people who
// never use the feature, and what happens to history when a kind is deleted.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
let store = [];
let synced = 0, rendered = 0;
let nextId = 0;
const app = load({
names: [
"NO_KIND", "FOOD_COLORS",
"loadFoodKinds", "saveFoodKinds", "liveFoodKinds",
"addFoodKind", "updateFoodKind", "deleteFoodKind",
"setDefaultFoodKind", "defaultFoodKindId", "foodKindNames",
],
stubs: {
foodKindsKey: () => "k",
localStorage: {
getItem: () => JSON.stringify(store),
setItem: (_, v) => { store = JSON.parse(v); },
},
uuid: () => `id${++nextId}`,
scheduleSync: () => { synced++; },
render: () => { rendered++; },
},
});
const reset = () => { store = []; nextId = 0; };
const names = () => app.liveFoodKinds().map(k => k.name);
suite("an account with no kinds behaves as it always did");
{
reset();
eq(app.liveFoodKinds(), [], "no kinds to begin with");
eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind");
eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry");
}
suite("creating kinds");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical");
eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot");
ok(synced > 0, "a new kind is queued for sync");
}
suite("the default");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so");
app.setDefaultFoodKind(dry.id);
eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default");
app.setDefaultFoodKind(fresh.id);
eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it");
eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1,
"…and unflags the old one, so there is never more than one");
app.setDefaultFoodKind(app.NO_KIND);
eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind");
}
suite("two devices that each set a default");
{
// What a sync race leaves behind: both rows flagged, different timestamps.
// Resolving to the newer beats showing two defaults or picking at random.
reset();
store = [
{ id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 },
{ id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 },
];
eq(app.defaultFoodKindId(), "b", "the more recent flag wins");
}
suite("renaming and deleting keep history readable");
{
reset();
const dry = app.addFoodKind("Dry");
app.updateFoodKind(dry.id, { name: "Dry kibble" });
eq(names(), ["Dry kibble"], "renaming changes the name in place");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…and meals pointing at the id follow it, since they resolve by id");
app.deleteFoodKind(dry.id);
eq(names(), [], "a deleted kind leaves the picker");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…but its name still resolves, so meals logged as it stay readable");
}
suite("colours survive a deletion");
{
// Deriving colour from position in the live list would repaint every past
// chart the moment a kind was removed. The index is fixed at creation.
reset();
app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
const raw = app.addFoodKind("Raw");
eq(raw.colorIndex, 2, "the third kind takes the third slot");
app.deleteFoodKind(fresh.id);
const live = app.liveFoodKinds();
eq(live.map(k => k.colorIndex), [0, 2],
"deleting the middle kind leaves the others' colours alone");
eq(app.addFoodKind("Treats").colorIndex, 3,
"and the next kind does not reuse the freed slot");
}
suite("the palette wraps rather than running out");
{
reset();
for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`);
const live = app.liveFoodKinds();
eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours");
eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0,
"…and the palette wraps, so two share rather than one having none");
}
// ---------------------------------------------- the day's split in the overview
// The Meals tile keeps the day's total; this is the breakdown under it. The
// case that matters is the one where it must not appear at all.
{
let kinds = [];
let el = { hidden: false, textContent: "" };
const view = load({
names: ["NO_KIND", "renderDayFoodKinds"],
stubs: {
document: { getElementById: () => el },
liveFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId });
const show = (evs) => { el = { hidden: false, textContent: "" }; view.renderDayFoodKinds(evs); return el; };
suite("the day's food split");
{
kinds = [];
eq(show([meal(180), meal(120)]).hidden, true,
"meals with no kind show no breakdown — the tile's total already says it");
eq(show([]).hidden, true, "a day with no meals shows nothing");
kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }];
const both = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]);
eq(both.hidden, false, "once a meal carries a kind, the breakdown appears");
eq(both.textContent, "Dry 260 g · Fresh 100 g", "…summed per kind, in the chart's order");
eq(show([meal(200, "d"), meal(50)]).textContent, "Dry 200 g · No kind 50 g",
"unlabelled food on a day that has kinds is named, not dropped");
kinds = [];
eq(show([meal(90, "gone")]).textContent, "Deleted kind 90 g",
"a kind deleted since still labels its food rather than vanishing");
kinds = [{ id: "d", name: "Dry" }];
eq(show([meal(0, "d"), meal(120, "d")]).textContent, "Dry 120 g",
"a meal logged without an amount adds nothing to the split");
}
}
export default report("food-kinds");
-335
View File
@@ -1,335 +0,0 @@
// The fit through the Food (grams) bars. The arithmetic is easy to get subtly
// wrong and the result is a sentence stating a fact about the puppy, so the
// cases that matter are the ones where it should decline to say anything.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
const app = load({ names: ["foodTrend"] });
const words = load({ names: ["foodTrendSentence"] });
// The 7/14/30 picker reaches the trend by deciding how many days weeklyData
// builds — there is no second mechanism, so this is the thing to hold still.
let windowDays = 7;
const weekly = load({
names: ["startOfDay", "endOfDay", "ymd", "weeklyData"],
stubs: {
chartDays: () => windowDays,
isExcluded: () => false,
eventsForDay: () => [],
sleepMsInRange: () => 0,
walkMsInRange: () => 0,
},
});
suite("the day picker is what sets the trend's window");
for (const n of [7, 14, 30]) {
windowDays = n;
eq(weekly.weeklyData([]).length, n, `picking ${n}d gives the charts ${n} days to fit over`);
}
windowDays = 7;
// weeklyData's shape, as far as foodTrend reads it. Today is last, as there.
const days = (grams, { excluded = [] } = {}) =>
grams.map((g, i) => ({ grams: g, excluded: excluded.includes(i) }));
suite("it declines to fit when there is nothing to fit");
{
eq(app.foodTrend(days([300, 320, 310])), null,
"three days is too few — today is dropped, leaving two, and two always fit perfectly");
eq(app.foodTrend(days([300, 320, 310, 330, 340], { excluded: [0, 1] })), null,
"marked days don't count toward the four either");
eq(app.foodTrend(days([])), null, "an empty window fits nothing");
}
suite("today is left out, being half-eaten");
{
// Four steady days then a partial today. Including today would tip the line
// down; the fit should not see it at all.
const t = app.foodTrend(days([400, 400, 400, 400, 50]));
ok(t, "four complete days are enough");
eq(Math.round(t.change), 0, "a flat run stays flat despite today being low");
eq(t.last, 3, "the line stops at the last complete day, not at today");
}
suite("it reports a direction only when the climb beats the scatter");
{
const rising = app.foodTrend(days([200, 250, 300, 350, 400, 450, 0]));
ok(rising.clear, "a clean climb is reported");
eq(Math.round(rising.change), 250, "…as the move across the five days it fitted, 200 g to 450 g");
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
ok(falling.clear, "a clean fall is reported");
ok(falling.change < 0, "…with a negative change");
// Same mean, no direction, plenty of noise: the honest answer is "steady".
const noisy = app.foodTrend(days([200, 500, 210, 480, 190, 520, 0]));
ok(!noisy.clear, "a see-saw is not a trend, however the slope comes out");
// A gentle real climb buried in large day-to-day swings: also not claimable.
const buried = app.foodTrend(days([300, 520, 180, 540, 200, 560, 0]));
ok(!buried.clear, "a slope smaller than the scatter is not reported as a trend");
}
suite("the fitted line passes through the data");
{
const t = app.foodTrend(days([100, 200, 300, 400, 500, 0]));
eq(Math.round(t.at(0)), 100, "it starts where the first day sits");
eq(Math.round(t.at(4)), 500, "and ends where the last complete day sits");
eq(Math.round(t.mean), 300, "the mean is the mean of the days it fitted");
}
suite("marked days are skipped without shifting the line");
{
// The middle day is marked; the rest describe a clean 50 g/day climb. The fit
// must ignore the hatch rather than reading it as a day of zero grams.
const t = app.foodTrend(days([200, 250, 0, 350, 400, 450, 0], { excluded: [2] }));
eq(Math.round(t.change), 250, "the climb is unchanged by the marked day");
ok(t.clear, "…and it is still clear, not drowned by a false zero");
}
suite("what the sentence is allowed to say");
{
const say = (grams, opts, win = 14) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), win);
const rising = say([200, 250, 300, 350, 400, 450, 0]);
ok(/up about 250 g/.test(rising), "a clear climb gives the size of the move");
ok(/from roughly 200 g a day to 450 g/.test(rising), "…and the figures at each end");
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
// The defect this replaced: the move was quoted per week while the fit spans
// at most five days on a 7-day window, so the figure and the two endpoints
// disagreed and a reader who subtracted them found the sentence wrong.
// Whatever the window, the three numbers in the sentence must reconcile.
for (const [label, grams, win] of [
["a steep 7-day fall", [460, 425, 390, 355, 320, 285, 0], 7],
["a long 14-day climb", [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 0], 14],
["a gentle 30-day climb", [...Array(29).fill(0).map((_, i) => 300 + i * 12), 0], 30],
]) {
const s = say(grams, undefined, win);
const m = s.match(/about (\d+) g — from roughly (\d+) g a day to (\d+) g/);
ok(m, `${label}: the sentence has all three figures`);
if (m) {
const [, moved, from, to] = m.map(Number);
eq(moved, Math.abs(to - from), `${label}: the move is exactly the difference of the two ends`);
ok(new RegExp(`is ${to > from ? "up" : "down"} about`).test(s),
`${label}: and the direction matches which end is larger`);
}
}
const steady = say([300, 302, 298, 301, 299, 300, 0]);
ok(/roughly steady/.test(steady), "a flat run is called steady");
ok(/averaging about 300 g a day/.test(steady),
"…and quotes the average, which is a measurement rather than model output");
ok(!/then/.test(steady) && !/ now\b/.test(steady),
"…but not fitted endpoints, which would dress up a line nobody should read");
const noisy = say([200, 500, 210, 480, 190, 520, 0]);
ok(/roughly steady/.test(noisy) && /variation is larger/.test(noisy),
"a see-saw says the variation beat the trend, rather than quoting a slope");
const none = say([300, 320, 310]);
ok(/Not enough complete days/.test(none) && /needs four/.test(none),
"too few days explains itself instead of leaving the chart bare");
// False precision would make a fit look like a reading.
ok(/\b\d*[05] g a day/.test(rising), "figures are rounded to 10 g, not quoted to the gram");
const falling = say([450, 400, 350, 300, 250, 200, 0]);
ok(/down about/.test(falling), "a clear fall says down");
}
// ---------------------------------------------------------------- by kind
// Splitting the bars must not change what the chart says for anyone who never
// defines a kind, and the per-kind caption must stay bounded as kinds are added.
{
let kinds = [];
const split = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
"foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
// days carrying a per-kind split, as weeklyData builds them.
const byKind = (rows) => rows.map(r => ({
grams: Object.values(r).reduce((s, v) => s + v, 0),
gramsByKind: r,
excluded: false,
meals: 0, mealsMissingGrams: 0,
}));
suite("an unsplit chart is unchanged");
{
kinds = [];
const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
const series = split.foodSeriesFor(days);
eq(series.length, 1, "no kinds defined gives exactly one series");
eq(series[0].name, "No kind", "…the unnamed one");
const s = split.foodSeriesSentences(series, 7);
eq(s.length, 1, "…and one sentence, as before kinds existed");
ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
}
suite("the split adds up");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
{ d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
]);
const series = split.foodSeriesFor(days);
eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
for (const d of days) {
const summed = series.reduce((acc, s) => acc + s.of(d), 0);
eq(summed, d.grams, "each day's segments sum to the day's own total");
}
}
suite("a kind with nothing logged is left out");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
"an unused kind gets no segment and no legend entry");
}
suite("a deleted kind's food still appears");
{
// The kind is gone from the picker but meals still point at it, and that
// food is real — it has to show under the name the tombstone kept.
kinds = [];
const namesOnly = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => [],
// The tombstone: gone from the picker, still carrying name and colour.
loadFoodKinds: () => [{ id: "gone", name: "Old recipe", colorIndex: 2, deleted: true }],
},
});
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
const series = namesOnly.foodSeriesFor(days);
eq(series.map(s => s.name), ["Old recipe"],
"it keeps its name rather than vanishing or reading as 'No kind'");
eq(series[0].colorIndex, 2,
"…and its colour, which grey would confuse with the 'No kind' series");
}
suite("the caption stays bounded as kinds are added");
{
kinds = [
{ id: "a", name: "Dry", colorIndex: 0 },
{ id: "b", name: "Fresh", colorIndex: 1 },
{ id: "c", name: "Raw", colorIndex: 2 },
{ id: "e", name: "Treats", colorIndex: 3 },
];
// Dry climbs clearly; the rest are flat or noise.
const days = byKind([
{ a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
{ a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
{ a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
]);
const series = split.foodSeriesFor(days);
const lines = split.foodSeriesSentences(series, 7);
ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
ok(lines.some(l => /no clear trend/.test(l)),
"…and the rest are folded into one clause rather than a sentence each");
}
suite("no kind moves at all");
{
kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
{ a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
]);
const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
eq(lines.length, 1, "one line when nothing is claimable");
ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
}
}
// ------------------------------------------------- the highlighted day's readout
// Tapping a bar selects that day; this is what the selection says. It reads off
// the existing selection rather than keeping its own, so the two cannot drift.
{
let kinds = [];
let selected = "2026-09-20";
let el = { hidden: false, textContent: "" };
const info = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor", "renderFoodDayInfo"],
stubs: {
document: { getElementById: () => el },
selectedDay: () => new Date(selected + "T12:00:00"),
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const day = (n, byKind, excluded = false) => ({
ymd: `2026-09-${String(n).padStart(2, "0")}`,
date: new Date(2026, 8, n),
grams: Object.values(byKind).reduce((s, v) => s + v, 0),
gramsByKind: byKind, excluded, meals: 0, mealsMissingGrams: 0,
});
const read = (days) => {
el = { hidden: false, textContent: "" };
info.renderFoodDayInfo(days, info.foodSeriesFor(days));
return el;
};
suite("the highlighted day's breakdown");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = [
day(18, { d: 200, f: 100 }), day(19, { d: 210, f: 90 }), day(20, { d: 260, f: 100 }),
];
selected = "2026-09-20";
const r = read(days);
eq(r.hidden, false, "the selected day gets a readout");
ok(/Dry 260 g · Fresh 100 g/.test(r.textContent), "each kind's amount, in the stack's order");
ok(/360 g in total/.test(r.textContent), "…and the total, so you needn't add them up");
ok(/Sep 20/.test(r.textContent), "…named, so it is clear which bar it belongs to");
selected = "2026-09-18";
ok(/Dry 200 g/.test(read(days).textContent), "selecting another bar moves the readout");
// Out of the window entirely: the chart is not showing that day at all.
selected = "2026-08-01";
eq(read(days).hidden, true, "a day outside the window has no bar and so no readout");
}
suite("the days that say something else");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
selected = "2026-09-20";
const withEmpty = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {})];
ok(/no food logged/.test(read(withEmpty).textContent), "a day with no food says so");
const withExcluded = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {}, true)];
ok(/not counted/.test(read(withExcluded).textContent),
"a day marked not counted says that instead of reading as empty");
}
suite("an unsplit chart gets the day total too");
{
// No kinds defined: there is still no hover on a phone, so the figure was
// only readable by eye off the axis.
kinds = [];
selected = "2026-09-20";
const days = [day(18, { "": 300 }), day(19, { "": 320 }), day(20, { "": 340 })];
const r = read(days);
eq(r.hidden, false, "the readout appears without any kinds defined");
eq(r.textContent, "Sun, Sep 20 — 340 g.", "…as the plain total, named by day");
ok(!/No kind/.test(r.textContent),
"…without inventing a kind name for food that has none");
ok(!/in total/.test(r.textContent),
"…and without saying the same number twice");
}
}
export default report("food-trend");
-196
View File
@@ -1,196 +0,0 @@
// Structural facts about the markup and stylesheet that no amount of reading
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
// removed, a label that truncates on a phone nobody here owns.
//
// The width arithmetic is an estimate, not a measurement — there is no browser
// in this loop. Layout numbers are read out of style.css so they cannot drift
// from the real ones, text is sized from per-character advances, and the pass
// mark insists on a few pixels of headroom because the estimate is only good
// to a few percent. It will catch a sixth tab or a longer label; it will not
// settle a two-pixel question.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { suite, eq, ok, report } from "./assert.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const html = readFileSync(join(root, "src", "index.html"), "utf8");
const css = readFileSync(join(root, "src", "style.css"), "utf8");
// ---------------------------------------------------------------- stylesheet
suite("the stylesheet is structurally intact");
{
let depth = 0, pairs = 0, stray = 0;
for (let i = 0; i < css.length - 1; i++) {
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
}
eq(depth, 0, `comments open and close in pairs (${pairs})`);
eq(stray, 0, "no stray comment terminators");
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
// Author rules that set a display beat the user agent's [hidden] rule, so
// hidden elements keep rendering without this. It has caught five so far.
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
"the global [hidden] rule is present");
}
// ------------------------------------------------------------------ the tabs
suite("every panel lives in exactly one tab");
{
const EXPECTED = [
"overview", "sleep-wake", "history",
"sleep-daily", "sleep-timeline", "sleep-trend",
"walks", "walk-timeline", "walk-trend",
"timing", "counts",
"weight", "training", "notes",
];
const main = html.slice(html.indexOf(" <main>"), html.indexOf(" </main>"));
let current = null;
const placement = new Map();
const wrappers = [];
for (const line of main.split("\n")) {
const open = line.match(/^ {6}<div class="tab-panel" data-tab="([^"]+)"/);
if (open) { current = open[1]; wrappers.push(current); continue; }
if (/^ {6}<\/div>/.test(line) && current) { current = null; continue; }
const panel = line.match(/data-panel="([^"]+)"/);
if (panel && /^ {8}<section/.test(line)) {
if (!placement.has(panel[1])) placement.set(panel[1], []);
placement.get(panel[1]).push(current);
}
}
const missing = EXPECTED.filter(k => !placement.has(k));
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
eq(missing, [], "no panel has gone missing");
eq(duplicated, [], "no panel appears twice");
eq(loose, [], "no panel sits outside a tab");
eq(unexpected, [], "no unaccounted-for panel has appeared");
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
eq(buttons, wrappers, "a button for each tab, in the same order");
const ariaOk = buttons.every(t =>
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
for (const k of ["walk-timeline", "walk-trend"]) {
ok(new RegExp(`<section[^>]*data-panel="${k}"`).test(html),
`"${k}" is still a <section>, which renderWalkPatterns queries for`);
}
}
// ----------------------------------------------------------------- the widths
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
const em = (s) => [...s].reduce((n, ch) => {
if (ch >= "0" && ch <= "9") return n + ADV.digit;
if (ch === ":" || ch === " ") return n + ADV.thin;
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
if (ch === "←" || ch === "→") return n + 0.6;
return n + (NARROW[ch] ?? ADV.lower);
}, 0);
const EMOJI = 18;
const BODY_GUTTER = 16;
const rule = (re) => (css.match(re) || [, ""])[1] || "";
const px = (block, prop, dflt) => {
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
};
const DEVICES = [
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
];
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
suite("the tab labels fit without truncating");
{
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+)</g)].map(m => m[1]);
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
const need = em(widest) * v.font;
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
}
}
suite("both day-bar timers fit on a row of their own");
{
// The bar may wrap when it must; what it must not do is squash the timers.
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
const narrowPill = inNarrow("\\.bar-clock");
const narrow = { font: px(narrowPill, "font-size", wide.font),
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
const barPadX = 10, groupGap = 6;
const worst = "3:12:45"; // both timers past an hour
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
ok(room - (2 * pill + groupGap) >= 2,
`${String(w).padStart(4)}px ${name}: two timers fit ` +
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
}
}
// Anything wider than the screen makes the whole document wider than the
// viewport, and a phone responds by letting you zoom out — which is how this
// class of bug is usually noticed, long after it was introduced.
suite("nothing can push the page wider than the screen");
{
// Free text the user types has no width limit of its own. A long unbroken
// token — a URL in a note, a chemical name off a food bag — sets a flex
// item's content-based minimum, or simply spills out of its box, and either
// way it widens the document. Every element that renders user input needs a
// break rule; this is the list, and it is easier to extend than to remember.
const USER_TEXT = [
"\\.event \\.note", // a note on a history row
"\\.event \\.note-text", // the Notes log
"\\.ex-name", // exercise names
"\\.ex-note", // exercise instructions
"\\.guest-item-label", // the label on a guest link
];
for (const sel of USER_TEXT) {
const block = rule(new RegExp(`\\n${sel}[^{]*\\{([^}]*)\\}`));
ok(/overflow-wrap:\s*(anywhere|break-word)/.test(block),
`${sel.replace(/\\/g, "")} can break a long unbroken word`);
}
// The month grid is the one panel positioned against something narrower than
// the page. Centred on the date button it hung off the right of a phone; it
// is anchored to the day bar instead, which spans the content width.
const dayCal = rule(/\n\.day-cal \{([^}]*)\}/);
ok(/right:/.test(dayCal) && !/left:\s*50%/.test(dayCal),
"the month grid is edge-anchored, not centred on the date button");
ok(!/max-width:[^;]*vw/.test(dayCal),
"…and bounded by its container rather than by the viewport");
const main = html.slice(html.indexOf('<section class="day-bar">'),
html.indexOf("</section>", html.indexOf('<section class="day-bar">')));
ok(/id="day-cal"/.test(main), "…and sits inside the day bar, which is what it is measured against");
// A fixed width wider than the narrowest content box cannot fit by
// definition. 320px phone, less the body's two 16px gutters.
const NARROWEST = 320 - 2 * BODY_GUTTER;
const tooWide = [...css.matchAll(/(?:^|[;{\s])(width|min-width):\s*(\d{3,})px/g)]
.filter(m => Number(m[2]) > NARROWEST)
.map(m => `${m[1]}: ${m[2]}px`);
eq(tooWide, [], `no fixed width exceeds a ${NARROWEST}px content box`);
}
export default report("layout");
-98
View File
@@ -1,98 +0,0 @@
// Long-press two rows and the app subtracts their times. The press itself
// needs a finger, but everything it decides — which picks are held, what the
// bar says — is ordinary logic, and that is where this can go quietly wrong.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
let rendered = 0;
const app = load({
names: [
"EVENT_LABELS", "ymd", "formatDuration", "formatTime",
"measurePick", "toggleMeasurePick", "clearMeasure",
"measureSummary", "measureLabel",
],
lets: ["measurePick"],
stubs: { render: () => { rendered++; } },
});
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
const ate = { id: "a", type: "eat", at: at(20, 12, 10) };
const poo = { id: "b", type: "poo", at: at(20, 15, 52) };
const pee = { id: "c", type: "pee", at: at(20, 18, 30) };
const lateEat = { id: "d", type: "eat", at: at(19, 18, 30) }; // the evening before
const events = [ate, poo, pee, lateEat];
const pick = (...ids) => { app.set.measurePick([]); ids.forEach(app.toggleMeasurePick); };
const held = () => app.get.measurePick();
suite("what a press does to the pick");
{
pick("a");
eq(held(), ["a"], "one press holds one");
pick("a", "b");
eq(held(), ["a", "b"], "a second press holds the pair");
// The user's choice: a third is refused rather than rolling the pair on.
pick("a", "b", "c");
eq(held(), ["a", "b"], "a third press is ignored while two are held");
// Not a third selection but an undo of one — a mis-press costs one press
// rather than starting over.
pick("a", "b");
app.toggleMeasurePick("a");
eq(held(), ["b"], "pressing a picked row unpicks it");
app.toggleMeasurePick("c");
eq(held(), ["b", "c"], "…leaving room for a different second");
pick("a", "b");
app.clearMeasure();
eq(held(), [], "clearing drops both");
}
suite("the reading");
{
const two = app.measureSummary(["a", "b"], events);
ok(two.show && two.complete, "two picks give a complete reading");
eq(two.duration, "3h 42m", "12:10 to 15:52 is 3h 42m");
// Pressed newest-first, which is the natural way to scan a log upward.
const reversed = app.measureSummary(["b", "a"], events);
eq(reversed.duration, "3h 42m", "the order they were pressed in doesn't change the gap");
eq(reversed.text, two.text, "…and it still reads chronologically, earliest first");
ok(/Ate/.test(two.text) && /Poo/.test(two.text), "both events are named");
}
suite("a pair that straddles midnight");
{
const overnight = app.measureSummary(["d", "b"], events); // 19th 18:30 → 20th 15:52
eq(overnight.duration, "21h 22m", "the gap crosses the day boundary correctly");
ok(/Sep/.test(overnight.text),
"the dates are named, since two bare times would be ambiguous across days");
ok(!/Sep/.test(app.measureSummary(["a", "b"], events).text),
"…but a same-day pair stays uncluttered");
}
suite("an incomplete or stale pick");
{
const one = app.measureSummary(["a"], events);
ok(one.show && !one.complete, "one pick shows the bar without a duration");
ok(/long-press another/.test(one.text), "…and asks for the second");
eq(app.measureSummary([], events).show, false, "nothing picked hides the bar");
// Deleted here, or tombstoned by another device mid-measurement.
const stale = app.measureSummary(["a", "gone"], events);
eq(stale.ids, ["a"], "an id that no longer resolves is dropped from the pick");
ok(!stale.complete, "…so what is left is one pick, not a broken pair");
eq(app.measureSummary(["gone", "also-gone"], events).show, false,
"both gone hides the bar rather than showing an empty one");
}
suite("the label");
{
eq(app.measureLabel(ate), `Ate ${app.formatTime(ate.at)}`, "type and time");
ok(/Sep 20/.test(app.measureLabel(ate, true)), "with the date when asked for");
}
export default report("measure");
-53
View File
@@ -1,53 +0,0 @@
// Runs every check. Exits non-zero if any assertion failed.
//
// nix develop -c node checks/run.mjs
//
// These sit beside the Go tests rather than replacing them: the server has
// `go test`, and this is the frontend's half — the arithmetic behind the
// charts, the structure of the markup, and the width budgets that no one here
// can see. It needs no build step and no dependencies; node is already in the
// devShell for `node --check`.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { readdirSync } from "node:fs";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
let failed = 0;
// The frontend has no build step, so a syntax error would otherwise only show
// up in a browser nobody here is running.
for (const file of ["app.js", "sw.js"]) {
try {
execFileSync(process.execPath, ["--check", join(root, "src", file)], { stdio: "pipe" });
console.log(` ok src/${file} parses`);
} catch (e) {
console.log(` FAIL src/${file}\n${e.stderr?.toString() ?? e.message}`);
failed++;
}
}
try {
JSON.parse(readdirSync(join(root, "src")).includes("changelog.json")
? (await import("node:fs")).readFileSync(join(root, "src", "changelog.json"), "utf8")
: "[]");
console.log(" ok src/changelog.json parses");
} catch (e) {
console.log(` FAIL src/changelog.json: ${e.message}`);
failed++;
}
const suites = readdirSync(here)
.filter(f => f.endsWith(".mjs") && !["run.mjs", "extract.mjs", "assert.mjs"].includes(f))
.sort();
for (const file of suites) {
const mod = await import(join(here, file));
failed += mod.default ?? 0;
}
console.log(failed === 0
? `\nall checks passed (${suites.length} suites)`
: `\n${failed} assertion(s) FAILED`);
process.exit(failed === 0 ? 0 : 1);
-77
View File
@@ -1,77 +0,0 @@
// The sleep and walk trends draw the selected day against yesterday and the
// window average. A day marked "not counted" has to be absent from all three,
// and the one that kept slipping through was the selected day itself — it is
// the boldest line on the panel, so it reads as the answer.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
const DAY = 86_400_000;
const SEL = new Date(2026, 8, 20); // the day under the cursor
const at = (day, hour) => new Date(2026, 8, day, hour).getTime();
let excluded = new Set();
let windowDays = 7;
const curves = load({
names: ["startOfDay", "pairWindows", "sleepWindows", "sleepTrendCurves"],
stubs: {
selectedDay: () => SEL,
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
isExcluded: (d) => excluded.has(d.getDate()),
chartDays: () => windowDays,
},
});
// A night's sleep on each of several days, so every curve has something to draw.
const slept = (day, fromHour, toHour) => ([
{ id: `s${day}a`, type: "sleep-start", at: at(day, fromHour) },
{ id: `s${day}b`, type: "sleep-end", at: at(day, toHour) },
]);
const week = [16, 17, 18, 19, 20].flatMap(d => slept(d, 1, 5));
suite("the selected day's own curve");
{
excluded = new Set();
const c = curves.sleepTrendCurves(week);
ok(c.today && c.today.length > 1, "a normal day is drawn");
eq(c.dayExcluded, false, "…and not flagged as excluded");
excluded = new Set([20]); // the selected day
const m = curves.sleepTrendCurves(week);
eq(m.today, null, "a day marked 'not counted' is not drawn at all");
eq(m.dayExcluded, true, "…and says so, so the legend can drop its chip");
eq(m.projected, null, "…and nothing is projected from a curve that isn't there");
ok(m.avg && m.avg.length > 1, "the average it would have been read against survives");
ok(m.yesterday && m.yesterday.length > 1, "so does yesterday");
}
suite("the comparison day and the average");
{
excluded = new Set([19]); // yesterday, relative to the 20th
const c = curves.sleepTrendCurves(week);
eq(c.yesterday, null, "a marked yesterday is dropped rather than drawn flat");
ok(c.today && c.today.length > 1, "the selected day is unaffected by it");
// Every day but the selected one marked: nothing left to average over.
excluded = new Set([16, 17, 18, 19]);
const none = curves.sleepTrendCurves(week);
eq(none.avg, null, "an average with no days left to average is null, not zero");
ok(none.today && none.today.length > 1, "…and the selected day still draws");
}
suite("a marked day never contributes to the average");
{
// The 19th sleeps far longer than the rest. With it counted the average is
// dragged up; marked, it should leave no trace.
const lopsided = [...[16, 17, 18].flatMap(d => slept(d, 1, 3)), ...slept(19, 1, 23), ...slept(20, 1, 3)];
windowDays = 7;
excluded = new Set();
const withIt = curves.sleepTrendCurves(lopsided).avg[24].y;
excluded = new Set([19]);
const without = curves.sleepTrendCurves(lopsided).avg[24].y;
ok(withIt > without, "marking the outlier lowers the average it was inflating");
eq(Math.round(without), 2, "…back to the two hours the remaining days actually slept");
}
export default report("trend-curves");
+2 -4
View File
@@ -26,7 +26,7 @@
pname = "puppy-tracker-server";
version = "0.2.0";
src = ./server;
vendorHash = "sha256-J1lYhwbaRh2PeAh3SzyB9WgUZa1gCNXBWdaJ5isUedA=";
vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU=";
# Pure-Go build for a tiny static binary.
env.CGO_ENABLED = "0";
ldflags = [ "-s" "-w" ];
@@ -59,9 +59,7 @@
};
devShells.default = pkgs.mkShell {
# nodejs is here for `node --check` on src/*.js — the frontend has no
# build step, so this is a syntax-check tool, not a dependency.
packages = [ pkgs.go pkgs.python3 pkgs.nodejs ];
packages = [ pkgs.go pkgs.python3 ];
shellHook = ''
echo "puppy-tracker dev shell"
echo " cd server && go run . -static ../src -data /tmp/puppy-events.json"
+3 -18
View File
@@ -39,20 +39,6 @@ in
'';
};
vapidKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/puppy-vapid-key";
description = ''
Path to an EnvironmentFile containing the Web Push signing key as
`PUPPY_VAPID_KEY=...` (a base64url P-256 private scalar). When null the
server generates one on first start and keeps it in its state directory,
which is fine for a single host. Note that browsers pin this key when
they subscribe: replacing it silently breaks every existing reminder
subscription until each device re-enables notifications.
'';
};
secureCookies = lib.mkOption {
type = lib.types.bool;
default = false;
@@ -92,10 +78,9 @@ in
"-data /var/lib/puppy-tracker/puppy.db"
] ++ lib.optional cfg.secureCookies "-secure-cookies");
# Secrets (registration code, Web Push key) are read from env files kept
# out of the store, exposed to the server as PUPPY_INVITE_CODE and
# PUPPY_VAPID_KEY.
EnvironmentFile = lib.filter (f: f != null) [ cfg.inviteCodeFile cfg.vapidKeyFile ];
# Invite code (registration secret) is read from an env file kept out of
# the store, exposed to the server as PUPPY_INVITE_CODE.
EnvironmentFile = lib.mkIf (cfg.inviteCodeFile != null) cfg.inviteCodeFile;
DynamicUser = true;
StateDirectory = "puppy-tracker";
+34 -367
View File
@@ -23,50 +23,20 @@ import (
const (
sessionCookie = "puppy_session"
sessionValidity = 30 * 24 * time.Hour
// A guest link's last_used is only refreshed this often, so "last used" can
// be shown in Settings without a write on every single request.
lastUsedResolution = 5 * time.Minute
// How far ahead a guest link may be set to expire. The owner picks the date,
// so this is only a backstop against a mistyped year turning a sitter's link
// into a permanent credential.
maxShareAhead = 365 * 24 * time.Hour
)
// ctxKey is an unexported type so our context values can't collide with any
// set elsewhere.
type ctxKey int
const sessionKey ctxKey = 0
const userIDKey ctxKey = 0
// User is the public shape returned to clients — never the password hash.
// Role is "owner" for a normal login and "guest" for a session minted from a
// share link, in which case Label names the link and Email is blanked (it is
// the owner's address, and a guest has no business seeing it).
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
// ShareID is the guest's own link id, which is what decides the events they
// are allowed to change (see Store.sync). The client uses it to grey out
// everything logged by someone else.
ShareID string `json:"shareId,omitempty"`
// Expires is the guest session's end, in Unix milliseconds. Owner sessions
// leave it zero — they only end by logging out.
Expires int64 `json:"expires,omitempty"`
}
// session is a resolved cookie: who the request acts as, and whether it got
// there through a guest link. ShareID is empty for an owner session.
type session struct {
userID string
shareID string
label string
expires int64
}
func (s session) guest() bool { return s.shareID != "" }
// Auth owns everything account-related: the users/sessions tables, the shared
// invite code required to register, and whether session cookies are marked
// Secure (on behind TLS/a proxy). photosDir is needed so the first account can
@@ -151,10 +121,8 @@ func (a *Auth) verify(email, password string) (User, bool) {
}
// startSession mints a token, stores its hash, and returns the raw token for
// the cookie. shareID is empty for an owner login; for a guest it names the
// share link, and expires is capped at that link's own end so the session can
// never outlive the link it came from.
func (a *Auth) startSession(userID, shareID string, expires int64) (string, error) {
// the cookie.
func (a *Auth) startSession(userID string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
@@ -162,53 +130,28 @@ func (a *Auth) startSession(userID, shareID string, expires int64) (string, erro
token := hex.EncodeToString(raw)
now := time.Now()
_, err := a.db.Exec(
`INSERT INTO sessions (token, user_id, created, expires, share_id) VALUES (?, ?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), expires, shareID)
`INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
if err != nil {
return "", err
}
return token, nil
}
// sessionForToken resolves a raw cookie token to the session it stands for,
// honouring expiry. A guest session is additionally only valid while its link
// is un-revoked and unexpired — checked here, on every request, so revoking a
// link kicks its live sessions out immediately rather than whenever their own
// row happens to lapse.
func (a *Auth) sessionForToken(token string) (session, bool) {
// userForToken resolves a raw cookie token to a user id, honouring expiry.
func (a *Auth) userForToken(token string) (string, bool) {
if token == "" {
return session{}, false
return "", false
}
var s session
var expires, linkRevoked, linkExpires int64
err := a.db.QueryRow(`
SELECT s.user_id, s.expires, s.share_id,
COALESCE(l.revoked, 0), COALESCE(l.expires, 0), COALESCE(l.label, '')
FROM sessions s LEFT JOIN share_links l ON l.id = s.share_id
WHERE s.token = ?`, hashToken(token),
).Scan(&s.userID, &expires, &s.shareID, &linkRevoked, &linkExpires, &s.label)
now := time.Now().UnixMilli()
if err != nil || now > expires {
return session{}, false
}
if s.guest() {
if linkRevoked != 0 || now > linkExpires {
return session{}, false
}
s.expires = expires
a.touchShare(s.shareID, now)
}
return s, true
}
// touchShare records that a link was used, at lastUsedResolution granularity so
// an active guest doesn't cause a write per request.
func (a *Auth) touchShare(shareID string, now int64) {
if _, err := a.db.Exec(
`UPDATE share_links SET last_used = ? WHERE id = ? AND last_used < ?`,
now, shareID, now-lastUsedResolution.Milliseconds()); err != nil {
log.Printf("touch share %s: %v", shareID, err)
var userID string
var expires int64
err := a.db.QueryRow(
`SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token),
).Scan(&userID, &expires)
if err != nil || time.Now().UnixMilli() > expires {
return "", false
}
return userID, true
}
func (a *Auth) endSession(token string) {
@@ -266,10 +209,7 @@ func (a *Auth) adoptPhotos(userID string) error {
// ---------- cookies & middleware ----------
// setCookie writes the session cookie. expires mirrors the session row's own
// end, so a guest's cookie lapses with the link rather than sitting around for
// the full 30 days pointing at a session the server already refuses.
func (a *Auth) setCookie(w http.ResponseWriter, token string, expires time.Time) {
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
@@ -277,7 +217,7 @@ func (a *Auth) setCookie(w http.ResponseWriter, token string, expires time.Time)
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
Expires: expires,
Expires: time.Now().Add(sessionValidity),
})
}
@@ -302,50 +242,26 @@ func cookieToken(r *http.Request) string {
}
// requireUser wraps a handler so it only runs for an authenticated request,
// stashing the resolved session in the context. Unauthenticated calls get a 401
// that the client uses as its cue to show the login screen.
// stashing the user id in the context. Unauthenticated calls get a 401 that the
// client uses as its cue to show the login screen.
func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := a.sessionForToken(cookieToken(r))
userID, ok := a.userForToken(cookieToken(r))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
}
}
// requireOwner is requireUser plus "and not through a guest link". It guards
// everything a temporary helper has no business touching: the profile, the
// owner's reminders, the share links themselves, and account deletion.
func (a *Auth) requireOwner(next http.HandlerFunc) http.HandlerFunc {
return a.requireUser(func(w http.ResponseWriter, r *http.Request) {
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
next(w, r)
})
// userID returns the authenticated user's id; only valid inside a requireUser
// handler.
func userID(r *http.Request) string {
id, _ := r.Context().Value(userIDKey).(string)
return id
}
// sessionOf returns the request's resolved session; only valid inside a
// requireUser handler.
func sessionOf(r *http.Request) session {
s, _ := r.Context().Value(sessionKey).(session)
return s
}
// userID returns the authenticated user's id — the owner's, for a guest
// session, which is what keeps all data scoping working unchanged.
func userID(r *http.Request) string { return sessionOf(r).userID }
// isGuest reports whether the request arrived through a share link.
func isGuest(r *http.Request) bool { return sessionOf(r).guest() }
// guestLabel is the share link's label, or empty for the owner. It is what gets
// stamped onto events the request creates.
func guestLabel(r *http.Request) string { return sessionOf(r).label }
// ---------- handlers ----------
type credentials struct {
@@ -433,17 +349,15 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
a.issue(w, u)
}
// issue starts an owner session, sets the cookie, and returns the user.
// issue starts a session, sets the cookie, and returns the user.
func (a *Auth) issue(w http.ResponseWriter, u User) {
expires := time.Now().Add(sessionValidity)
token, err := a.startSession(u.ID, "", expires.UnixMilli())
token, err := a.startSession(u.ID)
if err != nil {
log.Printf("start session: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.setCookie(w, token, expires)
u.Role = "owner"
a.setCookie(w, token)
writeUser(w, u)
}
@@ -457,10 +371,8 @@ func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleMe reports the current account, and which role the caller holds over
// it. Wrapped in requireUser, so reaching it means the session is valid. The id
// is the owner's either way — it is what the client namespaces its local cache
// by — but a guest is told so, and never told whose account this is.
// handleMe reports the current account. Wrapped in requireUser, so reaching it
// means the session is valid.
func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
var u User
err := a.db.QueryRow(
@@ -470,15 +382,6 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s := sessionOf(r); s.guest() {
u.Email = ""
u.Role = "guest"
u.Label = s.label
u.ShareID = s.shareID
u.Expires = s.expires
} else {
u.Role = "owner"
}
writeUser(w, u)
}
@@ -492,9 +395,8 @@ func (a *Auth) checkPassword(userID, password string) bool {
}
// deleteAccount removes a user and everything owned by them: events, profile,
// reminders, push subscriptions, share links, sessions, the user row, and their
// photo directory. The table wipes run in one transaction; photos are best-effort
// afterwards (orphaned files are harmless).
// 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 {
@@ -504,11 +406,7 @@ func (a *Auth) deleteAccount(userID string) error {
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM food_kinds WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM push_subscriptions WHERE user_id = ?`,
`DELETE FROM reminders WHERE user_id = ?`,
`DELETE FROM share_links WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
} {
@@ -546,234 +444,3 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// ---------- guest links ----------
//
// A guest link lets the owner hand someone (a dog sitter, family for a weekend)
// the ability to log events without handing over their password. Redeeming one
// mints an ordinary session row against the *owner's* user_id, tagged with the
// link it came from — so every data path downstream (sync, photos, config) keeps
// working untouched, and only the capability checks differ by role.
//
// The token is kept, not just its hash, so Settings can show the URL again
// whenever the owner wants to re-send it. That is a deliberate trade the way it
// would not be for a password or a session token: a guest link grants a subset
// of what the same database already holds in plaintext, so whoever can read
// puppy.db gains very little from it, and the link expires and can be revoked
// besides. The `token` column stays a hash and remains the lookup key; `secret`
// is the copy handed back to the owner.
// ShareLink is the public shape of a guest link. Token carries the URL's secret
// and comes back on every listing, so the owner can copy the link again rather
// than having one chance at it when it is created.
type ShareLink struct {
ID string `json:"id"`
Label string `json:"label"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
LastUsed int64 `json:"lastUsed,omitempty"`
Token string `json:"token,omitempty"`
}
// createShare mints a link that stops working at expires (Unix milliseconds)
// and returns it with its one-time raw token attached.
func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return ShareLink{}, err
}
token := hex.EncodeToString(raw)
now := time.Now()
link := ShareLink{
ID: newID(),
Label: label,
Created: now.UnixMilli(),
Expires: expires,
Token: token,
}
_, err := a.db.Exec(
`INSERT INTO share_links (id, user_id, token, secret, label, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)`,
link.ID, userID, hashToken(token), token, link.Label, link.Created, link.Expires)
if err != nil {
return ShareLink{}, err
}
return link, nil
}
// listShares returns the account's links that are still usable, each with its
// URL secret so Settings can offer the link for copying at any time. Revoked and
// lapsed ones are of no interest to the UI — the point of the list is "who can
// get in right now". A link created before secrets were kept comes back with an
// empty Token; the UI says so rather than showing a broken URL.
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
rows, err := a.db.Query(`
SELECT id, label, created, expires, last_used, secret
FROM share_links
WHERE user_id = ? AND revoked = 0 AND expires > ?
ORDER BY created DESC`, userID, time.Now().UnixMilli())
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ShareLink, 0)
for rows.Next() {
var l ShareLink
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed, &l.Token); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// revokeShare kills a link and every session already minted from it. The
// user_id guard means one account can never revoke another's link.
func (a *Auth) revokeShare(userID, id string) error {
res, err := a.db.Exec(
`UPDATE share_links SET revoked = 1 WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return sql.ErrNoRows
}
// sessionForToken would reject these anyway, on the revoked flag; dropping
// the rows means a revoked link leaves nothing behind either way.
_, err = a.db.Exec(`DELETE FROM sessions WHERE share_id = ?`, id)
return err
}
// redeemShare exchanges a raw token for a session on the owner's account. The
// session is capped at the link's own expiry, so it cannot outlive it.
func (a *Auth) redeemShare(token string) (raw string, sessionEnd int64, ok bool) {
if token == "" {
return "", 0, false
}
var id, ownerID string
var expires, revoked int64
err := a.db.QueryRow(
`SELECT id, user_id, expires, revoked FROM share_links WHERE token = ?`, hashToken(token),
).Scan(&id, &ownerID, &expires, &revoked)
now := time.Now()
if err != nil || revoked != 0 || now.UnixMilli() > expires {
return "", 0, false
}
sessionEnd = now.Add(sessionValidity).UnixMilli()
if expires < sessionEnd {
sessionEnd = expires
}
raw, err = a.startSession(ownerID, id, sessionEnd)
if err != nil {
log.Printf("redeem share %s: %v", id, err)
return "", 0, false
}
if _, err := a.db.Exec(`UPDATE share_links SET last_used = ? WHERE id = ?`, now.UnixMilli(), id); err != nil {
log.Printf("stamp share %s: %v", id, err)
}
return raw, sessionEnd, true
}
type shareRequest struct {
Label string `json:"label"`
// Expires is when the link should stop working, in Unix milliseconds. The
// client computes it from the date the owner picked — end of that day in
// their own timezone, which is the only place that timezone is known.
Expires int64 `json:"expires"`
}
// handleShares lists (GET) and creates (POST) guest links. Wrapped in
// requireOwner: a guest cannot see, mint or extend links.
func (a *Auth) handleShares(w http.ResponseWriter, r *http.Request) {
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(v)
}
switch r.Method {
case http.MethodGet:
links, err := a.listShares(userID(r))
if err != nil {
log.Printf("list shares: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(map[string]any{"links": links})
case http.MethodPost:
var req shareRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
label := strings.TrimSpace(req.Label)
if len(label) > 40 {
label = label[:40]
}
if label == "" {
label = "Guest"
}
now := time.Now()
if req.Expires <= now.UnixMilli() {
http.Error(w, "pick a date in the future", http.StatusBadRequest)
return
}
if req.Expires > now.Add(maxShareAhead).UnixMilli() {
http.Error(w, "that date is too far off", http.StatusBadRequest)
return
}
link, err := a.createShare(userID(r), label, req.Expires)
if err != nil {
log.Printf("create share: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleShare revokes one link: DELETE /api/shares/<id>. Wrapped in
// requireOwner.
func (a *Auth) handleShare(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/shares/")
if id == "" || strings.Contains(id, "/") {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := a.revokeShare(userID(r), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "no such link", http.StatusNotFound)
return
}
log.Printf("revoke share %s: %v", id, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRedeem is what a guest link actually points at: GET /guest/<token>.
// A plain navigation so tapping the link in a message just works — it sets the
// session cookie and bounces to the app, which keeps the token out of the
// address bar, out of bookmarks and out of the PWA's start URL. SameSite=Lax
// permits the cookie on a top-level GET like this one.
func (a *Auth) handleRedeem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token := strings.TrimPrefix(r.URL.Path, "/guest/")
raw, expires, ok := a.redeemShare(token)
if !ok {
// Nothing usable — send them to the app with a marker it renders as
// "this link has ended" rather than a login form they can't fill in.
http.Redirect(w, r, "/?guest=expired", http.StatusSeeOther)
return
}
a.setCookie(w, raw, time.UnixMilli(expires))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
-802
View File
@@ -1,802 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func testAuth(t *testing.T) *Auth {
t.Helper()
dir := t.TempDir()
db, err := openDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return newAuth(db, "letmein", false, filepath.Join(dir, "photos"))
}
// testOwner registers an account and returns its id.
func testOwner(t *testing.T, a *Auth) string {
t.Helper()
u, err := a.createUser("owner@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
return u.ID
}
// hoursAhead is an expiry that many hours from now, in Unix milliseconds —
// what the client sends after the owner picks a date.
func hoursAhead(h int) int64 {
return time.Now().Add(time.Duration(h) * time.Hour).UnixMilli()
}
// guestToken mints a link and redeems it, returning the raw session token a
// guest's cookie would carry.
func guestToken(t *testing.T, a *Auth, ownerID, label string, hours int) string {
t.Helper()
link, err := a.createShare(ownerID, label, hoursAhead(hours))
if err != nil {
t.Fatalf("create share: %v", err)
}
raw, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
return raw
}
// request builds a request carrying the given session cookie.
func request(method, path, cookie, body string) *http.Request {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
}
return r
}
func TestRedeemedLinkActsAsTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
token := guestToken(t, a, ownerID, "Anna", 24)
s, ok := a.sessionForToken(token)
if !ok {
t.Fatal("session for a fresh guest token was rejected")
}
if s.userID != ownerID {
t.Errorf("guest session scoped to %q, want the owner %q", s.userID, ownerID)
}
if !s.guest() {
t.Error("session from a share link does not report itself as a guest")
}
if s.label != "Anna" {
t.Errorf("label = %q, want %q", s.label, "Anna")
}
}
func TestOwnerSessionIsNotAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
raw, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
s, ok := a.sessionForToken(raw)
if !ok {
t.Fatal("owner session was rejected")
}
if s.guest() || s.label != "" {
t.Errorf("owner session reports guest=%v label=%q, want false/empty", s.guest(), s.label)
}
}
func TestUnusableTokensAreRejected(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
if _, _, ok := a.redeemShare("not-a-real-token"); ok {
t.Error("a garbage token was redeemed")
}
if _, _, ok := a.redeemShare(""); ok {
t.Error("an empty token was redeemed")
}
// An expired link: backdate it past its own end.
link, err := a.createShare(ownerID, "Stale", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), link.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, _, ok := a.redeemShare(link.Token); ok {
t.Error("an expired link was redeemed")
}
// A revoked link.
revoked, err := a.createShare(ownerID, "Revoked", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, revoked.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, _, ok := a.redeemShare(revoked.Token); ok {
t.Error("a revoked link was redeemed")
}
}
// Revocation has to bite on the next request, not whenever the guest's own
// session row happens to lapse — that is the whole point of being able to
// revoke.
func TestRevokingKillsLiveSessions(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
token, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if _, ok := a.sessionForToken(token); !ok {
t.Fatal("session invalid before revoking")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := a.sessionForToken(token); ok {
t.Error("session still valid after its link was revoked")
}
}
// A guest session must never outlive its link, however long the default
// session validity is.
func TestGuestSessionIsCappedAtTheLinkExpiry(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
_, sessionEnd, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if sessionEnd != link.Expires {
t.Errorf("session ends at %d, want the link's own %d", sessionEnd, link.Expires)
}
}
func TestRevokeIsScopedToTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
other, err := a.createUser("other@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(other.ID, link.ID); err == nil {
t.Error("another account revoked a link it does not own")
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("link was revoked by an account that does not own it")
}
}
func TestListSharesHidesRevokedAndExpired(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
live, err := a.createShare(ownerID, "Live", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
gone, err := a.createShare(ownerID, "Gone", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, gone.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
stale, err := a.createShare(ownerID, "Stale", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), stale.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].ID != live.ID {
t.Fatalf("listed %d link(s), want only the live one", len(links))
}
}
// Settings shows every live link's URL so it can be re-sent, which means a
// listing has to carry the same secret the link was created with — and that
// secret has to still work.
func TestListSharesReturnsAWorkingURL(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 {
t.Fatalf("listed %d link(s), want 1", len(links))
}
if links[0].Token != link.Token {
t.Fatalf("listing returned %q, want the issued secret %q", links[0].Token, link.Token)
}
if _, _, ok := a.redeemShare(links[0].Token); !ok {
t.Error("the secret handed back by the listing does not open the link")
}
}
// The lookup column stays a hash even though the secret is kept beside it, so
// the token in a URL is never what is matched against directly.
func TestLookupIsStillByHash(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
var stored string
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != hashToken(link.Token) {
t.Error("the lookup column is not the hash of the issued token")
}
}
// A link made before secrets were kept has no URL to show. It must still work
// and still be revocable — only the copy-again affordance is unavailable.
func TestLinkWithoutAStoredSecretStillWorks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Legacy", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
// What the migration leaves behind for a pre-existing row.
if _, err := a.db.Exec(`UPDATE share_links SET secret = '' WHERE id = ?`, link.ID); err != nil {
t.Fatalf("clear secret: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].Token != "" {
t.Fatalf("want the link listed with an empty token, got %+v", links)
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("a link whose secret was never stored stopped working")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Errorf("could not revoke it: %v", err)
}
}
func TestDeletingAnAccountDropsItsLinks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.deleteAccount(ownerID); err != nil {
t.Fatalf("delete account: %v", err)
}
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM share_links WHERE id = ?`, link.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Error("the deleted account's guest links survived")
}
}
// ---------- capability gating ----------
func TestRequireOwnerBlocksGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
reached := false
h := a.requireOwner(func(w http.ResponseWriter, r *http.Request) { reached = true })
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", guest, "{}"))
if w.Code != http.StatusForbidden {
t.Errorf("guest got %d, want %d", w.Code, http.StatusForbidden)
}
if reached {
t.Error("the guarded handler ran for a guest")
}
w = httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", owner, "{}"))
if w.Code != http.StatusOK || !reached {
t.Errorf("owner got %d and reached=%v, want 200 and true", w.Code, reached)
}
}
// A guest must still be able to do the thing the link exists for.
func TestRequireUserAllowsGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
var sawUser, sawLabel string
h := a.requireUser(func(w http.ResponseWriter, r *http.Request) {
sawUser, sawLabel = userID(r), guestLabel(r)
})
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/events/sync", guest, "{}"))
if w.Code != http.StatusOK {
t.Fatalf("guest got %d on a shared route, want 200", w.Code)
}
if sawUser != ownerID {
t.Errorf("handler saw user %q, want the owner %q", sawUser, ownerID)
}
if sawLabel != "Anna" {
t.Errorf("handler saw label %q, want %q", sawLabel, "Anna")
}
}
func TestHandleMeHidesTheOwnerFromAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
w := httptest.NewRecorder()
a.requireUser(a.handleMe)(w, request(http.MethodGet, "/api/me", guest, ""))
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
var u User
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
t.Fatalf("decode: %v", err)
}
if u.Email != "" {
t.Errorf("a guest was told the owner's email (%q)", u.Email)
}
if u.Role != "guest" || u.Label != "Anna" {
t.Errorf("role/label = %q/%q, want guest/Anna", u.Role, u.Label)
}
if u.ID != ownerID {
t.Errorf("id = %q, want the owner's %q so the client scopes its cache right", u.ID, ownerID)
}
}
// ---------- attribution ----------
func loggedBy(t *testing.T, db *sql.DB, id string) string {
t.Helper()
var by string
if err := db.QueryRow(`SELECT logged_by FROM events WHERE id = ?`, id).Scan(&by); err != nil {
t.Fatalf("read logged_by: %v", err)
}
return by
}
func TestAttributionIsStampedFromTheSession(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q, want %q", got, "Anna")
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
if got := loggedBy(t, a.db, "e2"); got != "" {
t.Errorf("the owner's own event was attributed to %q", got)
}
}
// Attribution is decided once, by whoever logged the event. A later edit —
// by the owner or by another guest — must not rewrite it.
func TestAttributionSurvivesLaterEdits(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
// The owner edits the note, bumping updatedAt so LWW takes the change.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after an owner edit, want it to stay %q", got, "Anna")
}
// And the guest re-POSTing their own event leaves it alone too.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 3000},
}); err != nil {
t.Fatalf("guest re-sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after the guest re-synced, want %q", got, "Anna")
}
}
// The value never comes off the wire, so a client cannot claim to be someone
// else — or launder its own events into looking like the owner's.
func TestAttributionCannotBeSetByTheClient(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000, LoggedBy: ""},
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000, LoggedBy: "The Owner", LoggedByShare: "s-other"},
})
if err != nil {
t.Fatalf("sync: %v", err)
}
for _, id := range []string{"e1", "e2"} {
if got := loggedBy(t, a.db, id); got != "Anna" {
t.Errorf("%s: logged_by = %q, want the session's %q", id, got, "Anna")
}
}
// And the server's own answer carries the stamp back, so the client can
// render the badge without having to guess.
for _, e := range merged {
if e.LoggedBy != "Anna" {
t.Errorf("%s came back as %q, want %q", e.ID, e.LoggedBy, "Anna")
}
if e.LoggedByShare != "s1" {
t.Errorf("%s came back from link %q, want %q", e.ID, e.LoggedByShare, "s1")
}
}
}
// ---------- what a guest is allowed to change ----------
// eventNote reads back one event's note and tombstone flag — enough to tell
// whether an attempted edit or delete actually landed.
func eventState(t *testing.T, db *sql.DB, id string) (note string, deleted bool) {
t.Helper()
if err := db.QueryRow(`SELECT note, deleted FROM events WHERE id = ?`, id).Scan(&note, &deleted); err != nil {
t.Fatalf("read event %s: %v", id, err)
}
return note, deleted
}
// The point of the whole guard: a sitter must not be able to rewrite or delete
// what the owner logged, however their client asks.
func TestGuestCannotChangeTheOwnersEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
// The owner logs something.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// A guest tries to edit it, with a much newer timestamp so last-write-wins
// alone would take the change.
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "rewritten by the sitter", UpdatedAt: 9000},
})
if err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "mine" {
t.Errorf("a guest rewrote the owner's event: note = %q", note)
}
// The guest gets the stored version back, so an honest client can heal.
for _, e := range merged {
if e.ID == "e1" && e.Note != "mine" {
t.Errorf("server returned %q for the owner's event, want %q", e.Note, "mine")
}
}
// And cannot delete it either — a tombstone is just another update.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 9001, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); deleted {
t.Error("a guest deleted the owner's event")
}
}
func TestGuestCanChangeItsOwnEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "oops", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "fixed" {
t.Errorf("a guest could not fix up their own entry: note = %q", note)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("a guest could not delete their own entry")
}
}
// Two links can carry the same label ("Sitter"), so the id — not the label —
// has to be what authorises the change.
func TestGuestCannotChangeAnotherLinksEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Sitter", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "anna's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("first guest sync: %v", err)
}
// Same label, different link.
if _, err := store.sync(ownerID, "Sitter", "s2", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "bob's", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("second guest sync: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "anna's" {
t.Errorf("one link's guest edited another's event: note = %q", note)
}
}
// The owner keeps full control of everything on their account, including what
// a guest logged.
func TestOwnerCanChangeAGuestsEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "sitter's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "corrected" {
t.Errorf("the owner could not edit a guest's event: note = %q", note)
}
// And delete it. A tombstone is just another update, so this rides the same
// clause — but it is the half that matters if a sitter logs something wrong
// and the owner wants it gone rather than fixed.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("owner delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("the owner could not delete a guest's event")
}
}
// Marking a day as not counted is a judgment about the record, so it is the
// owner's — a sitter cannot decide their own thin day shouldn't count, nor
// quietly take a good day out of the averages.
func TestGuestCannotExcludeADay(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
{ID: "pee1", Type: "pee", At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("guest sync: %v", err)
}
for _, e := range merged {
if e.Type == eventTypeDayExcluded {
t.Fatal("a guest marked a day as not counted")
}
}
// The rest of the same sync still lands — the mark is dropped, not the batch.
if len(merged) != 1 || merged[0].ID != "pee1" {
t.Errorf("dropping the mark cost the guest their other events: %+v", merged)
}
// The owner may, of course.
merged, err = store.sync(ownerID, "", "", []Event{
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("owner sync: %v", err)
}
var found bool
for _, e := range merged {
if e.ID == "mark" && e.Type == eventTypeDayExcluded {
found = true
}
}
if !found {
t.Error("the owner could not mark a day as not counted")
}
}
// The food kinds are the owner's library, like the exercise list: a guest
// labels a meal with a kind that exists but does not invent or rename one.
func TestGuestCannotChangeFoodKinds(t *testing.T) {
a := testAuth(t)
kinds := newFoodKindStore(a.db)
ownerID := testOwner(t, a)
if _, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// What the route hands the store for a guest: nothing incoming, everything
// back. Mirrors the exercises guard in main.go.
merged, err := kinds.sync(ownerID, nil)
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].Name != "Dry" {
t.Fatalf("a guest should still receive the library: %+v", merged)
}
if !merged[0].IsDefault {
t.Error("the default flag did not survive the round trip")
}
// And the owner can still rename it, which is the other half of the rule.
renamed, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
})
if err != nil {
t.Fatalf("owner rename: %v", err)
}
if renamed[0].Name != "Dry kibble" {
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
}
}
// A meal's kind rides the event sync like any other field, and an older client
// that doesn't know about kinds must not wipe one.
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
})
if err != nil {
t.Fatalf("sync: %v", err)
}
byID := map[string]Event{}
for _, e := range merged {
byID[e.ID] = e
}
if byID["e1"].FoodKindID != "k1" {
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
}
if byID["e2"].FoodKindID != "" {
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
}
}
// Guests still log freely — the guard is on changing what already exists.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].ID != "e1" {
t.Fatalf("guest's new event did not land: %+v", merged)
}
}
// ---------- link expiry ----------
func TestShareExpiryIsWhateverWasAskedFor(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
want := hoursAhead(53) // an odd span no fixed duration would produce
link, err := a.createShare(ownerID, "Anna", want)
if err != nil {
t.Fatalf("create share: %v", err)
}
if link.Expires != want {
t.Errorf("link expires at %d, want the requested %d", link.Expires, want)
}
}
func TestCreateShareRejectsBadDates(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
for _, tc := range []struct {
name string
expires int64
}{
{"in the past", time.Now().Add(-time.Hour).UnixMilli()},
{"missing", 0},
{"absurdly far off", time.Now().Add(5 * 365 * 24 * time.Hour).UnixMilli()},
} {
body := `{"label":"Anna","expires":` + strconv.FormatInt(tc.expires, 10) + `}`
w := httptest.NewRecorder()
a.requireOwner(a.handleShares)(w, request(http.MethodPost, "/api/shares", owner, body))
if w.Code != http.StatusBadRequest {
t.Errorf("%s: got %d, want %d", tc.name, w.Code, http.StatusBadRequest)
}
}
}
-1
View File
@@ -4,7 +4,6 @@ go 1.25.0
require (
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
modernc.org/sqlite v1.53.0
)
-2
View File
@@ -16,8 +16,6 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
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/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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-102
View File
@@ -1,102 +0,0 @@
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 ""
}
+54 -451
View File
@@ -28,25 +28,11 @@ type Event struct {
Type string `json:"type"`
At int64 `json:"at"`
Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
PhotoID string `json:"photoId,omitempty"`
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
// FoodKindID names which sort of food, for "eat" events. Empty is a real
// answer — "no kind" — and is what every meal logged before kinds existed
// carries, so none of them needed rewriting.
FoodKindID string `json:"foodKindId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
// owner's own. It is stamped by the server from the session (see Store.sync)
// and never read off the wire, so a client can neither forge nor rewrite it.
LoggedBy string `json:"loggedBy,omitempty"`
// LoggedByShare is that link's id. LoggedBy is a label the owner typed and
// two links may well share one ("Sitter"), so the id — not the label — is
// what decides whether a guest may change this event. Sent to the client so
// it can grey out what it isn't allowed to touch; opaque and harmless.
LoggedByShare string `json:"loggedByShare,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
@@ -61,12 +47,6 @@ type Exercise struct {
Deleted bool `json:"deleted,omitempty"`
}
// eventTypeDayExcluded marks a day the owner has taken out of the charts and
// averages — a sitter's thin day, a stay at kennels. It is an event so it rides
// the ordinary sync (per-item last-write-wins, tombstone to un-mark) rather than
// needing a table and endpoint of its own; the client reads it in app.js.
const eventTypeDayExcluded = "day-excluded"
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
func validUUID(s string) bool { return uuidRE.MatchString(s) }
@@ -79,12 +59,9 @@ func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s)
// every client sees the same values without configuring each device. UpdatedAt
// drives last-write-wins, mirroring how events sync.
type Config struct {
Name string `json:"name"`
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"`
Name string `json:"name"`
Birthday string `json:"birthday"`
UpdatedAt int64 `json:"updatedAt"`
}
type ConfigStore struct {
@@ -100,8 +77,8 @@ func (cs *ConfigStore) get(userID string) Config {
// One profile row per user. A missing row is the pre-configuration state,
// so a zero-value Config is the right answer.
err := cs.db.QueryRow(
`SELECT name, birthday, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &c.UpdatedAt)
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err)
}
@@ -111,32 +88,18 @@ func (cs *ConfigStore) get(userID string) Config {
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// Name/birthday/updated are last-write-wins: the incoming row replaces the
// stored one only when strictly newer. The pedigree id is stickier — an empty
// incoming value never clears a stored one, so a clock race between devices
// can't drop it; when both are set, the newer profile's id wins with the rest.
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer.
_, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?, ?)
INSERT INTO config (user_id, name, birthday, updated)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
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
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
userID, in.Name, in.Birthday, in.UpdatedAt)
if err != nil {
return Config{}, err
}
// Adopt a pedigree id the server is missing even from an older-stamped profile,
// so a device that set it isn't blocked by another device's newer name/birthday
// edit. (A set id is only ever changed by a newer profile that also sets one.)
if in.PedigreeID != "" {
if _, err := cs.db.Exec(
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
in.PedigreeID, userID); err != nil {
return Config{}, err
}
}
return cs.get(userID), nil
}
@@ -150,11 +113,8 @@ func newStore(db *sql.DB) *Store {
// sync merges one user's client events into the store using last-write-wins by
// UpdatedAt, then returns that user's full merged set (tombstones included, as
// they must propagate). loggedBy/shareID describe the caller's session — the
// guest link's label and id, both empty for the owner. They are stamped onto
// events this call inserts, and shareID additionally decides which existing
// events the caller is allowed to change.
func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event, error) {
// they must propagate).
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
@@ -163,35 +123,19 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
// event only overwrites the stored one when its updatedAt is strictly newer.
// Two further guards ride on it:
//
// - events.user_id = excluded.user_id — one user can never 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 logged_by_share clause — an owner (excluded.logged_by_share = '')
// may change anything; a guest may only change events logged through
// their own link. So a sitter can fix up their own entries, and cannot
// edit or delete a single one of the owner's. A rejected row simply
// stays as it was, and the caller gets the stored version back.
//
// The two attribution columns are deliberately absent from the DO UPDATE SET
// list: attribution is decided once, by whoever first inserted the event, and
// a later edit by anyone leaves it alone. That is also what makes it
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
// The `events.user_id = excluded.user_id` guard means one user can never
// 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.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
food_kind_id = excluded.food_kind_id,
exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id
AND (excluded.logged_by_share = ''
OR events.logged_by_share = excluded.logged_by_share)`)
AND events.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
@@ -201,15 +145,8 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
if ce.ID == "" {
continue
}
// Marking a day as not counted is a judgment about the record rather
// than something that happened to the puppy, so it belongs to the owner
// alongside everything else a guest may not decide. The client hides the
// control; this is what enforces it.
if shareID != "" && ce.Type == eventTypeDayExcluded {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
); err != nil {
return nil, err
}
@@ -223,7 +160,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
`SELECT id, type, at, note, photo_id, weight, exercise_id, updated, deleted
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -233,7 +170,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
); err != nil {
return nil, err
}
@@ -242,100 +179,6 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err()
}
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
// logged before kinds existed carries and what anyone who doesn't want to
// classify their food keeps carrying.
//
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
// tombstoned deletes — plus two fields of its own:
//
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
// than in the profile because the profile is last-write-wins across the
// whole row, and this file already carries a special case for pedigree_id
// to stop a clock race dropping it. Per-item LWW needs no such case: two
// devices setting different defaults resolve to the newer one.
// - ColorIndex fixes which palette entry the charts give it, assigned at
// creation. Deriving colour from position in the live list would silently
// recolour every past chart the moment a kind was deleted.
type FoodKind struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault,omitempty"`
ColorIndex int `json:"colorIndex"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
// Store and ExerciseStore are already near-twins, so a third in the same shape
// is the pattern this file has established, and it leaves both working
// collections untouched. Folding all three into one store parameterised by
// table name is the tidier end state, and a separate job.
type FoodKindStore struct {
db *sql.DB
}
func newFoodKindStore(db *sql.DB) *FoodKindStore {
return &FoodKindStore{db: db}
}
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, is_default = excluded.is_default,
color_index = excluded.color_index,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > food_kinds.updated
AND food_kinds.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, k := range client {
if k.ID == "" {
continue
}
if _, err := stmt.Exec(
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
rows, err := s.db.Query(
`SELECT id, name, is_default, color_index, updated, deleted
FROM food_kinds WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FoodKind, 0)
for rows.Next() {
var k FoodKind
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
return nil, err
}
out = append(out, k)
}
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.
@@ -428,20 +271,16 @@ func openDB(path string) (*sql.DB, error) {
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT '',
food_kind_id TEXT NOT NULL DEFAULT ''
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
exercise_id 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_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
@@ -453,22 +292,11 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS food_kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
color_index INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
pedigree_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -477,47 +305,10 @@ func openDB(path string) (*sql.DB, error) {
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
share_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS share_links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
last_used INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_share_links_user ON share_links(user_id);
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
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT '',
p256dh TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id);
CREATE TABLE IF NOT EXISTS reminders (
user_id TEXT NOT NULL,
kind TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
interval_min INTEGER NOT NULL DEFAULT 0,
last_fired INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, kind)
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
@@ -557,68 +348,6 @@ func migrateSchema(db *sql.DB) error {
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
}
}
// Guest links (see Auth.createShare). Every column defaults to the empty
// string, which is exactly what pre-guest-link rows mean: an event nobody
// but the owner logged, and a session that isn't a guest's.
hasLoggedBy, err := columnExists(db, "events", "logged_by")
if err != nil {
return err
}
if !hasLoggedBy {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasLoggedByShare, err := columnExists(db, "events", "logged_by_share")
if err != nil {
return err
}
if !hasLoggedByShare {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by_share TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Guest links are re-showable in Settings, which means keeping the token
// itself and not only its hash (see Auth.createShare). Links made before
// this have an empty secret and simply cannot be shown again.
hasSecret, err := columnExists(db, "share_links", "secret")
if err != nil {
return err
}
if !hasSecret {
if _, err := db.Exec(`ALTER TABLE share_links ADD COLUMN secret TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Which sort of food a meal was. Empty on every existing row, which is
// exactly right: those meals have no kind, and none of them need rewriting.
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
if err != nil {
return err
}
if !hasFoodKind {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
}
if !hasShareID {
if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN share_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
@@ -644,15 +373,6 @@ 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
}
@@ -712,7 +432,7 @@ func importEvents(db *sql.DB, path string) error {
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db)
if _, err := store.sync("", "", "", evs); err != nil {
if _, err := store.sync("", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
@@ -770,14 +490,6 @@ type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type foodKindSyncRequest struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type foodKindSyncResponse struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type cacheControlFS struct {
root http.FileSystem
}
@@ -804,9 +516,8 @@ func newSWVersion(dir string) *swVersion {
// 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",
"icon-180.png", "icon-192.png", "icon-512.png", "changelog.json"},
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg"},
}
}
@@ -862,8 +573,6 @@ func main() {
"shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration")
secureCookies := flag.Bool("secure-cookies", false,
"mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)")
vapidKey := flag.String("vapid-key", os.Getenv("PUPPY_VAPID_KEY"),
"base64url P-256 private key for Web Push (env PUPPY_VAPID_KEY); generated next to the DB when unset")
flag.Parse()
db, err := openDB(*dataPath)
@@ -880,24 +589,12 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
foodKindStore := newFoodKindStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil {
log.Fatalf("mkdir photos: %v", err)
}
// Reminders are optional: if the push identity cannot be established the rest
// of the app must still come up, just without notifications.
var scheduler *Scheduler
if key, err := loadVAPIDKey(*vapidKey, filepath.Join(filepath.Dir(*dataPath), "vapid.json")); err != nil {
log.Printf("WARNING: push reminders disabled: %v", err)
} else {
scheduler = newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key)
go scheduler.run(reminderTick)
}
auth := newAuth(db, *inviteCode, *secureCookies, photosDir)
if *inviteCode == "" {
log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)")
@@ -913,24 +610,12 @@ func main() {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
// Deleting the account is the owner's alone, so this arm — and only
// this arm — is gated; a guest still needs the GET to learn its role.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
// Guest links: minting, listing and revoking are the owner's, redeeming is
// the unauthenticated entry point the link itself points at.
mux.HandleFunc("/api/shares", auth.requireOwner(auth.handleShares))
mux.HandleFunc("/api/shares/", auth.requireOwner(auth.handleShare))
mux.HandleFunc("/guest/", auth.handleRedeem)
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -941,7 +626,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := store.sync(userID(r), guestLabel(r), sessionOf(r).shareID, req.Events)
merged, err := store.sync(userID(r), req.Events)
if err != nil {
log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -967,16 +652,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// Exercises are the owner's library, not a log: a guest logs training
// sessions against them (ordinary events) but does not get to rename or
// delete them. Dropping the incoming list makes this direction-only —
// the guest still receives the full set back. The client hides the
// editing UI to match; this is the part that enforces it.
incoming := req.Exercises
if isGuest(r) {
incoming = nil
}
merged, err := exerciseStore.sync(userID(r), incoming)
merged, err := exerciseStore.sync(userID(r), req.Exercises)
if err != nil {
log.Printf("exercises sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -987,36 +663,6 @@ func main() {
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// POST /api/foodkinds/sync — the same contract again for the food kinds a
// meal can be labelled with.
mux.HandleFunc("/api/foodkinds/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 foodKindSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// The library is the owner's, exactly as the exercise list is: a guest
// labels a meal with a kind that exists, but does not invent, rename or
// delete one. They still receive the full set, so the picker works.
incoming := req.FoodKinds
if isGuest(r) {
incoming = nil
}
merged, err := foodKindStore.sync(userID(r), incoming)
if err != nil {
log.Printf("food kinds 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(foodKindSyncResponse{FoodKinds: merged})
}))
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
@@ -1029,13 +675,6 @@ func main() {
case http.MethodGet:
writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost:
// The profile (name, birthday, pedigree id) is the owner's to set.
// The GET above stays open — a guest needs the name and birthday to
// render the header at all.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
@@ -1045,10 +684,6 @@ func main() {
if len(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) {
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
return
@@ -1065,37 +700,6 @@ 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)
}))
// Push reminders. Registered only when the scheduler came up, so a server
// without a usable VAPID key 404s these rather than half-working — which is
// also what tells the client to hide the reminder UI entirely. Owner-only:
// the reminders are the owner's own, and a guest device subscribing would
// route them to the sitter's lock screen.
if scheduler != nil {
mux.HandleFunc("/api/push/key", auth.requireOwner(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireOwner(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireOwner(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireOwner(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireOwner(scheduler.handleReminders))
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
@@ -1202,13 +806,12 @@ func main() {
serveSW(w, *staticDir, swVer)
return
}
// PWA: manifest.json must revalidate so updates propagate.
if r.URL.Path == "/manifest.json" {
w.Header().Set("Cache-Control", "no-cache")
}
// SPA fallback: unknown paths -> index.html (so deep links work).
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))
if r.URL.Path != "/" {
if info, err := os.Stat(candidate); err != nil || info.IsDir() {
-912
View File
@@ -1,912 +0,0 @@
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
}
-605
View File
@@ -1,605 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"time"
)
// Reminders are evaluated on the server because that is the only place that can
// act while every client is closed — a PWA gets no timers of its own once the
// tab is gone. The event log is already here (clients sync on every mutation),
// so a rule is just a query over it plus a push.
//
// Two shapes of rule, both keyed off the same synced events:
//
// - "sleep": the puppy has been awake too long. Fires only while awake, so it
// is silent overnight by construction.
// - "pee" / "poo" / "eat": nothing of that type logged for too long. Suppressed
// while the puppy is asleep — otherwise it nags all night — which also means
// it fires promptly on waking if it was already overdue, matching how a puppy
// actually behaves.
// reminderKinds are the rules a user can enable, with the interval each starts
// at. Order is the order they appear in Settings.
var reminderKinds = []struct {
Kind string
Default int // minutes
}{
{"sleep", 45},
{"pee", 60},
{"poo", 180},
{"eat", 240},
}
func defaultInterval(kind string) (int, bool) {
for _, k := range reminderKinds {
if k.Kind == kind {
return k.Default, true
}
}
return 0, false
}
// Reminder is one rule as the client sees it. LastFired is server-owned state
// and deliberately absent: a client PUT must never be able to reset it, or a
// device with a stale copy could make a rule re-fire immediately.
type Reminder struct {
Kind string `json:"kind"`
Enabled bool `json:"enabled"`
IntervalMin int `json:"intervalMin"`
}
type ReminderStore struct {
db *sql.DB
}
func newReminderStore(db *sql.DB) *ReminderStore { return &ReminderStore{db: db} }
// get returns every rule for a user, filling in defaults for kinds they have
// never touched so the client always renders the full set.
func (rs *ReminderStore) get(userID string) ([]Reminder, error) {
rows, err := rs.db.Query(
`SELECT kind, enabled, interval_min FROM reminders WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
stored := map[string]Reminder{}
for rows.Next() {
var r Reminder
if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin); err != nil {
return nil, err
}
stored[r.Kind] = r
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Reminder, 0, len(reminderKinds))
for _, k := range reminderKinds {
if r, ok := stored[k.Kind]; ok {
out = append(out, r)
continue
}
out = append(out, Reminder{Kind: k.Kind, Enabled: false, IntervalMin: k.Default})
}
return out, nil
}
// put writes the rules a client sent. Unknown kinds are ignored and intervals
// are clamped, so a bad client can't install a rule that fires every minute.
// Enabling a rule clears last_fired so it can alert immediately if already due,
// rather than waiting out an interval from whenever it last ran.
func (rs *ReminderStore) put(userID string, in []Reminder) error {
tx, err := rs.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, r := range in {
if _, ok := defaultInterval(r.Kind); !ok {
continue
}
if r.IntervalMin < 5 {
r.IntervalMin = 5
}
if r.IntervalMin > 24*60 {
r.IntervalMin = 24 * 60
}
if _, err := tx.Exec(`
INSERT INTO reminders (user_id, kind, enabled, interval_min, last_fired, updated)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(user_id, kind) DO UPDATE SET
enabled = excluded.enabled,
interval_min = excluded.interval_min,
updated = excluded.updated,
last_fired = CASE WHEN reminders.enabled = 0 AND excluded.enabled = 1
THEN 0 ELSE reminders.last_fired END`,
userID, r.Kind, r.Enabled, r.IntervalMin, time.Now().UnixMilli()); err != nil {
return err
}
}
return tx.Commit()
}
// ---------- subscriptions ----------
type SubscriptionStore struct {
db *sql.DB
}
func newSubscriptionStore(db *sql.DB) *SubscriptionStore { return &SubscriptionStore{db: db} }
// save records a device's push subscription. The endpoint is the primary key:
// browsers reuse it across sessions, and re-subscribing (which iOS forces
// regularly) must update the existing row rather than accumulate dead ones.
func (ss *SubscriptionStore) save(userID string, sub Subscription) error {
_, err := ss.db.Exec(`
INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, created)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth`,
sub.Endpoint, userID, sub.Keys.P256dh, sub.Keys.Auth, time.Now().UnixMilli())
return err
}
func (ss *SubscriptionStore) delete(endpoint string) error {
_, err := ss.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
return err
}
func (ss *SubscriptionStore) forUser(userID string) ([]Subscription, error) {
rows, err := ss.db.Query(
`SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Subscription
for rows.Next() {
var s Subscription
if err := rows.Scan(&s.Endpoint, &s.Keys.P256dh, &s.Keys.Auth); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ---------- evaluation ----------
// sleepState mirrors currentSleepState() in app.js: the latest sleep boundary
// decides whether the puppy is awake, with an equal-timestamp tie broken by the
// later-written event so both sides agree on the same answer.
type sleepState struct {
state string // "asleep", "awake", or "" when nothing has ever been logged
since int64 // ms epoch of that boundary
}
func (sch *Scheduler) sleepStateFor(userID string) (sleepState, error) {
var typ string
var at int64
err := sch.db.QueryRow(`
SELECT type, at FROM events
WHERE user_id = ? AND deleted = 0 AND type IN ('sleep-start', 'sleep-end')
ORDER BY at DESC, updated DESC LIMIT 1`, userID).Scan(&typ, &at)
if errors.Is(err, sql.ErrNoRows) {
return sleepState{}, nil
}
if err != nil {
return sleepState{}, err
}
if typ == "sleep-start" {
return sleepState{state: "asleep", since: at}, nil
}
return sleepState{state: "awake", since: at}, nil
}
// lastEventAt is the timestamp of the newest surviving event of a type, or 0
// when there is none.
func (sch *Scheduler) lastEventAt(userID, typ string) (int64, error) {
var at sql.NullInt64
err := sch.db.QueryRow(
`SELECT MAX(at) FROM events WHERE user_id = ? AND type = ? AND deleted = 0`,
userID, typ).Scan(&at)
if err != nil {
return 0, err
}
return at.Int64, nil
}
// notification is the payload the service worker receives. Tag is what makes a
// repeat fire replace the previous notification instead of stacking a new one.
type notification struct {
Title string `json:"title"`
Body string `json:"body"`
Tag string `json:"tag"`
URL string `json:"url"`
}
// due decides whether a rule should fire right now, and with what text. The
// returned notification is only meaningful when due is true.
func (sch *Scheduler) due(userID string, r Reminder, lastFired, now int64, sleep sleepState) (notification, bool, error) {
interval := int64(r.IntervalMin) * 60 * 1000
var since int64
var title, body string
if r.Kind == "sleep" {
// Only meaningful while awake; asleep means the rule has been satisfied.
if sleep.state != "awake" {
return notification{}, false, nil
}
since = sleep.since
title = "Time to sleep"
body = "Awake for " + humanDuration(now-since)
} else {
// A sleeping puppy isn't going to pee, eat, or poo — stay quiet until it
// wakes, at which point an already-overdue rule fires on the next tick.
if sleep.state == "asleep" {
return notification{}, false, nil
}
at, err := sch.lastEventAt(userID, r.Kind)
if err != nil {
return notification{}, false, err
}
if at == 0 {
// Nothing logged yet, so there is no clock to run.
return notification{}, false, nil
}
since = at
title, body = reminderText(r.Kind, now-since)
}
if now-since < interval {
return notification{}, false, nil
}
// Once overdue, repeat at the rule's own interval rather than every tick.
if lastFired != 0 && now-lastFired < interval {
return notification{}, false, nil
}
return notification{
Title: title,
Body: body,
Tag: "reminder:" + r.Kind,
URL: "./",
}, true, nil
}
func reminderText(kind string, elapsed int64) (title, body string) {
switch kind {
case "pee":
return "Time for pee", "No pee logged for " + humanDuration(elapsed)
case "poo":
return "Time for poo", "No poo logged for " + humanDuration(elapsed)
case "eat":
return "Time for a meal", "No meal logged for " + humanDuration(elapsed)
}
return "Reminder", "Nothing logged for " + humanDuration(elapsed)
}
// humanDuration renders an elapsed span the way the notification body reads it:
// "45 min", "1 h 12 min", "2 h".
func humanDuration(ms int64) string {
if ms < 0 {
ms = 0
}
mins := ms / 60000
h, m := mins/60, mins%60
switch {
case h == 0:
return fmt.Sprintf("%d min", m)
case m == 0:
return fmt.Sprintf("%d h", h)
default:
return fmt.Sprintf("%d h %d min", h, m)
}
}
// ---------- scheduler ----------
// Scheduler evaluates every enabled rule once a minute and pushes the ones that
// have come due. One goroutine for the whole server: the work per tick is a
// couple of indexed queries per user with a rule switched on.
type Scheduler struct {
db *sql.DB
subs *SubscriptionStore
rules *ReminderStore
key *VAPIDKey
client *http.Client
}
// reminderTick is how often every enabled rule is re-evaluated. A minute is well
// under the shortest interval a rule can be set to, so a reminder never lands
// more than a minute late.
const reminderTick = time.Minute
func newScheduler(db *sql.DB, subs *SubscriptionStore, rules *ReminderStore, key *VAPIDKey) *Scheduler {
return &Scheduler{
db: db,
subs: subs,
rules: rules,
key: key,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (sch *Scheduler) run(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := sch.tick(time.Now().UnixMilli()); err != nil {
log.Printf("reminder tick: %v", err)
}
}
}
// tick evaluates all rules once. now is a parameter so tests can drive it.
func (sch *Scheduler) tick(now int64) error {
// Only users who both switched a rule on and have somewhere to push to.
rows, err := sch.db.Query(`
SELECT DISTINCT r.user_id FROM reminders r
WHERE r.enabled = 1
AND EXISTS (SELECT 1 FROM push_subscriptions s WHERE s.user_id = r.user_id)`)
if err != nil {
return err
}
var users []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
users = append(users, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
sort.Strings(users)
for _, uid := range users {
if err := sch.tickUser(uid, now); err != nil {
log.Printf("reminders for %s: %v", uid, err)
}
}
return nil
}
func (sch *Scheduler) tickUser(userID string, now int64) error {
sleep, err := sch.sleepStateFor(userID)
if err != nil {
return err
}
rows, err := sch.db.Query(
`SELECT kind, enabled, interval_min, last_fired FROM reminders
WHERE user_id = ? AND enabled = 1`, userID)
if err != nil {
return err
}
type pending struct {
rule Reminder
note notification
}
var fire []pending
for rows.Next() {
var r Reminder
var lastFired int64
if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin, &lastFired); err != nil {
rows.Close()
return err
}
note, ok, err := sch.due(userID, r, lastFired, now, sleep)
if err != nil {
rows.Close()
return err
}
if ok {
fire = append(fire, pending{rule: r, note: note})
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(fire) == 0 {
return nil
}
subs, err := sch.subs.forUser(userID)
if err != nil {
return err
}
for _, p := range fire {
// Stamp the fire before sending: a push service that is slow or briefly
// erroring must not cause the same reminder to be retried every tick.
if _, err := sch.db.Exec(
`UPDATE reminders SET last_fired = ? WHERE user_id = ? AND kind = ?`,
now, userID, p.rule.Kind); err != nil {
return err
}
sch.broadcast(subs, p.note)
}
return nil
}
// broadcast sends one notification to every device the user has registered,
// pruning any subscription the push service reports as permanently gone.
func (sch *Scheduler) broadcast(subs []Subscription, note notification) {
payload, err := json.Marshal(note)
if err != nil {
log.Printf("marshal notification: %v", err)
return
}
for _, sub := range subs {
if err := sch.key.send(sch.client, sub, payload, 3600); err != nil {
var pe *pushError
if errors.As(err, &pe) && pe.Gone {
if delErr := sch.subs.delete(sub.Endpoint); delErr != nil {
log.Printf("drop dead subscription: %v", delErr)
}
continue
}
log.Printf("push send: %v", err)
}
}
}
// ---------- handlers ----------
// handleKey hands the client the VAPID public key it must pass as
// applicationServerKey when subscribing. Public by design — it only identifies
// this server; the private half never leaves it.
func (sch *Scheduler) handleKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{"key": sch.key.Public})
}
// handleSubscribe stores (or refreshes) the calling device's subscription.
// Clients re-post on every launch, because iOS quietly drops subscriptions and
// a stale one would silently stop receiving.
func (sch *Scheduler) handleSubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var sub Subscription
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&sub); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := validSubscription(sub); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := sch.subs.save(userID(r), sub); err != nil {
log.Printf("save subscription: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnsubscribe drops one endpoint. Scoped to the caller so one account
// can't delete another's device.
func (sch *Scheduler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if _, err := sch.db.Exec(
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`,
body.Endpoint, userID(r)); err != nil {
log.Printf("delete subscription: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTest pushes one notification to the caller's devices. Push failures are
// invisible from the browser side — especially on iOS — so this is the only
// practical way to tell "not subscribed" apart from "subscribed but undelivered".
func (sch *Scheduler) handleTest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
subs, err := sch.subs.forUser(userID(r))
if err != nil {
log.Printf("test push: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if len(subs) == 0 {
http.Error(w, "no subscriptions", http.StatusNotFound)
return
}
sch.broadcast(subs, notification{
Title: "Reminders are on",
Body: "This is what a reminder looks like.",
Tag: "reminder:test",
URL: "./",
})
w.WriteHeader(http.StatusNoContent)
}
// handleReminders reads and writes the caller's rules.
func (sch *Scheduler) handleReminders(w http.ResponseWriter, r *http.Request) {
uid := userID(r)
switch r.Method {
case http.MethodGet:
case http.MethodPut:
var body struct {
Reminders []Reminder `json:"reminders"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := sch.rules.put(uid, body.Reminders); err != nil {
log.Printf("put reminders: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Both verbs answer with the stored set, so a PUT tells the client exactly
// what was kept after clamping.
list, err := sch.rules.get(uid)
if err != nil {
log.Printf("get reminders: %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(map[string]any{"reminders": list})
}
// validSubscription rejects anything we could not push to later, so a bad row
// never reaches the scheduler. The key sizes are fixed by RFC 8291: an
// uncompressed P-256 point and a 16-byte auth secret.
func validSubscription(sub Subscription) error {
u, err := url.Parse(sub.Endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" {
return errors.New("endpoint must be an https URL")
}
if len(sub.Endpoint) > 2048 {
return errors.New("endpoint too long")
}
p256dh, err := b64.DecodeString(sub.Keys.P256dh)
if err != nil || len(p256dh) != 65 || p256dh[0] != 4 {
return errors.New("bad p256dh key")
}
auth, err := b64.DecodeString(sub.Keys.Auth)
if err != nil || len(auth) != 16 {
return errors.New("bad auth secret")
}
return nil
}
-387
View File
@@ -1,387 +0,0 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
const minute = int64(60 * 1000)
func testScheduler(t *testing.T) *Scheduler {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
key, err := newVAPIDKey()
if err != nil {
t.Fatalf("vapid: %v", err)
}
return newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key)
}
func addEvent(t *testing.T, sch *Scheduler, userID, typ string, at int64) {
t.Helper()
_, err := sch.db.Exec(
`INSERT INTO events (id, type, at, updated, user_id) VALUES (?, ?, ?, ?, ?)`,
typ+"-"+time.Now().Format("150405.000000000")+"-"+string(rune('a'+at%26)), typ, at, at, userID)
if err != nil {
t.Fatalf("insert %s: %v", typ, err)
}
}
func TestDueSleepRuleFiresOnlyWhileAwake(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "sleep", Enabled: true, IntervalMin: 45}
// Awake for an hour: past the 45 minute rule.
addEvent(t, sch, "u1", "sleep-end", now-60*minute)
note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 60*minute})
if err != nil {
t.Fatalf("due: %v", err)
}
if !ok {
t.Fatal("expected the sleep rule to fire after an hour awake")
}
if note.Body != "Awake for 1 h" {
t.Errorf("body = %q, want %q", note.Body, "Awake for 1 h")
}
if note.Tag != "reminder:sleep" {
t.Errorf("tag = %q, want reminder:sleep", note.Tag)
}
// Same elapsed time, but the puppy is now asleep — the rule is satisfied.
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 60*minute}); err != nil || ok {
t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err)
}
// Nothing ever logged: no baseline to measure from.
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{}); err != nil || ok {
t.Errorf("no sleep history: fired = %v (err %v), want no fire", ok, err)
}
}
func TestDueEventRuleSuppressedWhileAsleep(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
addEvent(t, sch, "u1", "pee", now-90*minute)
// Asleep: silent even though the pee clock is well past the interval. This is
// what keeps the rule from nagging all night.
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 30*minute}); err != nil || ok {
t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err)
}
// Awake with the same history: fires immediately, which is what happens the
// moment a sleep-end lands on an already-overdue rule.
note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 1*minute})
if err != nil {
t.Fatalf("due: %v", err)
}
if !ok {
t.Fatal("expected the pee rule to fire while awake and overdue")
}
if note.Body != "No pee logged for 1 h 30 min" {
t.Errorf("body = %q", note.Body)
}
}
func TestDueEventRuleNeedsAnEventToMeasureFrom(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "poo", Enabled: true, IntervalMin: 180}
// No poo has ever been logged, so there is no clock running yet.
if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now}); err != nil || ok {
t.Errorf("no history: fired = %v (err %v), want no fire", ok, err)
}
}
func TestDueRepeatsAtIntervalNotEveryTick(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "eat", Enabled: true, IntervalMin: 240}
awake := sleepState{state: "awake", since: now - 300*minute}
addEvent(t, sch, "u1", "eat", now-300*minute)
// Fired a minute ago: stay quiet rather than re-alerting on every tick.
if _, ok, err := sch.due("u1", rule, now-1*minute, now, awake); err != nil || ok {
t.Errorf("just fired: fired = %v (err %v), want no fire", ok, err)
}
// A full interval later it repeats.
if _, ok, err := sch.due("u1", rule, now-240*minute, now, awake); err != nil || !ok {
t.Errorf("interval elapsed: fired = %v (err %v), want fire", ok, err)
}
}
// A late-syncing device must be able to cancel a reminder retroactively: the
// clock runs on the event's own timestamp, not on when the server heard about it.
func TestDueUsesEventTimeNotSyncTime(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
awake := sleepState{state: "awake", since: now - 300*minute}
// Logged 10 minutes ago on a phone that was offline, synced just now.
_, err := sch.db.Exec(
`INSERT INTO events (id, type, at, updated, user_id) VALUES ('late', 'pee', ?, ?, 'u1')`,
now-10*minute, now)
if err != nil {
t.Fatalf("insert: %v", err)
}
if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || ok {
t.Errorf("late-synced pee: fired = %v (err %v), want no fire", ok, err)
}
}
func TestDeletedEventsDoNotResetTheClock(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60}
awake := sleepState{state: "awake", since: now - 300*minute}
addEvent(t, sch, "u1", "pee", now-90*minute)
// A mistyped pee, logged a minute ago and then deleted: its tombstone must
// not count as the most recent pee.
if _, err := sch.db.Exec(
`INSERT INTO events (id, type, at, updated, deleted, user_id) VALUES ('gone', 'pee', ?, ?, 1, 'u1')`,
now-1*minute, now); err != nil {
t.Fatalf("insert: %v", err)
}
if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || !ok {
t.Errorf("deleted pee: fired = %v (err %v), want fire", ok, err)
}
}
func TestSleepStateMatchesLatestBoundary(t *testing.T) {
sch := testScheduler(t)
now := int64(1_700_000_000_000)
if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "" {
t.Errorf("empty log: state = %q (err %v), want empty", s.state, err)
}
addEvent(t, sch, "u1", "sleep-start", now-120*minute)
addEvent(t, sch, "u1", "sleep-end", now-30*minute)
s, err := sch.sleepStateFor("u1")
if err != nil {
t.Fatalf("sleepStateFor: %v", err)
}
if s.state != "awake" || s.since != now-30*minute {
t.Errorf("state = %q since = %d, want awake since %d", s.state, s.since, now-30*minute)
}
// A later sleep-start flips it back.
addEvent(t, sch, "u1", "sleep-start", now-5*minute)
if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "asleep" {
t.Errorf("state = %q (err %v), want asleep", s.state, err)
}
}
func TestRemindersStoreDefaultsAndClamping(t *testing.T) {
sch := testScheduler(t)
list, err := sch.rules.get("u1")
if err != nil {
t.Fatalf("get: %v", err)
}
if len(list) != len(reminderKinds) {
t.Fatalf("got %d rules, want %d", len(list), len(reminderKinds))
}
for _, r := range list {
if r.Enabled {
t.Errorf("%s enabled by default", r.Kind)
}
}
// Out-of-range intervals are clamped, and unknown kinds ignored entirely.
if err := sch.rules.put("u1", []Reminder{
{Kind: "pee", Enabled: true, IntervalMin: 1},
{Kind: "sleep", Enabled: true, IntervalMin: 99999},
{Kind: "bark", Enabled: true, IntervalMin: 30},
}); err != nil {
t.Fatalf("put: %v", err)
}
got := map[string]Reminder{}
list, _ = sch.rules.get("u1")
for _, r := range list {
got[r.Kind] = r
}
if got["pee"].IntervalMin != 5 {
t.Errorf("pee interval = %d, want clamped to 5", got["pee"].IntervalMin)
}
if got["sleep"].IntervalMin != 24*60 {
t.Errorf("sleep interval = %d, want clamped to %d", got["sleep"].IntervalMin, 24*60)
}
if _, ok := got["bark"]; ok {
t.Error("unknown kind 'bark' was stored")
}
}
// Enabling a rule must clear last_fired, so switching it on alerts right away
// when it is already overdue instead of waiting out a stale interval.
func TestEnablingClearsLastFired(t *testing.T) {
sch := testScheduler(t)
now := time.Now().UnixMilli()
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil {
t.Fatalf("put: %v", err)
}
if _, err := sch.db.Exec(
`UPDATE reminders SET last_fired = ? WHERE user_id = 'u1' AND kind = 'pee'`, now); err != nil {
t.Fatalf("stamp: %v", err)
}
// Off, then on again.
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: false, IntervalMin: 60}}); err != nil {
t.Fatalf("put off: %v", err)
}
if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil {
t.Fatalf("put on: %v", err)
}
var lastFired int64
if err := sch.db.QueryRow(
`SELECT last_fired FROM reminders WHERE user_id = 'u1' AND kind = 'pee'`).Scan(&lastFired); err != nil {
t.Fatalf("scan: %v", err)
}
if lastFired != 0 {
t.Errorf("last_fired = %d, want 0 after re-enabling", lastFired)
}
}
func TestHumanDuration(t *testing.T) {
cases := []struct {
ms int64
want string
}{
{0, "0 min"},
{45 * minute, "45 min"},
{60 * minute, "1 h"},
{72 * minute, "1 h 12 min"},
{-5, "0 min"},
}
for _, c := range cases {
if got := humanDuration(c.ms); got != c.want {
t.Errorf("humanDuration(%d) = %q, want %q", c.ms, got, c.want)
}
}
}
func TestValidSubscription(t *testing.T) {
good := Subscription{Endpoint: "https://push.example.net/x"}
good.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
good.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg"
if err := validSubscription(good); err != nil {
t.Fatalf("valid subscription rejected: %v", err)
}
insecure := good
insecure.Endpoint = "http://push.example.net/x"
if err := validSubscription(insecure); err == nil {
t.Error("http endpoint accepted")
}
shortAuth := good
shortAuth.Keys.Auth = "AAAA"
if err := validSubscription(shortAuth); err == nil {
t.Error("short auth secret accepted")
}
badKey := good
badKey.Keys.P256dh = "AAAA"
if err := validSubscription(badKey); err == nil {
t.Error("malformed p256dh accepted")
}
}
// End-to-end through tick: a due rule reaches a push service with the right
// headers and an encrypted body, gets stamped so it will not immediately repeat,
// and a subscription the service reports as gone is dropped.
func TestTickSendsStampsAndPrunes(t *testing.T) {
sch := testScheduler(t)
now := time.Now().UnixMilli()
type received struct {
encoding string
auth string
body int
}
var got []received
gone := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
got = append(got, received{
encoding: r.Header.Get("Content-Encoding"),
auth: r.Header.Get("Authorization"),
body: len(body),
})
if gone {
w.WriteHeader(http.StatusGone)
return
}
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
sub := Subscription{Endpoint: srv.URL + "/push/abc"}
sub.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
sub.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg"
if err := sch.subs.save("u1", sub); err != nil {
t.Fatalf("save subscription: %v", err)
}
if err := sch.rules.put("u1", []Reminder{{Kind: "sleep", Enabled: true, IntervalMin: 45}}); err != nil {
t.Fatalf("put rule: %v", err)
}
addEvent(t, sch, "u1", "sleep-end", now-60*minute)
if err := sch.tick(now); err != nil {
t.Fatalf("tick: %v", err)
}
if len(got) != 1 {
t.Fatalf("push service saw %d requests, want 1", len(got))
}
if got[0].encoding != "aes128gcm" {
t.Errorf("Content-Encoding = %q, want aes128gcm", got[0].encoding)
}
if !strings.HasPrefix(got[0].auth, "vapid t=") {
t.Errorf("Authorization = %q, want a vapid token", got[0].auth)
}
// Header (16 salt + 4 length + 1 + 65 key) plus a non-empty GCM record.
if got[0].body <= 86 {
t.Errorf("body was %d bytes, want an encrypted record", got[0].body)
}
// Immediately re-ticking must not re-send: last_fired was stamped.
if err := sch.tick(now + 1000); err != nil {
t.Fatalf("tick: %v", err)
}
if len(got) != 1 {
t.Fatalf("re-tick sent again (%d requests total)", len(got))
}
// A full interval later it repeats — and this time the service says the
// subscription is gone, so it must be pruned.
gone = true
if err := sch.tick(now + 46*minute); err != nil {
t.Fatalf("tick: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected a repeat after the interval, got %d requests", len(got))
}
left, err := sch.subs.forUser("u1")
if err != nil {
t.Fatalf("forUser: %v", err)
}
if len(left) != 0 {
t.Errorf("dead subscription was kept: %d remain", len(left))
}
}
-316
View File
@@ -1,316 +0,0 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Web Push, implemented against the RFC rather than pulled in as a dependency:
// message encryption is RFC 8291 (ECDH to a per-message key) wrapped in the
// RFC 8188 aes128gcm content encoding, and the request is authorized with a
// VAPID (RFC 8292) ES256 JWT identifying this server to the push service.
// It is ~150 lines of stdlib crypto, and encryptPayload is checked against the
// RFC 8291 §5 test vector in webpush_test.go.
// b64 is the unpadded base64url alphabet every web push field uses: the keys a
// browser hands us in a PushSubscription, the JWT segments, and the VAPID key.
var b64 = base64.RawURLEncoding
// Subscription is a browser's PushSubscription: where to send, plus the two
// keys its service worker will decrypt with. Stored verbatim per device.
type Subscription struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"` // the client's public key, uncompressed P-256 point
Auth string `json:"auth"` // 16-byte shared authentication secret
} `json:"keys"`
}
// VAPIDKey is this server's identity to push services. The same key must be
// used for the lifetime of a subscription: browsers pin the public key given at
// subscribe time, so rotating it invalidates every existing subscription.
type VAPIDKey struct {
priv *ecdsa.PrivateKey
// Public is the uncompressed public point, base64url — handed to the client
// as applicationServerKey and echoed in the Authorization header.
Public string
}
// vapidFile is the on-disk form of a VAPID key: just the P-256 scalar, so the
// public half is always rederived and can never drift out of sync with it.
type vapidFile struct {
Private string `json:"private"`
}
func newVAPIDKey() (*VAPIDKey, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
return vapidFromKey(priv), nil
}
func vapidFromKey(priv *ecdsa.PrivateKey) *VAPIDKey {
pub, _ := priv.PublicKey.ECDH()
return &VAPIDKey{priv: priv, Public: b64.EncodeToString(pub.Bytes())}
}
func (k *VAPIDKey) marshal() ([]byte, error) {
return json.MarshalIndent(vapidFile{Private: b64.EncodeToString(k.priv.D.FillBytes(make([]byte, 32)))}, "", " ")
}
func parseVAPIDKey(raw []byte) (*VAPIDKey, error) {
var f vapidFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, err
}
return vapidFromSeed(f.Private)
}
// vapidFromSeed rebuilds the keypair from the base64url private scalar, the form
// both the key file and the -vapid-key flag carry.
func vapidFromSeed(seed string) (*VAPIDKey, error) {
d, err := b64.DecodeString(strings.TrimSpace(seed))
if err != nil {
return nil, fmt.Errorf("decode vapid key: %w", err)
}
if len(d) != 32 {
return nil, fmt.Errorf("vapid key must be 32 bytes, got %d", len(d))
}
ecdhPriv, err := ecdh.P256().NewPrivateKey(d)
if err != nil {
return nil, fmt.Errorf("invalid vapid key: %w", err)
}
// crypto/ecdh validated the scalar and derived the point for us; split the
// uncompressed encoding (0x04 | X | Y) back into the coordinates ecdsa wants.
point := ecdhPriv.PublicKey().Bytes()
if len(point) != 65 || point[0] != 4 {
return nil, fmt.Errorf("invalid vapid key: bad public point")
}
priv := &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: elliptic.P256(),
X: new(big.Int).SetBytes(point[1:33]),
Y: new(big.Int).SetBytes(point[33:]),
},
D: new(big.Int).SetBytes(d),
}
return vapidFromKey(priv), nil
}
// authHeader builds the VAPID Authorization header for one push endpoint. The
// audience is the endpoint's origin — a token minted for one push service is
// not valid at another — and the short expiry bounds replay if it leaks.
func (k *VAPIDKey) authHeader(endpoint, subject string) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
claims := map[string]any{
"aud": u.Scheme + "://" + u.Host,
"exp": time.Now().Add(12 * time.Hour).Unix(),
"sub": subject,
}
body, err := json.Marshal(claims)
if err != nil {
return "", err
}
// Header is constant for ES256, so it is spelled out rather than marshalled.
signing := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`)) + "." + b64.EncodeToString(body)
sum := sha256.Sum256([]byte(signing))
r, s, err := ecdsa.Sign(rand.Reader, k.priv, sum[:])
if err != nil {
return "", err
}
// JWS wants the raw r||s pair, fixed-width — not the ASN.1 sequence
// ecdsa.SignASN1 would give us.
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
jwt := signing + "." + b64.EncodeToString(sig)
return "vapid t=" + jwt + ", k=" + k.Public, nil
}
// encryptPayload encrypts plaintext for one subscription per RFC 8291, emitting
// a complete RFC 8188 aes128gcm body: a header carrying the salt and this
// message's ephemeral public key, followed by a single AES-GCM record.
//
// salt and the ephemeral key are parameters rather than generated inline purely
// so the RFC test vector can be reproduced; callers pass nil for both.
func encryptPayload(sub Subscription, plaintext, salt []byte, eph *ecdh.PrivateKey) ([]byte, error) {
clientPubRaw, err := b64.DecodeString(sub.Keys.P256dh)
if err != nil {
return nil, fmt.Errorf("decode p256dh: %w", err)
}
authSecret, err := b64.DecodeString(sub.Keys.Auth)
if err != nil {
return nil, fmt.Errorf("decode auth: %w", err)
}
clientPub, err := ecdh.P256().NewPublicKey(clientPubRaw)
if err != nil {
return nil, fmt.Errorf("invalid p256dh: %w", err)
}
if eph == nil {
if eph, err = ecdh.P256().GenerateKey(rand.Reader); err != nil {
return nil, err
}
}
if salt == nil {
salt = make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
}
shared, err := eph.ECDH(clientPub)
if err != nil {
return nil, fmt.Errorf("ecdh: %w", err)
}
// RFC 8291 §3.4: the auth secret salts a first extraction that binds the
// derived key to *both* public keys, so a message can only be decrypted by
// the subscription it was addressed to.
ephPub := eph.PublicKey().Bytes()
keyInfo := append([]byte("WebPush: info\x00"), clientPubRaw...)
keyInfo = append(keyInfo, ephPub...)
ikm, err := hkdf.Key(sha256.New, shared, authSecret, string(keyInfo), 32)
if err != nil {
return nil, err
}
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
if err != nil {
return nil, err
}
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(cek)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Single record, so the padding delimiter is 0x02 ("last record") with no
// padding after it. Multi-record chunking would use 0x01 for earlier records.
record := gcm.Seal(nil, nonce, append(append([]byte{}, plaintext...), 0x02), nil)
// RFC 8188 §2.1 header: salt | record size | key id length | key id.
var out bytes.Buffer
out.Write(salt)
_ = binary.Write(&out, binary.BigEndian, uint32(4096))
out.WriteByte(byte(len(ephPub)))
out.Write(ephPub)
out.Write(record)
return out.Bytes(), nil
}
// pushError reports a push service rejecting a send. Gone is set for the 404 and
// 410 responses that mean the subscription is permanently dead, which is the
// signal callers use to drop it — any other failure is transient and kept.
type pushError struct {
Status int
Body string
Gone bool
}
func (e *pushError) Error() string {
return fmt.Sprintf("push service returned %d: %s", e.Status, e.Body)
}
// send delivers one encrypted message to a subscription's endpoint.
func (k *VAPIDKey) send(client *http.Client, sub Subscription, payload []byte, ttl int) error {
body, err := encryptPayload(sub, payload, nil, nil)
if err != nil {
return err
}
auth, err := k.authHeader(sub.Endpoint, vapidSubject)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, sub.Endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Encoding", "aes128gcm")
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("TTL", fmt.Sprint(ttl))
// Reminders are only useful while current: if the device is offline long
// enough for a later evaluation to supersede this one, dropping it is right.
req.Header.Set("Urgency", "normal")
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
return nil
}
msg := make([]byte, 512)
n, _ := res.Body.Read(msg)
return &pushError{
Status: res.StatusCode,
Body: strings.TrimSpace(string(msg[:n])),
Gone: res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone,
}
}
// vapidSubject identifies this server to push services. RFC 8292 wants a
// contact URL; push services in practice only require that it be present and
// well-formed, and this app has no operator address to offer.
const vapidSubject = "mailto:puppy-tracker@localhost"
// loadVAPIDKey resolves the server's push identity. An explicit seed (flag or
// env) wins so deployments can hold the key in a secrets file; otherwise it is
// read from path, and generated and persisted there on first run. Rotating this
// key silently breaks every existing subscription, so it is only ever created
// when absent — never regenerated on a read error.
func loadVAPIDKey(seed, path string) (*VAPIDKey, error) {
if seed != "" {
return vapidFromSeed(seed)
}
raw, err := os.ReadFile(path)
if err == nil {
return parseVAPIDKey(raw)
}
if !os.IsNotExist(err) {
return nil, err
}
key, err := newVAPIDKey()
if err != nil {
return nil, err
}
out, err := key.marshal()
if err != nil {
return nil, err
}
// 0600: the private half is the only thing stopping someone else pushing
// notifications to this app's users.
if err := os.WriteFile(path, out, 0o600); err != nil {
return nil, err
}
log.Printf("generated VAPID key at %s", path)
return key, nil
}
-71
View File
@@ -1,71 +0,0 @@
package main
import (
"crypto/ecdh"
"testing"
)
// The worked example from RFC 8291 §5. Reproducing it exactly pins every step
// of the derivation — the ECDH, both HKDF extractions, the record padding and
// the RFC 8188 header layout — against the spec rather than against ourselves.
func TestEncryptPayloadRFC8291Vector(t *testing.T) {
const (
plaintext = "When I grow up, I want to be a watermelon"
authSecret = "BTBZMqHH6r4Tts7J_aSIgg"
receiverPub = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
senderPriv = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
saltB64 = "DGv6ra1nlYgDCS1FRnbzlw"
wantCiphered = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" +
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" +
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
)
var sub Subscription
sub.Endpoint = "https://push.example.net/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV"
sub.Keys.P256dh = receiverPub
sub.Keys.Auth = authSecret
salt, err := b64.DecodeString(saltB64)
if err != nil {
t.Fatalf("decode salt: %v", err)
}
seed, err := b64.DecodeString(senderPriv)
if err != nil {
t.Fatalf("decode sender key: %v", err)
}
eph, err := ecdh.P256().NewPrivateKey(seed)
if err != nil {
t.Fatalf("sender key: %v", err)
}
got, err := encryptPayload(sub, []byte(plaintext), salt, eph)
if err != nil {
t.Fatalf("encryptPayload: %v", err)
}
if b64.EncodeToString(got) != wantCiphered {
t.Errorf("ciphertext mismatch\n got: %s\nwant: %s", b64.EncodeToString(got), wantCiphered)
}
}
// A VAPID key must survive the round trip through its on-disk form, since the
// public half is pinned by every subscription made while it was in use.
func TestVAPIDKeyRoundTrip(t *testing.T) {
key, err := newVAPIDKey()
if err != nil {
t.Fatalf("generate: %v", err)
}
raw, err := key.marshal()
if err != nil {
t.Fatalf("marshal: %v", err)
}
back, err := parseVAPIDKey(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
if back.Public != key.Public {
t.Errorf("public key changed across round trip: %s != %s", back.Public, key.Public)
}
if _, err := back.authHeader("https://push.example.net/push/abc", vapidSubject); err != nil {
t.Errorf("authHeader: %v", err)
}
}
+340 -4182
View File
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
[
{ "date": "2026-09-22", "text": "Tap a bar in the Food (grams) chart and a line under it spells that day out — “Sat, Sep 20 — 340 g”, or “Dry 260 g · Fresh 100 g · 360 g in total” once you are using kinds. A phone has nothing to hover over, so the amount for a given day was previously only readable by eye off the axis. It follows whichever day is highlighted, so the ← → arrows and the date picker move it too, and it says so plainly when a day has no food logged or is marked as not counted" },
{ "date": "2026-09-22", "text": "Meals can be labelled with a kind of food. Make up your own in Settings → Food kinds — dry, fresh, raw, whatever you feed — and pick one when you log a meal; tap the ★ beside one to have it chosen for you automatically. You can also invent a kind from inside the log dialog if you realise you need it mid-meal. All of it is optional: “No kind” is always offered, every meal you have already logged keeps working untouched, and with no kinds defined the app looks and behaves exactly as it did. Once you are using them, today's overview shows the day's food broken down — “Dry 260 g · Fresh 100 g” — under the stat tiles, and the Food (grams) chart splits each day's bar by kind with a legend, and draws a separate trend line for each, so you can see fresh creeping up while dry comes down. The sentence underneath names only the kinds that are actually moving and folds the rest into one clause, so it stays short however many kinds you have. Renaming a kind updates the meals logged as it; deleting one keeps them readable under the name it had. A guest can label a meal with a kind you have created but cannot add, rename or delete them" },
{ "date": "2026-09-21", "text": "Fixed the figures under the Food (grams) chart contradicting each other. It read like “down about 329 g a week — roughly 460 g a day then, 320 g a day now”, where subtracting the two amounts gives 140 g, not 329 g. The rate was worked out per week while the line itself only covers the complete days in the window — at most five of them on a 7-day window, since today isn't finished — so it was stretched past the days it was measured from. It now gives the change between the two ends, which is a figure you can check by subtracting them: “down about 140 g — from roughly 460 g a day to 320 g”" },
{ "date": "2026-09-21", "text": "You can measure the time between two events. Press and hold one row, press and hold another, and a bar along the bottom shows the gap — “3h 42m · Ate 12:10 → Poo 15:52” — which answers things like how long after a meal he needs to go out. It stays there until you clear it with the ✕, so you can change day in between and pick the second event from another day; when the pair straddles midnight the bar shows the dates too. It works on any row that is a single moment: the history log, the notes log and weigh-ins. Holding a row you already picked unpicks it, and a third pick is ignored until you clear. Tapping a row still opens it for editing as before. One cost: because holding a row now means something, you can no longer select the text of a note to copy it" },
{ "date": "2026-09-21", "text": "A day marked “not counted” no longer appears in the Sleep trend or the Walk trend. It was already left out of the average and out of the “yesterday” comparison, but the day you were actually looking at was still drawn as the boldest line on the chart — so the one day you had said not to trust was the one the panel led with. Now it is left off and its legend chip goes with it, leaving the average and yesterday, which is what you would want to see on a day like that" },
{ "date": "2026-09-21", "text": "The Food (grams) chart has a trend line through it now, so you can see whether he is eating more as he grows — the daily bars bounce around enough to hide a steady climb. A line under the chart says what it amounts to in figures: “daily intake is up about 120 g — from roughly 280 g a day to 400 g”. When the day-to-day variation is bigger than any trend, which is most of the time over a short window, it says so and gives the average instead — that is a real measurement, where the ends of the line would only be the line's own guess. Today is left out of the line, since the day isn't finished and including it would drag the line down every morning; days marked “not counted” are skipped too. The line follows the 7 / 14 / 30 day picker like the rest of the charts, and the sentence names the window so you can see it change when you switch. If there aren't four complete days to fit it says so rather than leaving you with an empty chart, and if some meals have no amount recorded it says how many, because those days read lower than they really were" },
{ "date": "2026-09-20", "text": "Fixed the page being wider than the screen on a phone, which is why it had started letting you zoom out. The month grid behind the date was the main culprit: it was centred on the date button, which sits near the right edge, so part of the panel hung off the side of the screen. It is anchored to the edge of the bar now and stays on screen at any width. Also fixed a long unbroken word — a link, or something copied off a food bag — in a history note, an exercise name or its instructions pushing its row wider than the screen instead of wrapping" },
{ "date": "2026-09-20", "text": "Fixed three things that went wrong around a day marked “not counted”. The Timing panel measured “how long since the last pee” from before the marked day rather than from the actual last one, so the marker sat far out to the right. The Sleep and Walk trends drew the marked day's own curve as a flat zero when you were looking at that day — marking a day means don't let it drag the average, not pretend nothing happened on it. And a nap that started on a marked day and ended the next morning vanished from that next day's figures, even though the next day wasn't marked and the puppy really did sleep those hours" },
{ "date": "2026-09-09", "text": "The big asleep/awake card at the top of Today is gone, and both timers now live permanently in the frozen bar at the top — visible on every tab, wherever you have scrolled to. The card only existed on one tab and the timers hid themselves whenever it was on screen, which meant the thing you most often want at a glance was the thing you had to go and find. With only one place left to show them they have their seconds back too" },
{ "date": "2026-09-08", "text": "The date now opens a small month grid of the app's own instead of the browser's date picker. The browser's one is a sheet that covers the screen, which is backwards when the reason to change day is to see what the numbers did on it — this one sits under the bar with the overview still visible and updating as you move. ← and → still step a day at a time; the grid is for jumping further, and the Today button now lives inside it. That is what made room for the walk timer and the sleep timer to sit on one row: the top bar no longer splits onto two rows on a phone, except on the very smallest. The two timers count in minutes now rather than seconds — the big card on Today still ticks in seconds, which is where you look if you want them" },
{ "date": "2026-09-08", "text": "A walk in progress now has a timer in the frozen bar at the top, next to the asleep/awake one, counting from when the walk started — so you can see how long you have been out from any tab without going to look. Tapping it ends the walk, the same way tapping the sleep timer logs the sleep boundary. On a narrow phone two timers no longer fit beside the date controls, so the bar splits onto two rows while a walk is on and goes back to one when it ends. The big timer card on Today shows the walk too while there is one — you are awake on a walk either way, so the walk is the more useful of the two" },
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
{ "date": "2026-09-07", "text": "Switching tabs no longer jumps the page back to the top. The tab row is frozen to the top of the screen, so you change tabs from wherever you have scrolled to, and being thrown back past the log buttons you had just scrolled off was more disruptive than landing part-way down the new tab" },
{ "date": "2026-09-07", "text": "The page is split into five tabs — Today, Sleep, Walks, Habits and Growth — instead of one long column of fourteen panels. Today has the overview, sleep & wake and the day's history; Sleep and Walks each have their day-by-day chart, their when-it-happens grid and their trend; Habits has the pee, poo and meal timing and counts; Growth has weight, training and notes. Getting to the weight curve no longer means scrolling past everything else. The day bar and the log buttons sit above the tabs and stay there whichever one you're on, so logging is still one tap from anywhere. Folding a panel by tapping its heading works exactly as before, inside its tab, and the app reopens on the tab you left it on. The back button (or the back gesture) returns you to Today from wherever you are, and pressing it again leaves the app — always two presses to get out, however much you'd been flicking between tabs beforehand" },
{ "date": "2026-09-07", "text": "Guest links can be copied whenever you want. Settings → Guest access now shows each live link's URL next to it with a Copy button, instead of showing it once when you created it and never again. If you lose the message you sent, or want to pass the same link to someone else, you can just take it again rather than making a new one and leaving whoever already had the old one locked out. Links you made before this change can't be shown — only a scrambled form of those was kept — so revoke one and create a fresh one if you need its URL back" },
{ "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" },
{ "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" },
{ "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" },
{ "date": "2026-09-04", "text": "The three day-long charts — “By hour of day”, “When sleeping” and “When walking” — now mark the current time with a small vertical line and caret. On the sleeping and walking rows it also shows where today's row stops, and it lines the same clock position up across every day above it" },
{ "date": "2026-09-01", "text": "Removed the walking goal from the Walk trend — the dashed target line, its legend chip and the ✓ that marked a day as met. It came from the “five-minute rule” (five minutes per month of age, twice a day), which is a widely repeated rule of thumb rather than veterinary guidance, and the app was stating it more confidently than it deserved. The chart is now just a record of what you walked, against yesterday and the average" },
{ "date": "2026-09-01", "text": "The 7d / 14d / 30d buttons have moved out of the Sleep panel onto their own “Charts cover” row, just under the Log event buttons. They always set the window for every chart on the page — training, timing, sleep, walks, pees/poos/meals — but sitting inside the Sleep panel made them look like a sleep setting" },
{ "date": "2026-09-01", "text": "Dropped the “Darker = more sessions that day” caption under the training consistency grid. Tapping a cell still opens that day" },
{ "date": "2026-09-01", "text": "Dropped two hint lines: the “Based on N pee gaps…” sentence under the Timing charts, and the “Rule of thumb at this age…” one in the Walks panel. The charts above them already say it. The age-based walking goal is still there, drawn as the goal line on the Walk trend" },
{ "date": "2026-09-01", "text": "On the dark theme the awake timers are a warm near-white now — both “Awake for” at the top and the pill in the day bar — instead of yellow text on a yellow-tinted pill. The light theme keeps its dark gold, where white would disappear into a near-white pill" },
{ "date": "2026-09-01", "text": "Toned the awake timer down: the pill in the day bar is a fainter yellow and its text a good deal darker, so “Awake for” is comfortable to read rather than technically legible. The Awake labels elsewhere darkened with it" },
{ "date": "2026-08-31", "text": "The asleep timer text is a deeper blue, so “Asleep for” in the day-bar pill and on the big card is properly readable against its own pale blue background instead of the near-invisible blue-on-blue it was — the same treatment the awake gold just got, and it applies to the Asleep labels in the Sleep & wake list too" },
{ "date": "2026-08-31", "text": "The awake timer is sunshine too now — both the big “Awake for” card and the pill in the day bar — so the whole app tells asleep and awake apart by night-blue against day-gold rather than by blue against purple" },
{ "date": "2026-08-31", "text": "Awake stretches in the Sleep & wake list are sunshine yellow now instead of the app's purple — the same idea as the ☀️ on the timer pill — so asleep and awake read as night and day down the list rather than as two shades of the same accent" },
{ "date": "2026-08-31", "text": "The Walks list reads newest first as well, so a walk in progress is the top row and every list in the app now runs the same way" },
{ "date": "2026-08-31", "text": "The Walks panel and its two charts have moved down the page, below “Pees, poos & meals”" },
{ "date": "2026-08-31", "text": "Sleep windows and Wake windows are one “Sleep & wake” panel now, with the two interleaved into a single list, newest first — awake since 12:30, asleep 11:4012:30, awake since 09:15, and so on back through the day. A wake window is just the gap between two sleeps, so the two lists were always halves of the same sequence, and reading them together makes “only a 45-minute nap after two and a half hours up” obvious in a way two separate lists never did. Whatever is happening right now is the first row, as in the history and notes logs. Each row says which it is and carries a stripe in its colour; whichever one is still running keeps the highlight" },
{ "date": "2026-08-31", "text": "The list of weigh-ins under the Weight chart folds away on its own now — tap “History” inside the panel to collapse just the rows and keep the latest figure and the curve on screen. It remembers the choice like the panels do, and folding the whole Weight panel still takes everything with it" },
{ "date": "2026-08-31", "text": "Walks now get the same two pattern views sleep has. “When … walks” is a day-per-row grid shaded where a walk was on, so you can see at a glance whether the routine is actually regular or drifts around. “Walk trend” draws the minutes walked so far at each point of the day against yesterday and the average over the picked window, with the age-based goal as a line and a ✓ once the day clears it — the line climbs only while a walk is on, so every step is one walk. Both appear under the Walks panel as soon as you have logged a walk, and stay out of the way until then" },
{ "date": "2026-08-31", "text": "The 7d / 14d / 30d buttons now set the window for the Timing panel too. Until now the typical, shortest and longest gaps between pees, poos and meals were always measured over the last 7 days whatever you picked; switch to 30d and they are measured over 30, which settles down the typical gap once there is a month of history to draw on" },
{ "date": "2026-08-31", "text": "The charts are grouped by subject instead of all sharing one “Last 7 days” panel. Sleep hours per day is now its own Sleep panel, sitting just above the “When … sleeps” timeline with the rest of the sleep views. Daily counts and Food have joined the by-hour chart in one “Pees, poos & meals” panel, so how many a day, how much food went with them and what hours they fall in read together. Minutes walked per day has moved into the Walks panel. The 7d / 14d / 30d picker now sits in the Sleep panel and still sets the window for every one of these charts" },
{ "date": "2026-08-31", "text": "A sleep or walk pair is now tied together in the History log by a dotted rail down the side, in that pair's colour, running from the start row to the end row. Anything logged in between sits inside the bracket, so a pee taken on a walk reads as having happened during it. A pair still in progress, or one that ran over from yesterday, leaves its end of the rail open" },
{ "date": "2026-08-31", "text": "Added walks: tap 🦮 Walk start when you head out and 🏁 Walk end when you get home, and the time in between is counted as exercise. The pair gets its own row under the sleep buttons. Today's total and the number of walks show in the overview, every walk of the day is listed in a new Walks section (with the age-based rule of thumb of about five minutes per month of age, twice a day), and the daily charts gain a “Walks (minutes)” bar chart once you have logged one. Like sleep, only the boundary that makes sense is tappable — no walk running means Walk end is greyed out" },
{ "date": "2026-08-31", "text": "The Log event buttons are grouped into rows now instead of flowing into one grid: 😴 Sleep start and ⏰ Sleep end side by side on their own row, then 💧 Pee and 💩 Poo, and last the three that ask for a value — 🍽️ Ate / ⚖️ Weight / 📝 Note — three across. The two halves of a sleep pair can no longer end up split across a wrap, and no button is left stranded alone on the last row. The weigh-in button is now just ⚖️ Weight, which fits the narrower slot; weigh-ins read as “Weight” in the history log to match" },
{ "date": "2026-08-24", "text": "The timing charts now show the longest gap as well: each bar keeps its solid stretch from the shortest to the typical gap and fades on out to the longest one of the week. To stop the nightly long gap from squashing the daytime range into a sliver, the bars are stretched so the typical gap always sits dead centre — left of the middle is sooner than usual, right of it is longer, in every row" },
{ "date": "2026-08-24", "text": "Each row of the timing panel is now a small chart instead of a number: the band spans the shortest to the typical gap over the last 7 days, and the marker is how long it has been since the last one. Inside the band means there is time yet, off the right-hand end means the puppy is due — and a row with only one event so far says so instead of drawing an empty axis" },
{ "date": "2026-08-24", "text": "The timing panel now covers meals too — typical and shortest time between them, alongside the pee and poo gaps — so you can see the feeding rhythm the same way. It is titled just “Timing” now that it is no longer only about bathroom breaks" },
{ "date": "2026-08-24", "text": "The “By hour of day” chart is now tappable: tap any block to read what it counts (“3 meals between 07:00 and 08:00”) just under the chart — until now that number only showed as a hover tooltip, which phones never get. The block you tapped gets a ring" },
{ "date": "2026-08-21", "text": "The sleep timer pill in the day bar — the one that appears once you've scrolled past the big timer — is now tappable: tap it while the puppy is asleep to log the wake-up, or while awake to log a sleep start, without scrolling back up to the buttons" },
{ "date": "2026-08-20", "text": "Added reminders: turn them on in Settings and your phone gets a notification when it's time to sleep (\"Awake for 45 min\") or when there's been no pee, poo or meal for a while. Each one has its own interval, they arrive even with the app closed, and they stay quiet while the puppy is logged as asleep so you're not nagged all night. On iPhone, add Puppy Tracker to your Home Screen first — iOS only allows notifications for installed apps" },
{ "date": "2026-08-18", "text": "The sleep button that would just repeat the last one is now disabled: while asleep you can only tap ⏰ Sleep end, and while awake only 😴 Sleep start — no more accidental double taps creating zero-length sleep windows. If you did miss a boundary, you can still add it at the right time from the event log" },
{ "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" }
]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+162 -569
View File
@@ -7,7 +7,7 @@
<title>Puppy Tracker</title>
<link rel="manifest" href="manifest.json" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="icon-180.png" />
<link rel="apple-touch-icon" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
<script>
// Apply a saved theme before first paint so there's no light/dark flash.
@@ -21,38 +21,21 @@
</script>
</head>
<body>
<!-- Shared SVG defs. Inline SVGs in one document share an id space, so the
hatch every chart uses for a "not counted" day is defined once here
rather than repeated into each chart's markup. Colour comes from CSS,
so it follows the theme. -->
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
<defs>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line class="hatch-line" x1="0" y1="0" x2="0" y2="6" />
</pattern>
</defs>
</svg>
<!-- 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>
<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>
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
<div class="auth-card" id="auth-card">
<div class="auth-card">
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
<form id="auth-form">
@@ -73,18 +56,6 @@
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
</p>
</div>
<!-- Shown instead of the form when a guest link has run out or been
revoked. A guest has no password to sign in with, so offering them
the form would only be confusing. -->
<div class="auth-card" id="guest-ended" hidden>
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub">This guest link has ended</p>
<p class="muted-note">
It either expired or was turned off by the owner. Ask them for a new
link to keep logging.
</p>
</div>
</div>
<div id="app" hidden>
@@ -94,522 +65,187 @@
<div id="puppy-age" class="puppy-age" hidden></div>
</div>
<div class="header-actions">
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree" hidden>🌳</button>
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
<div id="online-status" class="status-pill"></div>
</div>
</header>
<!-- Only ever shown to a guest, so it is obvious whose dog this is, under
which name their entries will appear, and when the link runs out. -->
<p id="guest-banner" class="guest-banner" hidden></p>
<main>
<section class="day-bar">
<!-- Two groups, not seven loose children: on a narrow phone a running
walk adds a second pill and the row no longer fits, so it wraps —
and it has to wrap between the timers and the day controls rather
than splitting the controls across two lines. -->
<span class="bar-timers">
<!-- The asleep/awake timer, and the only one there is: it used to be
the compact twin of a big card on Today, shown once that scrolled
away, but a timer you have to scroll to is no timer at all. It is
frozen at the top instead, on every tab. Hidden until a sleep
event exists. Tapping it logs the boundary that flips the current
state (asleep → sleep end, awake → sleep start). -->
<button type="button" id="bar-clock" class="bar-clock" hidden>
<span id="bar-clock-icon" aria-hidden="true"></span>
<span id="bar-clock-time"></span>
</button>
<!-- Only while a walk is running. Tapping it ends the walk, the same
bargain the sleep pill offers. -->
<button type="button" id="bar-walk" class="bar-clock walking" hidden>
<span aria-hidden="true">🦮</span>
<span id="bar-walk-time"></span>
</button>
</span>
<span class="day-nav">
<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">
<!-- Opens the month grid below rather than the browser's own date
picker: that one is a full-screen sheet on a phone, and the
point of changing day here is watching the figures underneath
change with it. The input stays as the value and is never
shown — every read of the selected day still goes through it. -->
<button type="button" id="day-date-face" class="ghost"
aria-haspopup="dialog" aria-expanded="false"></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>
</span>
<!-- A child of the bar rather than of the date button, even though it
belongs to that button: positioned against the button it would be
centred on something near the right edge, and a 268px panel would
hang off the side of the screen — which makes the whole page wider
than the viewport and lets a phone zoom out. The bar spans the
content width, so anchoring to its edge can't leave the screen. -->
<div id="day-cal" class="day-cal" role="dialog" aria-label="Pick a day" hidden>
<div class="day-cal-head">
<button type="button" id="cal-prev" class="ghost icon-btn" aria-label="Previous month"></button>
<span id="cal-month" class="day-cal-month" aria-live="polite"></span>
<button type="button" id="cal-next" class="ghost icon-btn" aria-label="Next month"></button>
</div>
<div id="cal-weekdays" class="day-cal-weekdays" aria-hidden="true"></div>
<div id="cal-grid" class="day-cal-grid" role="grid"></div>
<button type="button" id="cal-today" class="ghost day-cal-today">Today</button>
</div>
<section id="big-clock" class="big-clock" hidden>
<div class="bc-label" id="bc-label"></div>
<div class="bc-time" id="bc-time">0:00</div>
<div class="bc-since" id="bc-since"></div>
</section>
<section class="quick-actions">
<h2>Log event</h2>
<!-- Explicit rows rather than one auto-fit grid: a start/end pair has
to stay side by side at every width (a flat grid split them apart
at some column counts, and orphaned the last button on a row of
its own). A row per timed pair, then the two one-tap moments, and
last the three that open a dialog to type a value. -->
<div class="actions">
<div class="action-row">
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
<button class="action sleep" data-type="sleep-end">⏰ Sleep end</button>
<div class="grid">
<button class="action sleep" data-type="sleep-start">😴 Sleep start</button>
<button class="action sleep" data-type="sleep-end">⏰ Sleep end</button>
<button class="action eat" data-type="eat">🍽️ Ate</button>
<button class="action pee" data-type="pee">💧 Pee</button>
<button class="action poo" data-type="poo">💩 Poo</button>
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
</div>
</section>
<section class="training" data-panel="training">
<h2>Training</h2>
<ul id="training-list" class="training-list"></ul>
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="exercise-add" class="ghost training-add"> Add exercise</button>
<div class="chart training-chart" id="training-chart-wrap" hidden>
<div class="chart-title">Consistency (last 14 days)</div>
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day over the last 14 days"></svg>
<p class="muted-note">Darker = more sessions that day. Tap a cell to open that day.</p>
</div>
</section>
<section class="day-bar">
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<input type="date" id="day-picker" />
<button type="button" id="day-today" class="ghost">Today</button>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
</section>
<section class="overview" data-panel="overview">
<h2 id="overview-title">Today's overview</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">Sleep</div>
<div class="stat-value" id="stat-sleep">0h 0m</div>
</div>
<div class="action-row">
<button class="action walk" data-type="walk-start">🦮 Walk start</button>
<button class="action walk" data-type="walk-end">🏁 Walk end</button>
<div class="stat">
<div class="stat-label">Awake</div>
<div class="stat-value" id="stat-awake">0h 0m</div>
</div>
<div class="action-row">
<button class="action pee" data-type="pee">💧 Pee</button>
<button class="action poo" data-type="poo">💩 Poo</button>
<div class="stat">
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
</div>
<div class="action-row three-up">
<button class="action eat" data-type="eat">🍽️ Ate</button>
<button class="action weight" data-type="weight">⚖️ Weight</button>
<button class="action note" data-type="note">📝 Note</button>
<div class="stat">
<div class="stat-label">Pees</div>
<div class="stat-value" id="stat-pees">0</div>
</div>
<div class="stat">
<div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div>
</div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div>
<div class="lasts">
<div class="last-row"><span>Last pee</span><span id="last-pee"></span></div>
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
</div>
</section>
<section class="timing" data-panel="timing">
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
<div class="lasts">
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee"></span></div>
<div class="last-row"><span>Shortest between pees</span><span id="gap-pee-min"></span></div>
<div class="last-row"><span>Typical time between poos</span><span id="gap-poo"></span></div>
<div class="last-row"><span>Shortest between poos</span><span id="gap-poo-min"></span></div>
</div>
<p class="muted-note timing-hint" id="timing-hint"></p>
</section>
<section class="sleep" data-panel="sleep-windows">
<h2>Sleep windows</h2>
<ul id="sleep-list" class="wake-list"></ul>
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p>
</section>
<section class="wake" data-panel="wake-windows">
<h2>Wake windows</h2>
<ul id="wake-list" class="wake-list"></ul>
<p id="wake-empty" class="empty">No wake windows yet for this day.</p>
</section>
<section class="weekly" data-panel="weekly">
<h2>Last 7 days</h2>
<div class="chart">
<div class="chart-title">Sleep (hours)</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day for the last 7 days"></svg>
</div>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day for the last 7 days"></svg>
<div class="legend">
<span class="lg pee"><span class="sw"></span>Pees</span>
<span class="lg poo"><span class="sw"></span>Poos</span>
<span class="lg eat"><span class="sw"></span>Meals</span>
</div>
</div>
</section>
<!-- Five tabs so each screen holds one subject rather than all
fourteen panels in one column. The day bar and the log buttons stay
above them: logging has to be one tap from wherever you are. -->
<nav class="tabs" role="tablist" aria-label="Sections">
<button type="button" class="tab" role="tab" data-tab="today" id="tab-today" aria-controls="tabpanel-today" aria-selected="false" tabindex="-1">Today</button>
<button type="button" class="tab" role="tab" data-tab="sleep" id="tab-sleep" aria-controls="tabpanel-sleep" aria-selected="false" tabindex="-1">Sleep</button>
<button type="button" class="tab" role="tab" data-tab="walks" id="tab-walks" aria-controls="tabpanel-walks" aria-selected="false" tabindex="-1">Walks</button>
<button type="button" class="tab" role="tab" data-tab="habits" id="tab-habits" aria-controls="tabpanel-habits" aria-selected="false" tabindex="-1">Habits</button>
<button type="button" class="tab" role="tab" data-tab="growth" id="tab-growth" aria-controls="tabpanel-growth" aria-selected="false" tabindex="-1">Growth</button>
</nav>
<section class="patterns" data-panel="sleep-timeline">
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note">(last 14 days)</span></h2>
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 228" role="img" aria-label="Sleep periods per day over the last 14 days"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
</section>
<!-- Page-level, not a panel's: every panel below that covers more than
one day reads this, so it sits on its own row above them all rather
than inside one of them, where it read as that panel's own control
(see renderChartWindow). -->
<div class="chart-window">
<span class="chart-window-label">Charts cover</span>
<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>
<section class="patterns" data-panel="hour-heatmap">
<h2>By hour of day <span class="muted-note">(last 14 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 over the last 14 days"></svg>
<p class="muted-note">Darker = happens more often at that hour.</p>
</section>
<section class="weight" data-panel="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
<div class="stat-label">Latest</div>
<div class="stat-value" id="weight-latest"></div>
</div>
<div class="stat">
<div class="stat-label">Since last</div>
<div class="stat-value" id="weight-change"></div>
</div>
</div>
</div>
<div class="chart">
<div class="chart-title">Weight (kg)</div>
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</section>
<div class="tab-panel" data-tab="today" id="tabpanel-today" role="tabpanel" aria-labelledby="tab-today" hidden>
<section class="overview" data-panel="overview">
<!-- The toggle lives here rather than in the day bar: that bar is held
to one row on small phones and a sixth control would break it,
while this panel is the selected day's summary and has the room. -->
<div class="overview-head">
<h2 id="overview-title">Today's overview</h2>
<button type="button" id="exclude-day" class="ghost exclude-btn"
aria-pressed="false"
title="Leave this day out of the charts and averages">⊘ Not counted</button>
</div>
<p id="excluded-note" class="muted-note excluded-note" hidden>
Not counted in the charts and averages. Everything below is unchanged.
</p>
<div class="stats">
<div class="stat">
<div class="stat-label">Sleep</div>
<div class="stat-value" id="stat-sleep">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Awake</div>
<div class="stat-value" id="stat-awake">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Walks</div>
<div class="stat-value" id="stat-walk">0m</div>
<div class="stat-sub" id="stat-walk-count" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Pees</div>
<div class="stat-value" id="stat-pees">0</div>
</div>
<div class="stat">
<div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div>
</div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div>
<!-- The day's food broken down by kind. Its own line rather than
inside the Meals tile: that tile is about 90px wide, and a
breakdown of two or three kinds will not sit in it. Hidden unless
a meal that day actually carries a kind. -->
<p id="stat-food-kinds" class="muted-note food-kind-split" hidden></p>
<div class="lasts">
<div class="last-row"><span>Last pee</span><span id="last-pee"></span></div>
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row"><span>Last walk</span><span id="last-walk"></span></div>
</div>
</section>
<!-- Sleep and wake windows are the same boundaries read two ways — a wake
window is exactly the gap between two sleeps — so they interleave into
one alternating list rather than sitting in two panels that each show
half the day. Every row carries its state; the open one keeps the
highlight. -->
<section class="sleepwake" data-panel="sleep-wake">
<h2>Sleep &amp; wake</h2>
<ul id="sleep-wake-list" class="wake-list"></ul>
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
</section>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p>
</section>
</div>
<div class="tab-panel" data-tab="sleep" id="tabpanel-sleep" role="tabpanel" aria-labelledby="tab-sleep" hidden>
<section class="patterns" data-panel="sleep-daily">
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Hours per day</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
</div>
</section>
<section class="patterns" data-panel="sleep-timeline">
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. The marker is now. 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" id="legend-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>
</div>
<div class="tab-panel" data-tab="walks" id="tabpanel-walks" role="tabpanel" aria-labelledby="tab-walks" hidden>
<section class="walks" data-panel="walks">
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
<ul id="walk-list" class="wake-list"></ul>
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<!-- The two walk patterns mirror the sleep ones on the Sleep tab, drawn
from walk windows instead of sleep windows. Both stay hidden until
there is a walk to draw, so they cost nothing to anyone not
tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. The marker is now. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<h2>Walk trend</h2>
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, and the recent average"></svg>
<div class="legend">
<span class="lg wtrend-today" id="legend-wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
</div>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
</div>
<div class="tab-panel" data-tab="habits" id="tabpanel-habits" role="tabpanel" aria-labelledby="tab-habits" hidden>
<section class="timing" data-panel="timing">
<h2>Timing <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<!-- One range chart per type, drawn by drawTimingChart: the band spans
the shortest to the typical gap and the marker is how long it has
been since the last one, so a marker past the band reads as due. -->
<div class="timing-charts">
<div class="timing-item">
<div class="timing-name">Pees</div>
<svg id="timing-chart-pee" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-pee" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Poos</div>
<svg id="timing-chart-poo" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-poo" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Meals</div>
<svg id="timing-chart-eat" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
</div>
</div>
<!-- The axis is stretched (see drawTimingChart), so say what the middle
of a bar means rather than leave it to be inferred. -->
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap in the window.</p>
</section>
<!-- One panel for the three views of the same events: how many a day,
how much food went with them, and what hours they fall in. -->
<section class="patterns" data-panel="counts">
<h2>Pees, poos &amp; meals <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<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, split by kind, with a trend line through each"></svg>
<div id="grams-legend" class="legend" hidden></div>
<!-- The highlighted day, broken down. Tapping a bar selects that
day (as it does on every chart), so this is what the selection
amounts to here — there is no hover on a phone, and the bar's
tooltip is unreachable. -->
<p id="grams-day-info" class="muted-note food-day-info" aria-live="polite" hidden></p>
<!-- What the trend line says, and when it is not saying anything —
see renderFoodTrendNote. -->
<p id="grams-note" class="muted-note" hidden></p>
</div>
<div class="chart">
<div class="chart-title">By hour of day</div>
<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 id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
<p class="muted-note">Darker = happens more often at that hour; the marker is now.</p>
</div>
</section>
</div>
<div class="tab-panel" data-tab="growth" id="tabpanel-growth" role="tabpanel" aria-labelledby="tab-growth" hidden>
<section class="weight" data-panel="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
<div class="stat-label">Latest</div>
<div class="stat-value" id="weight-latest"></div>
</div>
<div class="stat">
<div class="stat-label">Since last</div>
<div class="stat-value" id="weight-change"></div>
</div>
</div>
<div class="chart">
<div class="chart-title">Weight (kg)</div>
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div>
<!-- Folds on its own h3, independently of the Weight panel around it:
the row list grows by one every weigh-in, and the summary and the
curve above it are what you usually want left on screen. -->
<div class="subpanel" data-panel="weight-history">
<h3>History</h3>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</div>
</section>
<section class="training" data-panel="training">
<h2>Training</h2>
<ul id="training-list" class="training-list"></ul>
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
<div class="chart training-chart" id="training-chart-wrap" hidden>
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
</div>
</section>
<section class="notes-log" data-panel="notes">
<h2>Notes</h2>
<ul id="notes-list" class="event-list"></ul>
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
</section>
</div>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p>
</section>
</main>
<footer class="app-footer">
<button type="button" id="changelog-btn" class="linklike">Changelog</button>
</footer>
</div><!-- /#app -->
<!-- Pedigree lookup. A distinct full-screen view (hides #app while open)
that resolves a dog by chip / registration number / name against SKK
and renders its ancestry as a tree. Online-only. -->
<div id="pedigree-screen" class="pedigree-screen" hidden>
<header class="pedigree-header">
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back"></button>
<h1>🌳 Pedigree</h1>
<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">
<form method="dialog" id="settings-form">
<h3>Puppy settings</h3>
<!-- The profile is the owner's to set, so this block is hidden for a
guest; the two toggles below it are device-local preferences and
stay for everyone. -->
<div id="settings-profile">
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label>Pedigree ID
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
placeholder="SKK chip or reg. number (optional)" />
</label>
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
</div>
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label 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>
<hr class="settings-sep" />
<div id="reminders-section" hidden>
<h4 class="settings-subhead">Reminders</h4>
<label class="toggle-row">
<span>Push notifications</span>
<input type="checkbox" id="reminders-enabled" role="switch" class="switch" />
</label>
<p class="settings-hint" id="reminders-hint" hidden></p>
<div id="reminders-rules" hidden></div>
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
</div>
<!-- The food kinds a meal can be labelled with. Owner-only, like the
exercise library. Empty by default and entirely optional: an
account with no kinds never sees a picker when logging. -->
<div id="food-kinds-section">
<hr class="settings-sep" />
<h4 class="settings-subhead">Food kinds</h4>
<p class="settings-hint">
Label a meal with the sort of food it was — dry, fresh, whatever you
feed. Optional: with none defined, nothing changes, and “No kind”
stays available even once you have some.
</p>
<ul id="food-kind-list" class="food-kind-list"></ul>
<p id="food-kind-empty" class="settings-hint">No kinds yet.</p>
<div class="kind-new">
<input type="text" id="food-kind-name" maxlength="30" autocomplete="off" placeholder="e.g. Dry" />
<button type="button" id="food-kind-add" class="ghost">Add</button>
</div>
</div>
<!-- Guest links: hand a dog sitter a URL that logs events on this
account without giving them the password. Owner-only. -->
<div id="guest-access">
<hr class="settings-sep" />
<h4 class="settings-subhead">Guest access</h4>
<p class="settings-hint">
A link that lets someone log events on this account — no password,
no account of their own. It stops working on its own, and you can
turn it off at any time.
</p>
<label>Who is it for?
<input type="text" id="guest-label" autocomplete="off" maxlength="40"
placeholder="e.g. Anna (sitter)" />
</label>
<label>Works until
<input type="date" id="guest-expires" />
</label>
<p class="settings-hint" id="guest-expires-hint"></p>
<button type="button" id="guest-create" class="ghost">Create link</button>
<p id="guest-error" class="auth-error" hidden></p>
<ul id="guest-list" class="guest-list"></ul>
<p id="guest-empty" class="settings-hint">No active links.</p>
</div>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
</menu>
<div id="settings-danger">
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
</div>
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
</form>
</dialog>
@@ -641,11 +277,8 @@
<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="delete" id="exercise-delete" class="danger" hidden>Delete</button>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="exercise-save">Save</button>
</menu>
</form>
@@ -664,27 +297,13 @@
<label id="note-weight-field" hidden>Weight (kg)
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
</label>
<label id="note-grams-field" hidden>Amount (g)
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
</label>
<!-- Which sort of food. Only on a meal, and only once at least one kind
exists: someone who never defines one should never see it. "No
kind" is always an option and is where everyone starts. The chips
are built by renderKindPicker. -->
<div id="note-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="note-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
<div id="note-kind-new" class="kind-new" hidden>
<input type="text" id="note-kind-name" maxlength="30" autocomplete="off" placeholder="New kind, e.g. Fresh" />
<button type="button" id="note-kind-add" class="ghost">Add</button>
</div>
</div>
<label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label>
<div class="photo-field">
<input type="file" id="note-photo-input" accept="image/*" multiple hidden />
<button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden />
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</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>
<menu>
@@ -696,14 +315,7 @@
<dialog id="edit-dialog">
<form method="dialog" id="edit-form">
<h3 id="edit-title">Edit event</h3>
<p id="edit-logged-by" class="settings-hint" hidden></p>
<!-- Shown to a guest looking at an entry that isn't theirs: the dialog
opens read-only rather than not opening at all, so the details are
still there to read. -->
<p id="edit-readonly" class="settings-hint" hidden>
This was logged on the owner's own account, so only they can change it.
</p>
<h3>Edit event</h3>
<label>Time
<div class="time-row">
<input type="date" id="edit-date" />
@@ -713,22 +325,13 @@
<label id="edit-weight-field" hidden>Weight (kg)
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
</label>
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<!-- The same picker, so a meal's kind can be corrected after the fact.
No "new kind" box here: inventing one belongs where you are
logging, not where you are fixing a typo. -->
<div id="edit-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="edit-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
</div>
<label>Note
<textarea id="edit-note" rows="4"></textarea>
</label>
<div class="photo-field">
<input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden />
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</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>
<menu>
@@ -745,16 +348,6 @@
</dialog>
<!-- Brief confirmation after a one-tap quick log, with Undo / Add note. -->
<!-- Long-press two event rows and this holds the time between them until
you clear it — so you can change day in between and still be measuring.
Fixed at the bottom like the snackbar, and stays put where that one
fades; the snackbar lifts above it when both are on screen. -->
<div id="measure-bar" class="measure-bar" hidden role="status" aria-live="polite">
<span id="measure-duration" class="measure-duration"></span>
<span id="measure-detail" class="measure-detail"></span>
<button type="button" id="measure-clear" class="measure-clear" aria-label="Clear the measurement"></button>
</div>
<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>
-10
View File
@@ -13,16 +13,6 @@
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+57 -1345
View File
File diff suppressed because it is too large Load Diff
+1 -59
View File
@@ -13,21 +13,11 @@ const ASSETS = [
"./app.js",
"./manifest.json",
"./icon.svg",
"./icon-180.png",
"./icon-192.png",
"./icon-512.png",
"./changelog.json",
];
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(
caches.open(CACHE).then((cache) =>
cache.addAll(ASSETS.map((u) => new Request(u, { cache: "reload" })))
)
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
);
// 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
@@ -80,11 +70,6 @@ self.addEventListener("fetch", (event) => {
// Other API calls: never cache — sync must reflect live server state.
if (url.pathname.includes("/api/")) return;
// Guest links: a one-shot secret URL that must reach the server to be
// redeemed, and that has no business being written into the asset cache
// under a key containing its token.
if (url.pathname.includes("/guest/")) return;
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
@@ -100,46 +85,3 @@ self.addEventListener("fetch", (event) => {
})
);
});
// ---------- push reminders ----------
// The server evaluates reminder rules and pushes the ones that come due (see
// server/reminders.go). Subscriptions are userVisibleOnly, so every push must
// result in a notification — there is no silent path to fall back on.
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch (err) {
data = {};
}
// The tag is what makes a repeat of the same reminder replace the previous
// notification instead of stacking another one on the lock screen, and
// renotify:false lets that replacement happen without re-alerting.
event.waitUntil(
self.registration.showNotification(data.title || "Puppy Tracker", {
body: data.body || "",
tag: data.tag || "reminder",
renotify: false,
icon: "./icon-192.png",
badge: "./icon-192.png",
data: { url: data.url || "./" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "./";
// Prefer focusing a tab the app is already open in — opening a second window
// onto the same PWA is disorienting and loses whatever was on screen.
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if ("focus" in client) return client.focus();
}
return self.clients.openWindow(url);
})
);
});