Commit Graph
22 Commits
Author SHA1 Message Date
Alexander Heldt fab0ae1420 Add a Meals log to Habits, with a bulk label for unlabelled meals
A meal's amount and its kind are both optional by design, which is what
makes them easy to lose track of: a meal with no kind sits in the grey
band on the food chart, one with no grams is dropped from it silently,
and there was nowhere to go and see which meals those were.

The panel lists every meal across every day, ignoring the day picker as
the Notes log does — the ones worth finding are spread through history
rather than sitting on the day being viewed. Each filter chip carries
its own count, so the size of the gap reads off the panel without
selecting anything. Rows go through attachRowHandlers, so tapping one
opens the usual edit dialog and long-press still measures.

On the "no kind" filter it also offers to label the whole list at once,
which is what history needs after kinds arrived months into logging.
backfillFoodKind does it in one pass — one save, one sync, one render,
following setDefaultFoodKind rather than calling updateEvent hundreds of
times; sync already posts the whole event list, so it costs nothing
extra on the wire. The optional cut-off date filters the list as well as
the edit, so the number on the button is the rows on screen: labelling
all of history as one kind is wrong if the food was switched partway,
and once labelled the early meals cannot be told apart. There is no
undo, so the guards are the live count, the number on the button and a
confirm. Owner-only; the server's guest rules would refuse it anyway.

Meals are the only event type that gets this — the only one with
optional fields worth filling in afterwards.

Nothing appears until there is a meal to list, and the kind filter and
bulk block wait for a kind to exist, so an account not using kinds is
unchanged.
2026-09-22 15:38:13 +00:00
Alexander Heldt 5a08fb4510 Let a meal say what kind of food it was
Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.

The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.

Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.

Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.

The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.

One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.

A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
2026-09-22 10:31:27 +00:00
Alexander Heldt 556e4d75a8 Measure the time between two events by long-pressing them
"How long after eating did he poo?" is answerable from the log, but only by
reading two times off the screen and subtracting them — and the pair is often
on different days, so it is rarely on screen together at all. Hold one row,
hold another, and a bar along the bottom does the subtraction and keeps it
until you clear it, which is what lets you change day between the two picks.

Any row that is a single moment can be picked: history, notes, weigh-ins. Sleep
and walk rows cannot, being spans — measuring from one would need a rule about
which end, and a rule you have to remember is worse than the feature.

The picks are a module-level variable rather than storage. A measurement is a
question you are asking now, not a setting; but module-level is also what
carries it through the re-render a background sync causes every minute, which
would otherwise wipe a half-made measurement. Ids that stop resolving — deleted
here, tombstoned by another device — leave the pick on the next render instead
of lingering as half a pair.

Two additions beyond what was asked. Holding a picked row unpicks it: that is
not a third selection but an undo of one, and without it a mis-press costs a
clear. And the reading is ordered by time rather than by which was pressed
first, so it is always chronological and never negative — pressing upward
through a log is the natural way to read it.

The press mechanics are all load-bearing: a finger that travels is a scroll and
cancels, a fired press swallows the click that would otherwise also open the
edit dialog, and the platform's own long-press menu is suppressed. That last
part needs user-select: none on the rows, which costs the ability to select a
note's text to copy. Worth stating plainly — it is a real loss, taken because
holding a row now means something else.

checks/extract.mjs gained getters for mutable bindings while writing the checks
for this. It only ever returned a let's value at load time, so measurePick went
stale the moment the code reassigned it and the checks were quietly asserting
against a snapshot. Any future check reading a mutable binding would have hit
the same thing.
2026-09-21 20:53:56 +00:00
Alexander Heldt 59cf567946 Add frontend checks, run from the repo
The server has go test; the frontend had nothing, and the things most likely to
break there are the ones hardest to see: the arithmetic behind the charts, a
panel lost while shuffling tabs, a label that truncates on a phone none of us
owns. There is no browser in this loop, so these are what can be checked
without one.

