18d3778241630eecf1a9aee893a10bfe4ae65ae1
122
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
18d3778241 |
Break today's food down by kind in the overview
The Food chart splits by kind but today's overview did not, so the one place you look first still lumped dry and fresh into a single total. It is a line under the stat tiles rather than part of the Meals tile. That tile is about 90px wide with a 0.7rem sub-line, and two kinds will not sit in it without wrapping into a mess — so the tile keeps the day's total, which is the headline figure, and the breakdown gets the room it needs. It follows the chart's conventions so the two read as one breakdown rather than two arbitrary lists: the same layer order, "No kind" last, a kind deleted since still named through its tombstone, and a meal logged without an amount adding nothing. Hidden unless a meal that day carries a kind, which keeps the overview untouched for anyone not using them — the case the first assertion in the new suite pins down. This was a gap rather than a reversal: when the split was scoped to "the grams chart only", the options named the counts chart, the by-hour heatmap and Timing as staying put. The overview's food total was in neither list, so it was never decided either way. |
||
|
|
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. |
||
|
|
9e47aa53ff |
Stop the food trend's figures contradicting each other
The caption read like "down about 329 g a week — roughly 460 g a day then, 320 g a day now". Subtract the two amounts and you get 140 g, not 329. The arithmetic behind it was self-consistent, but the sentence was not, and a caption a reader can disprove by subtracting its own numbers is wrong whatever the code was doing. The rate was slope x 7, while the line only covers the complete days in the window. Today is never fitted, being unfinished, so a 7-day window leaves at most five days and any marked day takes another — in this case about three. The rate was therefore stretched well past the days it was measured from, and the two endpoints, which were not, could never agree with it. It quotes the change between the two ends now, which is the one figure a reader can check: "down about 140 g — from roughly 460 g a day to 320 g." The change is derived from the rounded ends rather than from the slope, so the subtraction works exactly rather than to within the rounding. The weekly rate goes rather than being repaired. It cannot reconcile on a short window, and it only ever meant anything where the fit spanned a week or more — which is not something to leave as a trap for whichever window the reader happens to have picked. The check that let this through asserted the sentence contained certain phrases, not that its numbers agreed with each other. There is now one that parses all three figures back out and asserts the move is exactly the difference of the ends, across each window length; it fails against the old wording, which is the only evidence worth having that it would have caught this. |
||
|
|
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. |
||
|
|
e4a5c3fe29 |
Leave a day that doesn't count off the trends entirely
The Sleep and Walk trends already kept a day marked "not counted" out of the window average and out of the "yesterday" comparison. What they still drew was that day's own curve, when it was the day you had selected — as the boldest line on the panel. So the single day you had said not to trust was the one the chart led with, against references that had carefully excluded it. It is left off now, along with its legend chip and, for the sleep trend, the projected tail that continued it. What remains is the average and yesterday, which is what you would want to look at on a day like that. This reverses part of an earlier fix. That one stopped the curve being drawn as a flat zero, on the reasoning that marking a day means "don't let it drag the average" rather than "pretend nothing happened". The flat zero was certainly wrong, but so was the conclusion: a real curve for an untrusted day is still the wrong thing to lead with. Absent is the honest third option. The walk trend gets the same treatment. It is the same panel in different units, and the two disagreeing about what a marked day means would be worse than either answer. |
||
|
|
7451650b6f |
Give the food trend's figures, not only its rate
"Up about 40 g a week" is a rate with nothing to anchor it: it says the line slopes without saying where it sits. The sentence now names both ends of the fit — "roughly 280 g a day then, 400 g a day now" — which is the reading anyone actually wants from a growth chart. When the fit is not trustworthy it quotes the average instead, and says the day-to-day variation is larger than any trend. That difference is the point. The average is a measurement and survives the noise; the ends of the line are the line's own output, and quoting them on a fit nobody should read would dress a guess up as a reading. Everything rounds to 10 g for the same reason — "287 g a day" would be false precision from four noisy points. The wording moves into a pure foodTrendSentence() so those rules can be checked, which is worth doing precisely because they are judgement rather than arithmetic: the checks now pin that a clear climb gives both figures, a flat run gives the average and no endpoints, a see-saw gives neither, and nothing is ever quoted to the gram. |
||
|
|
668f1f039e |
Draw a trend line through the food bars
The daily grams bars bounce around enough to hide a steady climb, so they cannot answer the question you actually have about a growing puppy: is he eating more than he was? A least-squares fit through them can. Two kinds of day stay out of the fit. Today is half-eaten, and including it would pull the line down every morning and let it drift back up as meals go in — a line that tracks the clock rather than the dog. A day marked "not counted" has a hatch rather than a figure, and fitting a zero there would invent a dip. The line is drawn only across the days it was fitted on, so it never implies it knows about the ones it skipped. The caption is the part that needed the care. A straight line through seven noisy points will always have a slope, and announcing it as a fact is the same mistake the walking goal made. So a direction is named only when the fitted climb is larger than the scatter of the days around it, and only when it clears 5 g a week and a twentieth of a typical day; otherwise it says the variation is larger than any trend, which over a short window is usually the truth. It names its window too — "over the last 14 days" — because the 7/14/30 picker already drove this (weeklyData builds the array the fit runs on) but nothing on screen said so, and the line moves too little between windows to show it. When there are fewer than four complete days it now says why there is no line rather than leaving bars with nothing through them. Meals can be logged without an amount, so the note counts them: a day can read low because he ate little or because nobody typed the number, and the chart should not let those look the same. The checks cover the refusals rather than the arithmetic — a see-saw is not reported as a trend, a slope under the scatter is not either, three days will not fit, a marked day does not shift the line, and each window length reaches the fit intact. |
||
|
|
14cad44d9b |
Stop the page growing wider than the screen
A phone had started allowing zoom-out, which is how a document wider than the viewport announces itself. The suspect was the "Ate" modal, but the dialogs are not it: all seven open with showModal(), so their containing block is the viewport and width: calc(100% - 32px) cannot exceed it. It was the month grid, added three commits ago: left: 50%; transform: translateX(-50%); width: 268px; max-width bounded the panel's width and nothing bounded its position. Centred on the date button — which sits near the right edge of the bar — a 268px panel hangs off the side of a phone, and being absolutely positioned it drags the document's scrollable width out with it. Anchoring to the button cannot be made safe: pin it right and it overflows the left on a narrow screen, pin it left and it overflows the right. So it is a child of the day bar now, pinned to that bar's inner edge and capped at the bar's own width. The bar spans the content width exactly, so the panel is on screen at every size by construction. A long unbroken word was a second way in, and a pre-existing one. A history row's note is a flex item with neither min-width: 0 nor a break rule, so a URL or something copied off a food bag sets its content-based minimum and widens the row. The Notes log directly below already guarded against precisely this, so the history row had simply been missed; exercise names and their instructions had the same gap. The checks gained the general form of both, since this class of bug is invisible until a phone starts zooming out: every element that renders text the user typed must be able to break a long word, the grid must stay edge-anchored inside the bar, and no fixed width may exceed the content box of a 320px phone. Each was confirmed to fail with its fix reverted. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
7c0ccca1b2 |
Stop the Today tab jumping the viewport too
The last change stopped showTab scrolling, which fixed four of the five tabs. Today kept jumping, because getting there is not an ordinary tab switch: it is a history step. Tapping the tab spends the armed entry with history.back(), the back button pops the same one, and either way the browser restores the scroll position it saved against the entry it lands on — wherever you happened to be when you left Today. showTab scrolling nothing made no difference; the scroll was the browser's, not ours. So scroll restoration is turned off for the document. Tabs are not pages and carry no scroll of their own to restore, so the automatic behaviour has nothing useful to offer here. It also governs reloads, which now open at the top, which is the right place for this app to start anyway. The harness gained an assertion for it, and it was checked by removing the line and watching it fail — a browser silently undoing what the code just did is exactly the sort of thing that slips past a test that was never seen to break. |
||
|
|
39343ff2a4 |
Join the tab row to the frozen date row
The tab row was painted in the page ground while the day bar above it is a card, and the two sat as separate bars with the day bar's rounded corners cutting between them — so the frozen top of the screen read as two mismatched strips rather than one thing. It takes the same card now: surface colour, radius and shadow. Once the log buttons have scrolled away and the two bars meet, they lose the seam and share one frame — the day bar squares its bottom corners and gives up its shadow, the tab row squares its top, and the pair casts a single shadow below. Scrolled back to the top they are two cards again, which is right: the log buttons genuinely sit between them there. CSS has no way to ask whether a sticky element is currently stuck, so a class is toggled from JS on the simplest available test — whether the two are touching. It rides the rAF-throttled scroll handler that already existed for the timer pill, so it adds no listener, and it is re-checked on a tab switch (a shorter tab can leave the page too short to stay scrolled) and when the day bar changes height. The tab row's sticky offset is now a pixel less than the day bar's height. The safe-area inset is free to be fractional while the measured height is a whole number, and that shortfall would show as a hairline of page ground between the two; the overlap it costs is invisible, since the day bar paints on top in the same colour. |
||
|
|
de1e18e394 |
Stop tab switches jumping the page to the top
Switching tabs scrolled back to the top, on the theory that arriving halfway down a different tab is disorienting. In practice it is the wrong way round: the tab row is sticky, so you switch tabs *from* wherever you have scrolled to, and being thrown back past the log buttons you had deliberately scrolled off is more disruptive than landing part-way down the new tab. So it simply does not scroll now. If the new tab is shorter than the old scroll position the browser clamps on its own and needs no help. The `scroll` option goes with it rather than being defaulted off — nothing passes it any more, and a parameter no caller uses is a worse thing to leave behind than the behaviour it guarded. |
||
|
|
e7b54b82fd |
Fit the tab labels on narrow phones
Five labels across a 320px viewport is tighter than it looks. The body's own 16px gutters, the bar's padding and four gaps leave about 45px of text per tab, and "Growth" — the widest label at roughly 3.2em — wants 46px at the default size. So on an iPhone SE and the smaller Androids it truncated to "Growt…", and on a 280px foldable cover screen it was 7px short. The same 370px breakpoint the day bar already uses for the same reason now trims the type to 0.75rem and the horizontal padding to 2px, which takes the widest label to about 39px against 43px of room even at 280px. Above the breakpoint nothing changes; there was never a problem there. Also fixes the sticky offset going stale. The tab bar sits directly under the day bar, so its `top` is that bar's measured height, published as --day-bar-h. That was measured on load and on resize — but the bar also grows the first time a sleep is logged, when the timer pill appears inside it, and that fires no resize, leaving the tab bar overlapping the day bar until something else happened to trigger one. A ResizeObserver on the bar covers that and every other cause, with the resize listener kept as the fallback. The label arithmetic is now checked rather than eyeballed, since no browser is available here: the layout numbers are read back out of the stylesheet so they cannot drift, text width is estimated from per-character advances, and the pass mark demands real headroom rather than a bare fit because the estimate is only good to a few percent. Removing the new rules makes it fail on exactly the two devices that were broken, which is the only way to know a check like that is doing anything. |
||
|
|
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. |
||
|
|
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. |
||
|
|
babed44c25 |
Hide buttons and panels that were marked hidden
A guest opening an entry the owner had logged still saw Delete and Save on it.
The read-only branch had been setting .hidden on both since guest links landed;
the attribute was there, and the buttons rendered anyway.
The user agent hides [hidden] elements with display: none, but that is a UA
rule and loses to any author rule setting a display — and `button` here sets
display: inline-flex, so no button the app hides has ever actually gone away.
The stylesheet already knew this in eight places: .auth-screen[hidden],
.snackbar[hidden], dialog label[hidden] and five more, each added when someone
noticed that particular element misbehaving. What that pattern cannot do is fix
the cases nobody has hit yet, and there were five sitting there: the 🌳 pedigree
button showed before a pedigree ID was set, the reminder rule rows showed while
unsubscribed, "send a test notification" showed when push was unavailable, the
pedigree disambiguation list stayed up after being dismissed, and the exercise
dialog offered Delete while adding a new exercise rather than editing one.
So this states it once, globally, and drops all ten per-selector rules. It
needs !important: the offenders are not all the same weight, and
`#reminders-rules { display: flex }` is an id selector that outranks any
attribute selector available. That is the right trade here — nothing should
ever want an element painted after being marked hidden, so there is no case the
blunt version gets wrong.
Only the two dialog buttons are new breakage; the other five predate the recent
work and are the reason this is a global fix rather than a sixth patch.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
103a5f9937 |
Mark now on the day-long charts
The hour heatmap and the two actograms all run midnight to midnight, and none of them said where in that span you currently are. On the heatmap that made the darkest block ambiguous: a column at 18h is either a habit or simply the hour you are standing in with the rest of the day not yet lived. On the actograms it hid the reason today's row stops early — it stops at now, not at bedtime. One rule serves all three. drawActogram already draws both timelines, so the line lands there once, and the heatmap appends the same parts after its hit rects. Ink over a surface-coloured underlay, the same treatment the timing chart's "since the last one" marker uses, so it stays readable crossing a shaded sleep window, a dark cell or bare track. A caret hangs above the track to keep it from reading as one more hour gridline, and the whole thing is pointer-events: none so it cannot swallow a tap meant for the row underneath. Deliberately not the accent colour: on these charts accent already means "the selected day", and now is a different kind of fact. The captions gain a clause naming the marker, which runs against the last few commits taking captions away. Those removed lines restated what the picture already said. This one does not: a thin vertical line has no inherent meaning, and unlike darker-means-more there is nothing to infer it from. |
||
|
|
122897c732 |
Remove the walking goal
It was mine, not the user's: added unprompted alongside the Walks panel because sleepTargetFor existed and it seemed to fit. The number came from the "five-minute rule" — five minutes per month of age, twice a day — written from general knowledge with no source consulted and none cited in the code. That rule is widely repeated but is folk guidance, not a veterinary standard. Its origin is unclear, it is contested by what evidence exists on early exercise and joint development, and it ignores breed, terrain, lead or off-lead and the individual dog. Drawing it as a line labelled "Goal", with a ✓ for clearing it, stated it far more confidently than a rule of thumb earns — particularly in an app whose entire job is otherwise to record what happened rather than to advise. So the target line goes, along with its legend chip, the ✓, and walkTargetFor itself, which had no other caller. The trend keeps today against yesterday and the average: a record, with the judgement left to whoever knows the dog. |
||
|
|
31e04fb9a1 |
Lift the chart window out of the Sleep panel
It governs nearly every panel on the page — the training grid, the timing charts, both sleep patterns, the counts panel, all three walk views — but it sat inside Sleep, so it read as a sleep setting. That was already the reason its old home carried a comment explaining it was not one; a control needing a comment to deny what its position says is in the wrong position. It now has its own row under the quick actions, above the first panel that reads it, with a "Charts cover" label to say what it does. The row skips the card chrome: it is a control, not content, and giving it a section's surface and shadow would have made it look like a panel with nothing in it. No JS change — both the click wiring and renderChartWindow address the buttons by class, so they never cared where the picker lived. Also removes that old comment, which had stopped being wrong about the picker and started being wrong about the panel: the walks move carried it along by accident and left it sitting above Weight, explaining a control three panels away. |
||
|
|
1c591bfb99 |
Drop the training grid's caption
Third of the explanatory lines to go, and the same case as the other two: it told you darker meant more, which a heatmap says by being one. The tap-to-open-that-day half went with it, but the behaviour did not — the cells are still wired through setChartSVG. Losing the sentence costs the only hint that they are tappable at all, which is the trade the other captions made too. |
||
|
|
9d8d1b6c0d |
Drop the timing and walking hint lines
Both restated their own chart in prose. The timing hint gave the typical pee gap as a sentence directly under a row whose whole job is to show that gap on a track, and the walking one gave a target the walk trend already draws as a line with a chip naming the number. Removed with them: the pee-gap cache in renderTiming, which existed only so the hint and the pee row could share one computation, and the .timing-hint rule. median and walkTargetFor stay — the timing chart and the walk trend's goal line are still their callers. The changelog entry announcing walks keeps its mention of the rule of thumb. That one shipped and was true at the time, so a new entry retires it rather than an edit pretending it was never there. |
||
|
|
83eb50960f |
Warm the awake timer text off pure white
Pure white belonged to no colour in the app; the timers it painted read as uncoloured rather than as the sunshine end of the pair. #fdf4da is white with a sixth of the gold mixed in — warm enough to be recognisably the same family as the pill it sits on, pale enough that it is nothing like --wake-ink, which is where "yellow text on a yellow pill" came from. 11.83:1 on the pill and 12.36 on the card, so the legibility that white bought is intact. The light theme is untouched: anything this pale on a cream pill would be gone. The changelog entry from the previous commit is amended rather than followed by one of its own — it has not shipped, and "white" was only ever a description of this same line of the stylesheet. |
||
|
|
9c34827387 |
White text on the awake timers in the dark theme
The last two attempts at this were invisible because they only moved the light theme's ink. --wake-ink flips per theme and the dark value was left at #f5c451 throughout — a bright gold, on a pill tinted the same hue. Yellow on yellow was exactly the report, and it was untouched by both changes made in answer to it. --wake-timer-ink carries white for the two timers on the dark theme, where the pill is a deep olive and white lands at 12.99:1. The light theme keeps the gold ink: the same pill there is near-white cream, and white on it would be 1.05:1 — gone. So this is one value that cannot be shared across the themes even in principle, only chosen per theme. Kept apart from --wake-ink so the AWAKE labels in the sleep & wake list stay gold and go on matching the stripe beside them, which is the one place the colour is carrying meaning rather than just filling in text. |
||
|
|
3baff5f6dc |
Tone down the awake pill
Reported as too yellow and hard to read, and both were fair. The pill measured 4.71:1, which clears the bar on paper but only just, and the pair it clears it with — dark yellow on light yellow — differ almost only in lightness, so there is no hue separation doing any of the work. The purple it replaced measured worse at 3.46 and still read more easily, which is the tell that the number was not the whole story. Treating 4.71 as sufficient rather than as barely-passing was the mistake. Two changes, one per complaint. The pill's tint drops from 18% to 12%, where asleep keeps 18%: equal percentages of these two hues are not equally strong, and the yellow shouted at a share the blue carries quietly. And --wake-ink goes from #8f6400 to #6f4d00, which the labels in the list and on the big card take along with the pill — they were light by the same reasoning, just against paler grounds that hid it. The pill now measures 7.10 in the light theme and 7.98 in the dark, against the asleep pill's 5.01, and all sixteen text sites the two states cover still clear 4.5:1, worst 5.01. |
||
|
|
0ea9374b46 |
Give asleep a readable ink as well
--sleep was doing the same two jobs --wake had just been split out of: the pale tint behind a timer and the text on top of it. As text it never worked — 2.57:1 on the day-bar pill, 2.79 on the big card, 2.85 on the list label — and the dark theme was no better off than the light one, at 4.07 on the pill, which I had assumed was fine until I measured it. So --sleep-ink joins --wake-ink, deep blue in the light theme and a lighter one in the dark, and the four places that painted text with --sleep now use it. The tints, stripes, chart bars and borders keep --sleep: nothing about the colour of sleep changes, only the colour of writing about it. All sixteen text sites the two states cover — pill, big clock, list label and open-row label, each state, each theme — now clear 4.5:1, the worst being 4.71. The pedigree's ♂ keeps the raw --sleep. It is the same blue serving an unrelated meaning, and recolouring it here would be a change to the pedigree made for reasons that have nothing to do with it. |
||
|
|
f9becddbc6 |
Carry the sunshine to the two awake timers
The list said awake in gold while the big card and the day-bar pill still said it in purple, which left the app describing one state in two colours. Both move across, so asleep against awake is night-blue against day-gold everywhere. Text takes --wake-ink, not --wake. A gold light enough to read as sunshine is not legible as text on a pale ground: the bright value measures 1.71:1 against the card it would sit on, where the ink makes 4.96, and 4.71 on the pill's tint — the same split the pee button has always used, dark ink on the yellow rather than yellow ink. Worth noting for later: this now makes awake considerably easier to read than asleep, whose blue-on-blue pill has been sitting at 2.57:1 since long before any of this. Untouched, being a separate question about the sleep colour. |
||
|
|
a7608829e9 |
Paint awake as sunshine, not accent
Awake rows in the sleep & wake list borrowed --accent, which meant purple did
double duty: it was the colour of "awake" and also the colour of "this row is
the one still running", so the two readings overlapped on exactly the row where
both applied. A warm gold takes the first job. Asleep against awake now reads
as night against day, which is the distinction the list exists to draw, and it
agrees with the ☀️ the timer pill already shows.
Two variables rather than one. --wake is the bright gold, for the stripe and
the open row's tint. --wake-ink is that warmth pulled dark, for the small
uppercase label, and it is the value that flips with the theme: a yellow legible
on white is not legible on near-black, and vice versa. Measured on the four
backgrounds it lands on, it clears 4.5:1 in both themes, ordinary rows and the
tinted open one alike — better than the purple it replaces, which only ever made
the large-text bar.
The gold sits near --pee, but the two never share a panel: pee is a dot in the
history log and a bar in the counts chart, awake is a stripe in a list of
windows.
Scoped to the list, as asked. The big timer card and the day-bar pill still
paint awake with the accent.
|
||
|
|
9d69bac73b |
Read the walks list newest first too
Needed, on the evidence: the history log, the notes log, the weigh-in list and now sleep & wake all run newest first, so the walks list was the only one in the app reading the other way. A short list makes the inconsistency cheap rather than absent — two panels of the same rows in the same shape should not have to be checked for direction before they can be read. Reversed at the point of display rather than in walkWindowsForDay. Its other two callers — the day's total and the history rails — read the windows as a set, and giving a shared helper a display order would only make them harder to follow. |
||
|
|
68840847f0 |
Read the sleep & wake list newest first
The merged list ran oldest first, which put last night's sleep at the top and pushed whatever is happening now further down with every window the day adds — so the row you look at most often was the one that moved. Reversed, it matches the history and notes logs: the open row leads, and the day reads backwards from where you are. The interleaving is untouched, since alternation survives either direction. The changelog entry for the merge is corrected rather than answered with a second one. It walked through an example day in the old direction, and the build that shipped it is the same one this lands in — nobody has a version where the list read the other way. |
||
|
|
02be8ce3e7 |
Interleave the sleep and wake lists, move walks down
Sleep windows and Wake windows were two panels showing two halves of one sequence: a wake window is precisely the gap between two sleeps, so the panels alternated with each other and neither could be read as the day's rhythm on its own. Answering "how long was he up before that nap?" meant looking at one panel, holding a time in your head, and finding it in the other. They are one list now, the two window sets sorted together by start time, which is all the interleaving that alternating-by-construction needs. Each row states whether it is asleep or awake and carries a stripe in its colour, so the alternation reads before the words do, and the row still running keeps the highlight it had. renderWindowList grew the ability for a window to bring its own class and label; the single-kind lists still pass one of each for the whole list and tag only the open row, where "Walking" is news rather than a repeat of the heading it sits under. One rule had to be spelled out: the new per-kind stripe ties on specificity with the generic .ww.ongoing, and being later would have won, leaving an open awake row with a faded edge instead of its full-strength one. The walks group — the panel and both its patterns — moves below "Pees, poos & meals". The three stay together: they were grouped under Walks deliberately, and leaving the patterns behind would strand two walk charts among the sleep ones. Both old panel keys go stale, so anyone who had folded Sleep windows or Wake windows gets the merged panel open, as with any panel that is new to a build. |
||
|
|
50d0d8a726 |
Let the weigh-in list fold on its own
The Weight panel holds three things, and only one of them grows: the latest figure and the curve stay one screen forever, while the row list gains a line every weigh-in until it pushes everything else off. Folding the panel to get rid of it also takes away the two parts worth keeping. So the fold mechanism now works on any keyed element inside main, not only on a section, and the list moves into a block that folds by its own h3 while the panel keeps folding by its h2. The two nest without special handling — separate keys, and a folded section hides the block along with the rest, so the block's own state is simply there again when the section reopens. The CSS generalises from section.collapsible to a class, but scoped to main: the pedigree tree uses .collapsed for its own branches, and an unscoped selector would have folded every card in it. |
||
|
|
8f9e8783fa |
Give walks the two sleep pattern views
Walks had the overview tile, the window list and minutes per day, but not the two views that answer the questions sleep already answers: when in the day does it actually happen, and is today keeping up. "When … walks" is the sleep actogram drawn from walk windows, so the drawing moves into drawActogram(svgId, windows, barCls) and both timelines become one line each. Nothing about that picture was ever sleep-specific — it clips windows to day rows and shades them — so the third caller costs nothing. "Walk trend" is the sleep trend's shape with three deliberate differences. Today and yesterday are built from their own walk boundaries rather than hourly samples, so the steps stay square: a 30-minute walk is a step, not an hour-wide ramp. The average keeps hourly sampling, since a mean over many days is a smooth reference with no steps of its own to preserve. And there is no projected tail — sleep is a state the puppy drifts back into, so continuing today's line by what the average day adds is a fair guess, while walks are decisions, and projecting them would be predicting the handler, not the dog. The goal is a line, not a band: the five-minute rule gives one number to reach with no upper bound a band would imply. With no projection to carry it, the ✓ that marks being on track goes on today's own chip. The axis stays linear where the sleep trend stretches above 10h. That stretch exists because sleep's interesting hours crowd a 16h goal; a walk total reads the same at 10 minutes as at 60. Both panels sit with the other walk views rather than beside their sleep twins, following the grouping-by-subject the charts just moved to, and both hide themselves until a walk exists so they cost nothing to anyone not logging them. |
||
|
|
079eb41672 |
Apply the chart window to the timing panel
The 7d/14d/30d picker already drove every chart on the page, but the timing panel was not a chart and got missed: gapsBetween defaulted to a literal 7 and the heading said "(last 7 days)" as fixed text, so picking 30d widened the counts and heatmap while the pee, poo and meal gaps under them stayed on a week. Two panels describing the same events over different windows is the kind of disagreement nobody thinks to check for. The default is now chartDays(), read at call time so it tracks the picker rather than freezing at load, and the heading joins the other panels in deriving its "(last N days)" from the same place. The two sentences that quoted seven days — the empty-row fallback and the range tooltip — say the picked number, and the note about the faded stretch says "the window" rather than "the week", which is no longer always true. An explicit days argument still wins, so a future caller wanting a fixed window is not forced through the picker. |
||
|
|
e037ab2716 |
Group the charts by subject, not by window
"Last 7 days" collected four charts for no better reason than sharing an axis, which put sleep hours a full panel away from the two sleep patterns and the counts chart nowhere near the by-hour view of the same events. Each chart now sits with its subject. Sleep hours per day becomes its own Sleep panel directly above the sleep timeline, so the three sleep views read in sequence. Daily counts and Food join the heatmap in one "Pees, poos & meals" panel: how many a day, how much food went with them, and what hours they fall in are three views of one set of events and belong on one card. Minutes walked per day goes to the Walks panel, which is the same move applied to the chart the split didn't mention. The day-window picker stays a single control, in the Sleep panel, because it was never scoped to the panel holding it — it drives the training grid, both sleep patterns and the counts panel too. That is unchanged, and the panel it lives in occupies the slot the old one did, so it has not moved on screen. A comment says so, since a global control sitting inside one card does not announce itself. Duplicating it into each panel would work as-is (both the labels and the buttons are addressed by querySelectorAll) if reaching it ever becomes a scroll. Both new panels take new data-panel keys, so anyone who had the old panels folded gets the new ones open rather than inheriting a collapse they chose for something else. The dead #daily-charts-title lookup goes with the heading. |
||
|
|
7a1c0f3808 |
Shorten the weigh-in label to "Weight"
"Weigh-in" was the longest label in the app and, since it moved into a
third-width button, close enough to the edge to wrap on a mid-size phone.
"Weight" clears it with room to spare and loses nothing: the section, the
chart and the dialog field are all called Weight already.
EVENT_LABELS follows the button rather than only the button changing. Every
type's button label and its log label are the same string today, and weight
would have been the one exception — "⚖️ Weight" to log it, "Weigh-in" in the
history row and "Log Weigh-in" on the dialog. The prose that says "weigh-ins"
as a plural noun is left alone; it reads as English, not as a label.
The changelog entry for the rows is amended again rather than gaining a
neighbour, for the same reason as last time: it is one description of one
panel, and the rename is part of what that panel now looks like.
|
||
|
|
09b9b62d06 |
Move Ate down to the dialog row
Pee and Poo take a row of their own and Ate joins Weigh-in and Note across the bottom, which regroups the panel by what a tap does rather than by what the event means: the bottom row is now exactly the three buttons that open a dialog to type a value, and the row above it exactly the two that log on one tap. Ate was the odd one out under the old split — it sat with the pees and poos but behaved like a weigh-in. That hands the longest label in the app, "⚖️ Weigh-in", a third-width button. The narrow-phone padding trim already in place was written for "🍽️ Ate" in that slot and now serves Weigh-in instead; on a mid-size phone the label is close enough to the edge that it may wrap to two lines, which costs a little row height and nothing else. Shortening it to "Weight" is the fix if it does. The changelog entry describing the rows is corrected in place rather than answered with a second entry. The update banner diffs by entry text, so an edited entry is already surfaced as new to anyone coming from the previous build — and one accurate description of the layout beats two entries whose combined reading is only correct if you apply them in order. |
||
|
|
d551bb96eb |
Bracket sleep and walk pairs in the history log
A pair reads as two unrelated rows in the day's log: "Walk start 08:00" and "Walk end 08:30" sit apart from each other and, with a pee logged in between, apart from anything that happened while the puppy was out. A dotted rail down the margin ties the pair together, in that pair's colour. It runs the full span rather than only touching the two boundary rows, so whatever was logged in between falls inside the bracket — a pee taken on a walk now reads as having happened during it, which is the more useful claim and comes for free once the rail spans rows at all. Which rows a span covers comes from the same sleepWindowsForDay / walkWindowsForDay the Sleep and Walks panels draw, so a pair crossing midnight is treated identically in both places. Where a span's boundary isn't a row in this day — an ongoing walk, or one that ran over from yesterday — that end of the rail is left open and runs off the edge of the list, rather than anchoring on a row that didn't start or end anything. A boundary with no partner in the day draws nothing: a rail needs two rows to tie. The roles are named for the rendered list, which runs newest-first, so a span's last event is its top row and the rail is built downward from there. It costs no layout. The rail is drawn in the padding the section already has, left of the cards, so no row indents or moves; the anchored end stops level with the row's dot and the open ends overshoot the list gap so consecutive segments join into one line. |
||
|
|
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. |
||
|
|
ead575df2c |
Group the log buttons into rows
The buttons flowed into one auto-fit grid, so how they grouped was whatever the viewport's column count happened to produce. At two columns — every phone — seven buttons left the last one stranded alone on a fifth row, and between roughly 504px and 584px the grid goes to three columns and splits "Sleep start" from "Sleep end" across a wrap, which is the one pairing that has to stay together: the two halves are read as a unit, and one of them is always the disabled twin of the other. Each row is now stated outright instead. The timed pair takes a row of its own, the one-tap moments go three across, and the two that open a dialog to type a value take the last row. Nothing is orphaned at any width and the pairing cannot come apart. The rows group by what the buttons mean rather than by what tapping them does, which puts "Ate" with the pees and poos even though it opens the grams dialog like a weigh-in. Meaning is what you are scanning for at 3am; the dialog is a detail you meet after the tap. Three columns leave about 55px of text room at 320px wide, so the narrow breakpoint that already trims the day bar trims that row's side padding too, rather than letting a button wrap to two lines. |
||
|
|
17ce68da08 |
Show the longest gap in the timing charts
The bar gains a faded stretch from the typical gap out to the longest one of the window, behind the solid shortest-to-typical part. Where the solid ends is the median, so the boundary marks "typical" without another mark on the track. Extending the linear axis to reach it does not work. Gaps are heavily skewed — one long overnight gap a night against daytime gaps a tenth its length — and on a linear scale the everyday range collapsed into the first tenth of the track, to the point that four different "since" values rendered as the same picture. So the axis is stretched the way the sleep trend's is above 10h: the typical gap is pinned to the middle, shortest-to-typical takes the left half and typical-to-longest the right. Every row then reads the same way, left of centre sooner than usual and right of centre longer, however extreme that row's tail is. A note in the panel says as much, since a stretched axis is not something to leave to inference. The right edge now being the longest gap retires the old cap at twice the typical: the marker's chevron means "past the longest gap yet" rather than past an arbitrary cutoff. |
||
|
|
8d0ebdd4bc |
Fix the heatmap caption needing several taps
A tap fires the emulated mouseenter and then the click on the same block, and the click handler was a toggle: the first of the pair selected the block, the second cleared it, so one tap left the caption exactly where it started. It only appeared on a later tap that arrived without a fresh mouseenter, which is what made it look like taps were being missed. The click now selects rather than toggles, so running both handlers for one tap is a no-op — the same idempotent shape the weight chart's hit targets already use. Tap-again-to-clear goes with it: telling a hover-set selection apart from a click-set one is more machinery than the affordance is worth, and the caption keeping its last block matches the weight chart's caption. The changelog entry that promised clearing is corrected in place, since the behaviour it describes never worked. |
||
|
|
b42ea2e309 |
Draw each timing row as a range chart
A pair of numbers per type says how long the gaps are but not where the puppy currently sits between them, which is the thing you actually want when deciding whether to go out now. Each row becomes a small chart instead: a band from the shortest to the typical gap, a marker for time since the last one, and a scale of [min(shortest, since), max(typical, since)] so the marker is free to land inside the band, off its left end (just went) or off its right end (due). Three cases the layout has to survive. A gap in tracking can leave "since" at days against a typical gap of hours, so past twice the typical the marker parks at the right edge behind a chevron and its label keeps the real number — squashing the band to a sliver would be worse, and clamping silently would read as being in range. A band too narrow to hang a label off each end carries both values centred over its middle rather than pinned to the track ends, where they would imply a spread the band does not have. Under two events there is no gap at all, so the row falls back to a sentence instead of an axis with nothing on it. The marker is ink over a surface-coloured ring so it stays legible on any band colour in either theme, and the gap between a label's word and its value is an explicit dx: a trailing space inside a tspan does not survive XML whitespace normalisation. |
||
|
|
29b961c1b0 |
Add meal gaps to the timing panel
Meals have the same shape as the pee and poo intervals already shown here — gapsBetween() is type-agnostic, so it is two more rows and one more call. The window, the median-based "typical" and the em-dash empty state all come along unchanged. The heading drops "Bathroom", which stops being accurate once feeding is in the panel. data-panel stays "timing", so a collapsed panel stays collapsed across the rename. The hint keeps keying off pees: it is advice about when to take the puppy out, which meal spacing has nothing to say about. Only its empty-state text widens. |
||
|
|
2a14c34010 |
Make the by-hour heatmap blocks tappable
The count behind a block was only ever reachable through its <title> tooltip,
which needs a pointer — on a phone there was no way to find out whether a dark
block meant two meals or five. Each cell now carries a transparent hit rect
that names it in a caption under the chart ("3 meals between 07:00 and
08:00"), the same string the tooltip shows, so hover and tap agree. The
focused cell takes an accent ring and tapping it again clears it.
The hit rect claims the 1px spacing between cells and half the gap to the
neighbouring rows, which takes the target from ~13x31 to ~13x38 CSS px on a
360px-wide screen; the rows still tile without overlapping and stay clear of
the hour labels. Width is capped by fitting 24 hours across the chart, so a
mis-tap lands on a neighbouring hour — the caption names the range it hit,
which makes that self-correcting.
The focused cell is held outside the render so a background sync can't wipe
what is being read; its count is recomputed each pass, so the caption stays
current. The caption is aria-live, which also gives screen readers a route to
the numbers that role="img" on the svg otherwise closes off.
|