They read the real code rather than copying it. The app is one long IIFE with
nothing exported, and adding a module system or a build step to make it
testable would be a large change in service of a small one — so
checks/extract.mjs reads src/app.js, brace-matches the declarations a check
asks for, and evaluates them. Rename a function and it throws by name. A check
quietly exercising a stale copy of the code would be worse than no check, and
that is the failure mode this avoids.

calendarGridStart is pulled out of renderCalendar as part of this. It is the
one line of the month grid that is easy to get wrong and impossible to notice
— a month starting on the week's first day needs no backing up, one starting
the day before needs six — so it earns a name and a test.

The width figures are estimates, not measurements: layout numbers come out of
style.css so they cannot drift, text is sized from per-character advances, and
the pass mark demands a few pixels of headroom 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, and nothing here replaces looking at a phone.

No new dependencies: nodejs is already in the devShell for `node --check`, and
checks/ sits outside src/ so it is not served with the app.
2026-09-20 10:43:13 +00:00
Alexander Heldt 8d7139b031 Stop a marked day distorting Timing, the trends, and its neighbour
Marking a day "not counted" was implemented by filtering its events out of the
list the cross-day panels are given. That is too blunt a tool, because three of
those panels are not asking "which days count":

  - Timing's marker is how long since the last pee, which is a question about
    now. With the marked day's events gone it answered from the day before —
    27 hours instead of 2 in the case I reproduced, so the marker sat off the
    end of its band.

  - The sleep and walk trends draw the day you are looking at against yesterday
    and the average. Marking that day collapsed its own curve to a flat zero.
    Marking a day means don't let it drag the average, not pretend nothing
    happened on it.

  - A nap from 23:00 on a marked day to 07:00 the next morning lost its
    sleep-start, leaving a dangling sleep-end that pairWindows discards. The
    next day — not marked — lost seven hours it really slept. Nobody reported
    this one; it turned up while reproducing the other two.

None of them needed the filtering, because each already excludes marked days
itself and more precisely than deleting events can: gapsBetween throws away a
gap that *touches* one, the trend loops skip them when averaging, weeklyData
and the actograms zero and hatch them. The filter was a second mechanism
fighting the first. Only the by-hour and training panels still get the filtered
list — they bucket individual events and care about neither day boundaries nor
spans, which is exactly what removing events does.
2026-09-20 10:42:51 +00:00
Alexander Heldt e4b056b5b9 Drop the big clock; keep both timers in the bar, with seconds
The asleep/awake counter rendered twice: a big card at the top of Today, and a
pill in the frozen bar that stayed invisible until the card scrolled out of
sight. That arrangement made the timer hardest to see exactly when you wanted
it — it lived on one tab in five, and hid itself whenever it was on screen.

So the card goes. Both timers sit in the frozen bar, on every tab, always
visible. That takes the standby mechanism with it, along with the rect
comparison that decided when to engage it, renderBigClockCard, and one of the
two jobs the scroll handler was doing.

Having a single place to render them is also what pays for the seconds. They
were cut to minutes last change to buy width; the month grid has since given
back about 93px by taking Today off the bar, which more than covers the 32px
the seconds cost. One row from 360px up now, wrapping only on a 320px SE and
the 280px foldable.

The pills stay at 0.9rem rather than going back to 1rem. It is tempting now
they are the only timer, but there are 6px of slack at 375px and the larger
type adds about 10, which would wrap an iPhone SE 2 and an iPhone 8.
2026-09-09 04:13:03 +00:00
Alexander Heldt fbacd97da7 Replace the date picker with a month grid of our own
The day bar had run out of room: two timers and four day controls came to more
than the bar's width on every phone, so it wrapped onto two rows whenever a walk
was running. Shaving pixels off both groups was not enough — even the most
compact form of it only fitted a 428px iPhone Plus.

The way out was structural. The browser's date picker is a sheet that covers
the screen, which is backwards here: the reason to change day is to see what
the figures did on it, and a modal hides exactly the thing you opened it for.
So the date button now opens a small panel under the bar instead — month name
with arrows, locale-ordered weekday initials, six rows of days — with the
overview still on screen and updating as you move through it.

That also solved the width, because Today belongs inside the grid rather than
beside it. The day controls drop from about 204px to 111px, which is enough for
both timers on one row from 320px up; only a 280px foldable cover still wraps.
The bar keeps its wrapping for that case and for large system font sizes.

The hidden input stays as the value everything reads — selectedDay() and every
caller are untouched, and only the input's own picker is no longer opened.

Six rows always, so the panel does not change height from month to month.
Future days are disabled, matching the bar's → being disabled on today. The
week starts where Intl says it does for the reader's locale, falling back to
Monday. Arrow keys walk the grid and pull the neighbouring month into view at
the edges.

The grid arithmetic is checked rather than eyeballed, both week starts across
every month of a year, leap years and year boundaries — including February
2026, which begins on a Sunday and so needs six leading days from January under
Monday weeks. That is the case a naive `1 - getDay()` gets wrong, and it would
have been invisible until somebody happened to open that month.
2026-09-09 04:03:09 +00:00
Alexander Heldt fba0f73717 Show a timer in the day bar while a walk is on
The bar already carries the asleep/awake counter; a walk in progress had
nothing, so "how long have we been out" meant going to the Walks panel to look.
It gets a second pill now, counting from the walk's start, and tapping it ends
the walk — the same bargain the sleep pill offers for the sleep boundary.

Two timers no longer fit beside the day controls on a narrow phone, so the bar
wraps. That needed the children grouping first: with seven loose ones the break
could land anywhere, and stranding "Today" alone on a second line is worse than
not wrapping at all. The timers are one group and the day controls another, so
the wrap falls between them.

Two things follow from the bar changing height. The tab bar sticks to that
height, and the ResizeObserver added when the pill first appearing had the same
effect already covers it — nothing new needed. And both pills go to standby
together while the big card is on screen: standby is visibility, not display,
so the bar keeps its wrapped height while you scroll and the tab bar beneath it
does not shuffle.

The card shows the walk while there is one. It has room for a single timer, and
you are necessarily awake on a walk, so "awake for 3h" is the less useful of the
two readings; the bar keeps both. That also meant moving the card out of the
early return for "no sleep logged yet" — a walk can be the first thing ever
recorded, and the card was staying blank through it.
2026-09-08 20:46:04 +00:00
Alexander Heldt 0dfbab82cf Split the page into five tabs
Fourteen panels sat in one column, so reaching the weight curve meant scrolling
past sleep, timing, walks and counts. The page had only grown — walks, walk
patterns, training and the excluded-day marker all landed on the same scroll —
and folding panels away, while it helps, is a per-panel fiddle you then have to
undo to look at anything.

They are grouped by subject now: Today (overview, sleep & wake, history), Sleep,
Walks, Habits (pee/poo/meal timing and counts) and Growth (weight, training,
notes). No tab holds more than three. The day bar and the log buttons stay above
them on every tab, because logging has to be one tap from wherever you are, and
the tab bar sticks under the day bar — two stacked stickies need the second's
offset to be the first's height, so that height is measured and published as
--day-bar-h rather than guessed.

What gets hidden is the wrapper, never the sections inside it. 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, hidden tabs included. Nothing
measures layout — the charts scale through their viewBox, and the one
getBoundingClientRect belongs to the timer pill — so drawing into a hidden
wrapper is safe, and a tab is never briefly stale when you arrive on it. That
pill's own check gains an explicit "is the card's tab showing": a hidden element
measures as zeroes, which gave the right answer here by coincidence rather than
by rule.

Back returns to Today from any tab in one press, and a second press leaves.
Exactly one history entry is ever live, armed on leaving Today and spent on
returning — including when the return is a tap on the Today tab, which would
otherwise strand the entry and make the next press appear to do nothing. An
entry per switch is what a browser does unaided, and is why tabbed apps get a
reputation for trapping you.

Folding is untouched and composes: tabs group, folding tunes what shows within a
group. Both selectors that reach for panels are descendant selectors, so the
extra nesting cost them nothing.

The reordering was scripted rather than done by hand — fourteen sections moving
between five wrappers is how you silently lose one — and a check now asserts
every panel sits in exactly one tab, that buttons and wrappers correspond, and
that the aria pairs are wired. The first run of that script dropped three
explanatory comments along the way, which is exactly the sort of thing it exists
to catch.
2026-09-07 19:55:40 +00:00
Alexander Heldt 66f89b35a9 Keep showing a guest link's URL so it can be copied again
The URL was shown once, in a box under the create button, and then gone: only
a hash of the token was stored, so the app genuinely could not produce it a
second time. Lose the message you sent the sitter and the only way back was to
mint a new link — which strands whoever is already holding the old one.

Settings now lists every live link with its URL and a Copy button, so re-sending
one is just copying it again.

That means keeping the token rather than only its hash, and it is worth being
plain about the trade. It is not the trade you would make for a password, which
the user has probably reused, or a session token, which grants everything
indefinitely. A guest link grants a strict subset of what the same database
already holds in plaintext, expires on a date the owner picked, and can be
revoked in one tap — so an attacker who can read puppy.db gains very little by
also being able to open it as a guest. The lookup column stays a hash and
remains the key redeem matches against; the secret sits in a new column beside
it, which also keeps the migration additive.

Links created before this have an empty secret. They keep working and stay
revocable — the migration touches nothing but the new column — and the list
says why their URL is missing rather than rendering a broken one.

The two tests that asserted the old contract now assert the new one: a listing
hands back a secret that really opens the link, and the lookup column is still
a hash. Added one for the legacy row, since "still works, just cannot be shown"
is the part a future change is most likely to break quietly.
2026-09-07 19:25:20 +00:00
Alexander Heldt da68b733e4 Let a day be left out of the stats
Every logged day was treated as equally trustworthy, and they aren't. A day
someone else had the puppy leaves a thin record that reads exactly like a real
one — five hours of sleep, two pees, no walk — and then drags down the average,
widens the longest gap in the Timing panel and puts a trough in every chart
that never happened. "Not counted", in the overview panel's heading, takes the
day you are looking at out of everything that aggregates across days.

Nothing is deleted or hidden. The day's own overview, history and sleep & wake
list are exactly as they were, dimmed and labelled; navigate to it and it is
all still there. Only the cross-day views stop seeing it, and weight and notes
keep counting wherever they fall — a weigh-in and a vet note are facts you
recorded, not behaviour a sparse logger distorts.

The mark is an ordinary event, the way a training session is. That was the
whole reason to do it this way: a set of marks that sync per-item with
last-write-wins and tombstones is exactly what the event contract already
provides, so un-marking is a delete, offline works, and two devices marking the
same day resolve themselves. An excluded_days table would have meant a table,
an endpoint, a request/response pair and a client cache to re-derive semantics
already in hand. Every renderer selects events by type, so a new type is inert
everywhere it isn't wanted; only the History log has to filter it out, being
the one view that shows whatever it is handed.

render() already computed the event list once and fanned it out, which made the
seam a single place: day-scoped panels keep the full list, weight and notes
keep it too, and the seven cross-day renderers take a counted one.

Filtering alone gets two things wrong, and those are most of the diff.

An empty slot lies. A marked day with no events draws a zero bar, which reads
as "the puppy barely slept" — precisely the misreading the mark exists to
prevent. So weeklyData zeroes the day's figures and flags it, and the four bar
charts, both actograms and the training grid paint a hatch in the slot instead.
Zeroing centrally rather than in each chart means every axis maximum, total and
tooltip downstream is already right. The slot stays: dropping it would make
consecutive bars stop being consecutive days.

Gaps balloon. gapsBetween subtracts consecutive events, so with a day's events
gone Tuesday's last pee sits next to Thursday's first and the subtraction
invents thirty hours — worse for the panel than the sparse day ever was. Any
gap whose interval touches a marked day is therefore discarded rather than
measured. Sleep and walk durations need no such care: sleepMsInRange and
walkMsInRange already clip to the day being measured, so a nap running in from
a marked day contributes only its counted part.

Both trend charts skip marked days explicitly rather than leaning on their
existing "any sleep at all" guard, which would have let a nap crossing midnight
give a marked day a non-zero total and sneak it back into the average.

Owner-only, alongside the rest of what a guest may not decide: a sitter should
not be able to rule their own thin day out, nor quietly take a good one out of
the averages. The server drops day-excluded events arriving on a guest session;
the client hides the control to match.
2026-09-07 19:01:06 +00:00
Alexander Heldt e22031ed4f Add guest links for temporary shared access
Handing a dog sitter the ability to log a pee meant handing them the account
password: permanent, total control, revocable only by changing it. Settings →
Guest access now mints a URL that does the one thing instead.

A link is a session, not an account. Opening /guest/<token> inserts an ordinary
session row against the owner's user_id, tagged with the link it came from, so
every data path downstream — sync, photos, the profile — stays scoped by
user_id exactly as before and needed no changes at all. Only the capability
checks differ by role, which is what kept this from touching the sync contract.
Redemption is a plain GET so tapping the link in a message works, and the 303
to / leaves the token out of the address bar, bookmarks and the PWA start URL.

What a guest cannot change is enforced in the upsert, not in the UI. The WHERE
clause gains a logged_by_share test: an owner (empty share id) may change
anything, a guest only rows carrying their own link's id. A sitter can fix up
their own entries and cannot rewrite or delete one of the owner's, including
everything logged before this existed, since those rows carry the empty id too.
Deletes come along free, being tombstones. The test is on the link id rather
than its label because two links can easily both be "Sitter", and the id is
also why /api/me hands the guest its share id: the client needs it to know what
to grey out. The exercise library is the owner's on the same reasoning — a
guest trains against it but the server drops any exercise a guest sends.

Attribution is stamped from the session on insert and left out of DO UPDATE
SET, so it is decided once by whoever logged the event and survives every later
edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs
the owner's whole event list on every sync, but those rows already exist and
keep their stored values.

Expiry is a date the owner picks; the link dies at the end of that day in their
own timezone, which the client computes because the server has no way to know
it. Sessions are capped at the link's own end, and every request re-checks the
link is live rather than trusting the session row, so revoking kicks a guest
out on their next request instead of whenever their session happens to lapse.
Only the token hash is stored, as with session tokens, so the URL is shown once
at creation and cannot be read back.

The client side follows from that. A guest opening someone else's entry gets
the edit dialog read-only rather than a form that would silently discard what
they typed, and mergeSynced takes the server's copy for anything they may not
change — otherwise a refused write would sit in their cache forever showing an
edit that never happened. An ended link wipes their cached copy of someone
else's history and says so, rather than offering a sign-in form they have no
password for.
2026-09-07 11:19:20 +00:00
Alexander Heldt f162ac5732 Track walks as timed exercise
A walk is a start and an end, so it reuses the shape sleep already has rather
than inventing one: walk-start / walk-end events, paired into windows, with a
trailing unmatched start meaning "out right now". The server stores type as an
opaque string, so nothing there changes and the events ride the existing sync.

Pairing boundaries into windows was written out twice already, once for sleep
and once for its inverse, so this pulls the scan into pairWindows(open, close)
and makes all three callers of it. Same for the latest-boundary lookup behind
currentSleepState, which currentWalkState now shares — including the updatedAt
tie-break, which matters as soon as a start and an end land in the same minute.

They are called walks, not exercise. "Exercise" is already taken by the
training definitions (their own synced collection, and exerciseId on training
events), and two meanings of the word in one app would be worse than the
slightly narrower name.

The day's total leads the overview tile with the count underneath, since the
question is how much exercise the puppy got rather than how many outings it
took. The Walks panel lists the day's windows and carries the total in its
heading so a collapsed panel still answers it, and the daily charts gain a
minutes-per-day bar chart that stays hidden until there is a walk to draw —
the grams chart's rule. The panel also states the five-minute rule for the
puppy's current age, the same way the sleep trend states a goal band.

Walk boundaries answer to the walk state, not the sleep one, so "Walk end" is
disabled with no walk running and stays undimmed mid-walk even while the puppy
is logged asleep.
2026-08-31 21:23:40 +00:00
Alexander Heldt 51d015c231 Add push reminders for sleep, pee, poo and meals
A closed PWA has no timers, so reminders are evaluated on the server: the
event log is already there (clients sync on every mutation), and a ticker
re-checks each enabled rule once a minute and pushes the ones that are 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 and stay quiet while the puppy is asleep — otherwise they nag all
night, and suppressing them means an overdue rule instead fires promptly on
waking, which is when it actually matters. Sleep state is derived exactly the
way currentSleepState() does in app.js, tie-break included, so both sides
always agree. Rules read the event's own timestamp rather than when it synced,
so a pee logged offline at 03:10 cancels the reminder retroactively.

Every push carries a tag, so a repeat replaces the previous notification
instead of stacking another one on the lock screen. last_fired is server-owned
and not writable by a client, so a stale device can't force a re-fire.

Web Push is implemented directly rather than pulled in as a dependency: RFC
8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token,
stdlib only, checked against the RFC 8291 test vector. The key is generated
into vapid.json beside the DB or supplied via -vapid-key; without one the
server logs a warning, skips registering the routes, and the client hides the
UI. Subscriptions a push service reports as 404/410 are dropped.

PNG icons are added because iOS gates push on a Home Screen install and
rejects SVG for apple-touch-icon, and Android has no notification icon
without them.
2026-08-20 17:19:18 +00:00
Alexander Heldt c2f74e64c8 Gate pedigree behind a dog id set in settings
The pedigree view is now opt-in and tied to your own dog rather than an
always-present free-text search. Add the dog's SKK chip or registration
number in Settings (it rides the synced profile alongside name and
birthday); the 🌳 button stays hidden until one is set, then opens the
page and loads that dog's ancestry directly.

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

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

A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
2026-07-26 09:22:14 +00:00
Alexander Heldt 2e817a086d Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same
LWW/tombstone contract as events, served by POST /api/exercises/sync.
Training sessions are ordinary events (type "training") referencing an
exercise by id, so they ride the existing event sync unchanged.

The Training panel lists each exercise with last-trained / this-week /
streak stats, expandable instructions, and a one-tap Log button with the
usual undo/add-note snackbar. An exercise-by-day heatmap shows the last
14 days of consistency, and history and the daily overview count
training sessions like any other event.
2026-07-12 16:54:02 +00:00
Alexander Heldt 5c016ca49e Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it
owns. DELETE /api/me re-checks the password (guarding an unattended session),
then wipes the user's events, config, sessions and user row in one transaction
and removes their photos/<user_id>/ directory. The client clears the account's
local cache and returns to the login screen.

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

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

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

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

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

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

On first start the server auto-imports any legacy events.json/config.json
sitting in the data dir, renaming them to *.imported. The -data flag now
points at puppy.db; photos still live on the filesystem alongside it.
2026-07-09 16:56:35 +00:00
Alexander Heldt f14a749169 Track age and weight 2026-07-04 09:20:31 +00:00
Alexander Heldt d61e88fa29 Light of day 2026-06-21 17:41:56 +00:00