Compare commits
22
Commits
da68b733e4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9ea45410 | ||
|
|
3584758b86 | ||
|
|
18d3778241 | ||
|
|
5a08fb4510 | ||
|
|
9e47aa53ff | ||
|
|
556e4d75a8 | ||
|
|
e4a5c3fe29 | ||
|
|
7451650b6f | ||
|
|
668f1f039e | ||
|
|
14cad44d9b | ||
|
|
59cf567946 | ||
|
|
8d7139b031 | ||
|
|
e4b056b5b9 | ||
|
|
fbacd97da7 | ||
|
|
fba0f73717 | ||
|
|
7c0ccca1b2 | ||
|
|
39343ff2a4 | ||
|
|
de1e18e394 | ||
|
|
e7b54b82fd | ||
|
|
0dfbab82cf | ||
|
|
66f89b35a9 | ||
|
|
babed44c25 |
@@ -26,6 +26,16 @@ source-of-truth and sync between devices.
|
||||
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
|
||||
events (`type: "training"`) referencing an exercise by id, so they ride the
|
||||
event sync unchanged.
|
||||
- Food kinds (`Dry`, `Fresh`) are a third synced collection with the same
|
||||
contract, via `POST /api/foodkinds/sync`; a meal references one by
|
||||
`foodKindId`. Empty means **no kind**, which is what every meal logged before
|
||||
kinds existed carries — so nothing needed migrating and nobody is made to
|
||||
classify their food. Which kind a new meal starts on is a flag on the kind
|
||||
itself rather than a profile field: the profile is last-write-wins across the
|
||||
whole row (see the `pedigree_id` special case below), and per-item LWW lets
|
||||
two devices that each chose a default resolve to the newer instead of
|
||||
fighting. Each kind also keeps a fixed `colorIndex`, so deleting one never
|
||||
repaints the charts of the ones around it.
|
||||
- The puppy's name and birthday are a per-account profile stored on the host
|
||||
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
|
||||
of being configured per-client. The client caches the last-seen values in
|
||||
@@ -43,6 +53,64 @@ source-of-truth and sync between devices.
|
||||
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
|
||||
`sync error` / `offline`. Tap it to force-sync.
|
||||
|
||||
## On screen
|
||||
|
||||
The panels are grouped into five tabs — **Today**, **Sleep**, **Walks**,
|
||||
**Habits** (pee/poo/meal timing and counts) and **Growth** (weight, training,
|
||||
notes) — so each screen holds one subject instead of all fourteen panels in one
|
||||
column. The day bar and the log buttons sit above the tabs and stay put on all
|
||||
of them, because logging has to be one tap from wherever you are.
|
||||
|
||||
- The day bar carries up to two timers, each of which logs the boundary that
|
||||
ends what it is counting when tapped: the asleep/awake one, and — only while
|
||||
a walk is running — the walk. They are frozen at the top on every tab, and
|
||||
they are the only place a timer appears. There used to be a big card at the
|
||||
top of Today as well, with the pills standing by until it scrolled out of
|
||||
sight; a timer you have to scroll to, on one tab in five, is not doing the
|
||||
job a timer is for. Having one place to render them is also what lets them
|
||||
carry seconds — they are the display now, not a summary of one.
|
||||
- **The day picker is a month grid of the app's own**, not the browser's. The
|
||||
native one is a sheet covering the screen, and the reason to change day is to
|
||||
see what the figures did on it — so this is a small panel under the bar, with
|
||||
the overview still visible and updating as you move. `←` and `→` stay in the
|
||||
bar for the common ±1 day; the grid handles jumps and carries *Today*, which
|
||||
is what freed the width to fit two timers on one row. The hidden
|
||||
`<input type="date">` remains the value everything reads; only its own picker
|
||||
is no longer opened.
|
||||
- The bar can still wrap, and does below about 300px. Its height changes when
|
||||
it does and the tab bar sticks to that height, which is why `--day-bar-h` is
|
||||
kept current by a `ResizeObserver` rather than measured once.
|
||||
- **Long-press two event rows to measure between them.** "How long after eating
|
||||
did he poo?" is answerable from the log, but only by reading two times off the
|
||||
screen and subtracting — and the pair is often on different days, so it is
|
||||
rarely on screen together. A bar along the bottom holds the gap until you
|
||||
clear it, so changing day mid-measurement is fine. Any row that is one event
|
||||
at one moment can be picked: history, notes, weigh-ins. Sleep and walk rows
|
||||
cannot, being spans rather than moments. A third pick is refused while two are
|
||||
held; pressing a picked row unpicks it. The picks live in a variable rather
|
||||
than `localStorage` — a measurement is a question you are asking now, not a
|
||||
setting — but being module-level is what carries them through the re-render a
|
||||
background sync causes every minute. Long-press has no keyboard equivalent, so
|
||||
this is touch and mouse only.
|
||||
- Each tab is a `.tab-panel` wrapper around the existing sections. The
|
||||
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
||||
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
||||
exists, and hiding them directly would clobber it.
|
||||
- `render()` still draws every panel on every pass, including the tabs you
|
||||
can't see. Nothing measures layout — the charts scale through their `viewBox`
|
||||
— so drawing into a hidden wrapper is safe, and it means a tab is never
|
||||
briefly stale when you arrive on it.
|
||||
- Tapping a panel's heading still folds it away, remembered across reloads, and
|
||||
composes with tabs: tabs group, folding tunes what shows within a group. The
|
||||
chosen tab is remembered the same way (device-global, like the theme).
|
||||
- **Back returns to Today**, from any tab, in one press; a second press leaves
|
||||
the app. Exactly one history entry is ever live — armed on leaving Today and
|
||||
spent on returning, whether that return came from the back button or from
|
||||
tapping the tab. An entry per switch is what a browser does unaided, and is
|
||||
why tabbed apps get a reputation for trapping you: flick between tabs fifteen
|
||||
times and it takes fifteen presses to escape. Two presses, always, from
|
||||
anywhere.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -58,6 +126,13 @@ puppy-tracker/
|
||||
│ ├── webpush.go # VAPID + RFC 8291/8188 message encryption
|
||||
│ ├── pedigree.go # SKK lookup, background crawl, per-dog cache
|
||||
│ └── htmlutil.go # scraping helpers for the pedigree crawl
|
||||
├── checks/ # frontend checks (see Checks); `node checks/run.mjs`
|
||||
│ ├── run.mjs # runs every suite, exits non-zero on a failure
|
||||
│ ├── extract.mjs # pulls declarations out of app.js so checks run real code
|
||||
│ ├── assert.mjs
|
||||
│ ├── excluded-days.mjs
|
||||
│ ├── calendar.mjs
|
||||
│ └── layout.mjs
|
||||
└── src/ # the web app
|
||||
├── index.html
|
||||
├── app.js
|
||||
@@ -69,6 +144,37 @@ puppy-tracker/
|
||||
└── icon-180.png, icon-192.png, icon-512.png
|
||||
```
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
nix develop -c sh -c 'cd server && go test ./...' # the server
|
||||
nix develop -c node checks/run.mjs # the frontend
|
||||
```
|
||||
|
||||
The server has `go test`; `checks/` is the other half. The frontend has no
|
||||
build step and no test framework, so these are plain scripts with no
|
||||
dependencies beyond the `nodejs` already in the devShell. They cover the things
|
||||
that are invisible until they bite:
|
||||
|
||||
- **Arithmetic behind the charts** — what a day marked *not counted* does and
|
||||
does not take out of the numbers, and the month grid's week starts, leap
|
||||
years and month boundaries.
|
||||
- **Structure** — that every panel still sits in exactly one tab (losing one
|
||||
while shuffling tabs is silent), that the stylesheet's braces and comments
|
||||
balance, that the global `[hidden]` rule is still there.
|
||||
- **Width budgets** — whether the tab labels and the two day-bar timers still
|
||||
fit the phones people use.
|
||||
|
||||
Two things worth knowing about them. They **extract the real functions out of
|
||||
`src/app.js`** rather than copying them, so a check cannot quietly go on
|
||||
testing a stale copy — rename a function and `checks/extract.mjs` throws by
|
||||
name. And the width figures are **estimates, not measurements**: there is no
|
||||
browser in the loop, so the layout numbers are read out of `style.css` and the
|
||||
text is sized from per-character advances, with a couple of pixels of headroom
|
||||
demanded because the estimate is only good to a few percent. They will catch a
|
||||
sixth tab or a longer label; they will not settle a two-pixel question. A real
|
||||
phone is still the arbiter of anything visual.
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
@@ -118,6 +224,15 @@ heading, takes the day you're looking at out of the aggregates.
|
||||
- **What stops counting** is the behaviour: sleep hours, timeline and trend,
|
||||
walk minutes and patterns, pee/poo/meal counts, food, by-hour, the training
|
||||
grid, and the Timing panel's typical gaps.
|
||||
- **Marking a day is not the same as deleting its events**, and only two panels
|
||||
are handed a filtered list (by-hour and training, which bucket individual
|
||||
events and care about neither day boundaries nor spans). The rest take the
|
||||
whole log and exclude days themselves, because three things break if the
|
||||
events simply go: "how long since the last pee" is a question about *now* and
|
||||
answered from the wrong event; the trend curve for the day you are *looking
|
||||
at* collapses to zero; and a nap from 23:00 on a marked day to 07:00 on the
|
||||
next loses its `sleep-start`, leaving a dangling `sleep-end` and costing the
|
||||
next day — which isn't marked — seven hours it really slept.
|
||||
- **What keeps counting** is weight and notes. A weigh-in and a vet note are
|
||||
records of fact, not behaviour a sparse logger distorts, so they stay on the
|
||||
weight curve and in the Notes log.
|
||||
@@ -169,10 +284,14 @@ Guest access** mints a link that does exactly the first thing.
|
||||
and every request re-checks that the link is still live — so revoking kicks
|
||||
whoever is already using it out on their very next request, not whenever their
|
||||
session happens to lapse. Revoking also deletes those session rows outright.
|
||||
- **The URL is shown once.** Only a hash of the token is stored, exactly as with
|
||||
session tokens, so a leaked database yields no working links — and the app
|
||||
cannot show you the URL again later. Settings lists each live link by label,
|
||||
expiry and when it was last used.
|
||||
- **The URL stays available.** Settings lists each live link by label, expiry
|
||||
and when it was last used, with the URL and a *Copy* button, so a link can be
|
||||
re-sent without minting a new one and stranding whoever holds the old. That
|
||||
means the token is stored, not just its hash — a deliberate trade, and not the
|
||||
one you would make for a password or a session token: a guest link grants a
|
||||
subset of what the same database already holds in plaintext, so whoever can
|
||||
read `puppy.db` gains little from it, and it expires and can be revoked
|
||||
besides. The lookup column stays a hash; the secret sits beside it.
|
||||
- **Events say who logged them.** An event created through a link carries that
|
||||
link's label (badged in the History log) and its id (which is what authorises
|
||||
changes). The server stamps both from the session on insert and never reads
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// The smallest thing that will do. Checks print a line per assertion so a
|
||||
// failure says what was expected of the code, not just which line threw.
|
||||
let fails = 0;
|
||||
let current = "";
|
||||
|
||||
export function suite(name) {
|
||||
current = name;
|
||||
console.log(`\n== ${name} ==`);
|
||||
}
|
||||
|
||||
export function eq(got, want, what) {
|
||||
const g = JSON.stringify(got), w = JSON.stringify(want);
|
||||
if (g === w) { console.log(` ok ${what}`); return; }
|
||||
console.log(` FAIL ${what}\n got ${g}\n want ${w}`);
|
||||
fails++;
|
||||
}
|
||||
|
||||
export function ok(cond, what) {
|
||||
eq(Boolean(cond), true, what);
|
||||
}
|
||||
|
||||
export function failed() { return fails; }
|
||||
|
||||
export function report(file) {
|
||||
if (fails === 0) console.log(`\n${file}: all passed`);
|
||||
else console.log(`\n${file}: ${fails} FAILED`);
|
||||
return fails;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// The month grid behind the date button. Off-by-one week starts and month
|
||||
// boundaries are how a hand-rolled calendar goes wrong, and none of it shows
|
||||
// until somebody opens the month that breaks.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, report } from "./assert.mjs";
|
||||
|
||||
const app = load({ names: ["ymd", "calendarGridStart"] });
|
||||
|
||||
const MONDAY = 1, SUNDAY = 0;
|
||||
const cells = (year, monthIdx, start) => {
|
||||
const gs = app.calendarGridStart(new Date(year, monthIdx, 1), start);
|
||||
return Array.from({ length: 42 }, (_, i) => {
|
||||
const d = new Date(gs);
|
||||
d.setDate(gs.getDate() + i);
|
||||
return d;
|
||||
});
|
||||
};
|
||||
|
||||
suite("the grid starts on the locale's first weekday");
|
||||
for (const [name, start] of [["Monday", MONDAY], ["Sunday", SUNDAY]]) {
|
||||
const everyMonth = Array.from({ length: 12 }, (_, m) => cells(2026, m, start)[0].getDay());
|
||||
eq(everyMonth.every(day => day === start), true,
|
||||
`${name} start: all twelve months of 2026 open on a ${name}`);
|
||||
}
|
||||
|
||||
suite("six rows, covering the month exactly once");
|
||||
{
|
||||
let alwaysSix = true, coversAll = true;
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const cs = cells(2026, m, MONDAY);
|
||||
if (cs.length !== 42) alwaysSix = false;
|
||||
const inMonth = cs.filter(d => d.getMonth() === m).length;
|
||||
if (inMonth !== new Date(2026, m + 1, 0).getDate()) coversAll = false;
|
||||
}
|
||||
eq(alwaysSix, true, "42 cells every month, so the panel never changes height");
|
||||
eq(coversAll, true, "every day of every month appears exactly once");
|
||||
}
|
||||
|
||||
suite("the months that catch people out");
|
||||
{
|
||||
// 1 Feb 2026 is a Sunday: under Monday weeks that needs six leading days from
|
||||
// January, which is precisely what a naive `1 - getDay()` gets wrong.
|
||||
const feb = cells(2026, 1, MONDAY);
|
||||
eq(app.ymd(feb[0]), "2026-01-26", "Feb 2026 starts Sunday: the grid opens on 26 Jan");
|
||||
eq(app.ymd(feb[6]), "2026-02-01", "…putting the 1st in the last column of row one");
|
||||
eq(app.ymd(cells(2026, 1, SUNDAY)[0]), "2026-02-01",
|
||||
"the same month under Sunday weeks needs no leading days at all");
|
||||
eq(cells(2026, 7, MONDAY).filter(d => d.getMonth() === 7).length, 31,
|
||||
"a 31-day month starting late in the week still fits");
|
||||
eq(cells(2024, 1, MONDAY).filter(d => d.getMonth() === 1).length, 29, "Feb 2024 shows 29 days");
|
||||
eq(cells(2026, 1, MONDAY).filter(d => d.getMonth() === 1).length, 28, "Feb 2026 shows 28");
|
||||
}
|
||||
|
||||
suite("year boundaries");
|
||||
{
|
||||
eq(app.ymd(cells(2026, 0, MONDAY)[0]).startsWith("2025"), true,
|
||||
"January's leading cells come from the previous December");
|
||||
eq(app.ymd(cells(2026, 11, MONDAY)[41]).startsWith("2027"), true,
|
||||
"December's trailing cells run into January");
|
||||
}
|
||||
|
||||
export default report("calendar");
|
||||
@@ -0,0 +1,106 @@
|
||||
// A day marked "not counted" leaves the averages but stays on the record. The
|
||||
// subtlety is that marking a day is not the same as deleting its events, and
|
||||
// three panels break if you treat it that way — see the notes in render().
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, report } from "./assert.mjs";
|
||||
|
||||
const app = load({
|
||||
names: [
|
||||
"ymd", "startOfDay", "EXCLUDED_TYPE", "ALWAYS_COUNTS", "excludedSet",
|
||||
"excludedDays", "isExcluded", "countedEvents", "spansExcluded",
|
||||
"pairWindows", "sleepWindows", "sleepMsInRange", "lastEventOfType",
|
||||
"gapsBetween",
|
||||
],
|
||||
lets: ["excludedSet"],
|
||||
// gapsBetween defaults its window to the chart picker; the checks pass it in.
|
||||
stubs: { chartDays: () => 7 },
|
||||
});
|
||||
|
||||
const H = 3600_000;
|
||||
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
|
||||
const NOW = at(20, 12);
|
||||
const mark = (day) => ({ id: `x${day}`, type: app.EXCLUDED_TYPE, at: at(day, 12) });
|
||||
const marking = (events) => { app.set.excludedSet(app.excludedDays(events)); return events; };
|
||||
|
||||
suite("what a marked day takes out of the numbers");
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "a", type: "pee", at: at(18, 9) },
|
||||
{ id: "b", type: "pee", at: at(19, 9) },
|
||||
{ id: "c", type: "pee", at: at(20, 9) },
|
||||
mark(19),
|
||||
]);
|
||||
eq(app.gapsBetween(evs, "pee", 14), [],
|
||||
"a gap reaching across a marked day is discarded rather than measured");
|
||||
}
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "d", type: "pee", at: at(18, 8) },
|
||||
{ id: "e", type: "pee", at: at(18, 12) },
|
||||
{ id: "f", type: "pee", at: at(20, 8) },
|
||||
{ id: "g", type: "pee", at: at(20, 12) },
|
||||
mark(19),
|
||||
]);
|
||||
eq(app.gapsBetween(evs, "pee", 14), [4 * H, 4 * H],
|
||||
"…while the gaps either side of it survive");
|
||||
}
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "a", type: "pee", at: at(19, 9) },
|
||||
{ id: "b", type: "pee", at: at(20, 9) },
|
||||
{ id: "w", type: "weight", at: at(19, 10) },
|
||||
{ id: "n", type: "note", at: at(19, 11) },
|
||||
mark(19),
|
||||
]);
|
||||
// The marker sits on the day it marks, so it is filtered out with the rest;
|
||||
// harmless, since excludedSet was read off the full list beforehand.
|
||||
eq(app.countedEvents(evs).map(e => e.id).sort(), ["b", "n", "w"],
|
||||
"behaviour on a marked day is dropped; a weigh-in and a note are not");
|
||||
app.set.excludedSet(new Set());
|
||||
eq(app.countedEvents(evs).length, evs.length, "nothing marked means nothing filtered");
|
||||
}
|
||||
|
||||
suite("spansExcluded");
|
||||
{
|
||||
marking([mark(19)]);
|
||||
eq(app.spansExcluded(at(19, 1), at(19, 5)), true, "wholly inside a marked day");
|
||||
eq(app.spansExcluded(at(18, 23), at(20, 1)), true, "straddling one");
|
||||
eq(app.spansExcluded(at(20, 1), at(20, 9)), false, "clear of one");
|
||||
app.set.excludedSet(new Set());
|
||||
eq(app.spansExcluded(at(18, 1), at(24, 1)), false, "nothing marked, so nothing spans");
|
||||
}
|
||||
|
||||
// These three were live bugs: the panels were handed a list with the marked
|
||||
// day's events removed, which answers a different question from the one each
|
||||
// of them is asking.
|
||||
suite("what a marked day must NOT take out");
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "p1", type: "pee", at: at(19, 9) },
|
||||
{ id: "p2", type: "pee", at: at(20, 10) }, // on the marked day
|
||||
mark(20),
|
||||
]);
|
||||
const last = app.lastEventOfType(evs, "pee");
|
||||
eq((NOW - last.at) / H, 2,
|
||||
"Timing: 'since the last pee' is about now, so it finds the one on the marked day");
|
||||
}
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "s1", type: "sleep-start", at: at(20, 1) },
|
||||
{ id: "s2", type: "sleep-end", at: at(20, 3) },
|
||||
mark(20),
|
||||
]);
|
||||
eq(app.sleepMsInRange(evs, at(20, 0), NOW) / H, 2,
|
||||
"Sleep trend: the curve for the day you are looking at shows its real hours");
|
||||
}
|
||||
{
|
||||
const evs = marking([
|
||||
{ id: "s1", type: "sleep-start", at: at(19, 23) }, // marked day
|
||||
{ id: "s2", type: "sleep-end", at: at(20, 7) }, // the next, unmarked
|
||||
mark(19),
|
||||
]);
|
||||
eq(app.sleepMsInRange(evs, at(20, 0), at(20, 24)) / H, 7,
|
||||
"a nap crossing midnight still counts for the unmarked day it ends on");
|
||||
}
|
||||
|
||||
export default report("excluded-days");
|
||||
@@ -0,0 +1,103 @@
|
||||
// Pulls named declarations out of src/app.js and evaluates them, so a check
|
||||
// exercises the code that ships rather than a copy of it.
|
||||
//
|
||||
// The app is one long IIFE with nothing exported — it has no build step and no
|
||||
// module system, and adding either to make it testable would be a large change
|
||||
// in service of a small one. Reading the source back is the cheaper trade: the
|
||||
// checks stay honest, and the app stays a file you can open in a browser.
|
||||
//
|
||||
// If a declaration is renamed or removed, load() throws by name. That is the
|
||||
// point: a check that quietly tested a stale copy would be worse than no check.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "app.js");
|
||||
|
||||
// Blanks out comments and string bodies so brace counting can't be fooled by a
|
||||
// `}` inside one. Positions are preserved, so offsets into the result are valid
|
||||
// offsets into the original.
|
||||
function mask(src) {
|
||||
const out = src.split("");
|
||||
let i = 0;
|
||||
const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; };
|
||||
while (i < src.length) {
|
||||
const c = src[i], next = src[i + 1];
|
||||
if (c === "/" && next === "/") {
|
||||
const end = src.indexOf("\n", i); const stop = end === -1 ? src.length : end;
|
||||
blank(i, stop); i = stop; continue;
|
||||
}
|
||||
if (c === "/" && next === "*") {
|
||||
const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2;
|
||||
blank(i, stop); i = stop; continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
let k = i + 1;
|
||||
while (k < src.length) {
|
||||
if (src[k] === "\\") { k += 2; continue; }
|
||||
if (src[k] === c) break;
|
||||
k++;
|
||||
}
|
||||
blank(i + 1, Math.min(k, src.length)); i = Math.min(k + 1, src.length); continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
// The source of one top-level declaration, brace-matched from its opening line.
|
||||
function declaration(src, masked, name) {
|
||||
const patterns = [
|
||||
new RegExp(`^ {2}(?:async )?function ${name}\\b`, "m"),
|
||||
new RegExp(`^ {2}(?:const|let) ${name}\\b`, "m"),
|
||||
];
|
||||
for (const re of patterns) {
|
||||
const m = re.exec(masked);
|
||||
if (!m) continue;
|
||||
const start = m.index;
|
||||
// A function runs to its matching close brace; a const/let to the newline
|
||||
// after the statement that balances its own brackets.
|
||||
let depth = 0, seen = false, i = start;
|
||||
for (; i < masked.length; i++) {
|
||||
const ch = masked[i];
|
||||
if (ch === "{" || ch === "(" || ch === "[") { depth++; seen = true; }
|
||||
else if (ch === "}" || ch === ")" || ch === "]") {
|
||||
depth--;
|
||||
if (depth === 0 && seen && ch === "}" && /function/.test(m[0])) return src.slice(start, i + 1);
|
||||
} else if (ch === ";" && depth === 0 && !/function/.test(m[0])) {
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`checks/extract: could not find "${name}" in src/app.js.\n` +
|
||||
`It was probably renamed or removed — update the check that asks for it.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* load({ names, lets, stubs }) → { ...declarations, set: { <let>: fn } }
|
||||
*
|
||||
* names declarations to pull across, in dependency order
|
||||
* lets of those, the mutable ones a check needs to reach. Each gets a setter
|
||||
* and a getter: the plain export is the value at load time, so a binding
|
||||
* the code reassigns (rather than mutates) would go stale and a check
|
||||
* would quietly assert against a snapshot.
|
||||
* stubs names the extracted code calls but which are not worth extracting —
|
||||
* DOM lookups, chartDays(), and so on
|
||||
*/
|
||||
export function load({ names, lets = [], stubs = {} }) {
|
||||
const src = readFileSync(SRC, "utf8");
|
||||
const masked = mask(src);
|
||||
const body = names.map(n => declaration(src, masked, n)).join("\n\n");
|
||||
const stubNames = Object.keys(stubs);
|
||||
const exported = names.map(n => n.replace(/^.*\s/, ""));
|
||||
const setters = lets.map(n => `${n}: (v) => { ${n} = v; }`).join(", ");
|
||||
const getters = lets.map(n => `${n}: () => ${n}`).join(", ");
|
||||
const factory = new Function(...stubNames, `
|
||||
${body}
|
||||
return { ${exported.join(", ")}, set: { ${setters} }, get: { ${getters} } };
|
||||
`);
|
||||
return factory(...stubNames.map(n => stubs[n]));
|
||||
}
|
||||
|
||||
export const appSource = () => readFileSync(SRC, "utf8");
|
||||
@@ -0,0 +1,167 @@
|
||||
// Kinds of food: a library of user-named labels a meal can carry. The rules
|
||||
// worth holding still are the ones that decide what happens to people who
|
||||
// never use the feature, and what happens to history when a kind is deleted.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
let store = [];
|
||||
let synced = 0, rendered = 0;
|
||||
let nextId = 0;
|
||||
|
||||
const app = load({
|
||||
names: [
|
||||
"NO_KIND", "FOOD_COLORS",
|
||||
"loadFoodKinds", "saveFoodKinds", "liveFoodKinds",
|
||||
"addFoodKind", "updateFoodKind", "deleteFoodKind",
|
||||
"setDefaultFoodKind", "defaultFoodKindId", "foodKindNames",
|
||||
],
|
||||
stubs: {
|
||||
foodKindsKey: () => "k",
|
||||
localStorage: {
|
||||
getItem: () => JSON.stringify(store),
|
||||
setItem: (_, v) => { store = JSON.parse(v); },
|
||||
},
|
||||
uuid: () => `id${++nextId}`,
|
||||
scheduleSync: () => { synced++; },
|
||||
render: () => { rendered++; },
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => { store = []; nextId = 0; };
|
||||
const names = () => app.liveFoodKinds().map(k => k.name);
|
||||
|
||||
suite("an account with no kinds behaves as it always did");
|
||||
{
|
||||
reset();
|
||||
eq(app.liveFoodKinds(), [], "no kinds to begin with");
|
||||
eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind");
|
||||
eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry");
|
||||
}
|
||||
|
||||
suite("creating kinds");
|
||||
{
|
||||
reset();
|
||||
const dry = app.addFoodKind("Dry");
|
||||
const fresh = app.addFoodKind("Fresh");
|
||||
eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical");
|
||||
eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot");
|
||||
ok(synced > 0, "a new kind is queued for sync");
|
||||
}
|
||||
|
||||
suite("the default");
|
||||
{
|
||||
reset();
|
||||
const dry = app.addFoodKind("Dry");
|
||||
const fresh = app.addFoodKind("Fresh");
|
||||
eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so");
|
||||
|
||||
app.setDefaultFoodKind(dry.id);
|
||||
eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default");
|
||||
|
||||
app.setDefaultFoodKind(fresh.id);
|
||||
eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it");
|
||||
eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1,
|
||||
"…and unflags the old one, so there is never more than one");
|
||||
|
||||
app.setDefaultFoodKind(app.NO_KIND);
|
||||
eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind");
|
||||
}
|
||||
|
||||
suite("two devices that each set a default");
|
||||
{
|
||||
// What a sync race leaves behind: both rows flagged, different timestamps.
|
||||
// Resolving to the newer beats showing two defaults or picking at random.
|
||||
reset();
|
||||
store = [
|
||||
{ id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 },
|
||||
{ id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 },
|
||||
];
|
||||
eq(app.defaultFoodKindId(), "b", "the more recent flag wins");
|
||||
}
|
||||
|
||||
suite("renaming and deleting keep history readable");
|
||||
{
|
||||
reset();
|
||||
const dry = app.addFoodKind("Dry");
|
||||
app.updateFoodKind(dry.id, { name: "Dry kibble" });
|
||||
eq(names(), ["Dry kibble"], "renaming changes the name in place");
|
||||
eq(app.foodKindNames().get(dry.id), "Dry kibble",
|
||||
"…and meals pointing at the id follow it, since they resolve by id");
|
||||
|
||||
app.deleteFoodKind(dry.id);
|
||||
eq(names(), [], "a deleted kind leaves the picker");
|
||||
eq(app.foodKindNames().get(dry.id), "Dry kibble",
|
||||
"…but its name still resolves, so meals logged as it stay readable");
|
||||
}
|
||||
|
||||
suite("colours survive a deletion");
|
||||
{
|
||||
// Deriving colour from position in the live list would repaint every past
|
||||
// chart the moment a kind was removed. The index is fixed at creation.
|
||||
reset();
|
||||
app.addFoodKind("Dry");
|
||||
const fresh = app.addFoodKind("Fresh");
|
||||
const raw = app.addFoodKind("Raw");
|
||||
eq(raw.colorIndex, 2, "the third kind takes the third slot");
|
||||
|
||||
app.deleteFoodKind(fresh.id);
|
||||
const live = app.liveFoodKinds();
|
||||
eq(live.map(k => k.colorIndex), [0, 2],
|
||||
"deleting the middle kind leaves the others' colours alone");
|
||||
eq(app.addFoodKind("Treats").colorIndex, 3,
|
||||
"and the next kind does not reuse the freed slot");
|
||||
}
|
||||
|
||||
suite("the palette wraps rather than running out");
|
||||
{
|
||||
reset();
|
||||
for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`);
|
||||
const live = app.liveFoodKinds();
|
||||
eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours");
|
||||
eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0,
|
||||
"…and the palette wraps, so two share rather than one having none");
|
||||
}
|
||||
|
||||
// ---------------------------------------------- the day's split in the overview
|
||||
// The Meals tile keeps the day's total; this is the breakdown under it. The
|
||||
// case that matters is the one where it must not appear at all.
|
||||
{
|
||||
let kinds = [];
|
||||
let el = { hidden: false, textContent: "" };
|
||||
const view = load({
|
||||
names: ["NO_KIND", "renderDayFoodKinds"],
|
||||
stubs: {
|
||||
document: { getElementById: () => el },
|
||||
liveFoodKinds: () => kinds,
|
||||
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
||||
},
|
||||
});
|
||||
const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId });
|
||||
const show = (evs) => { el = { hidden: false, textContent: "" }; view.renderDayFoodKinds(evs); return el; };
|
||||
|
||||
suite("the day's food split");
|
||||
{
|
||||
kinds = [];
|
||||
eq(show([meal(180), meal(120)]).hidden, true,
|
||||
"meals with no kind show no breakdown — the tile's total already says it");
|
||||
eq(show([]).hidden, true, "a day with no meals shows nothing");
|
||||
|
||||
kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }];
|
||||
const both = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]);
|
||||
eq(both.hidden, false, "once a meal carries a kind, the breakdown appears");
|
||||
eq(both.textContent, "Dry 260 g · Fresh 100 g", "…summed per kind, in the chart's order");
|
||||
|
||||
eq(show([meal(200, "d"), meal(50)]).textContent, "Dry 200 g · No kind 50 g",
|
||||
"unlabelled food on a day that has kinds is named, not dropped");
|
||||
|
||||
kinds = [];
|
||||
eq(show([meal(90, "gone")]).textContent, "Deleted kind 90 g",
|
||||
"a kind deleted since still labels its food rather than vanishing");
|
||||
|
||||
kinds = [{ id: "d", name: "Dry" }];
|
||||
eq(show([meal(0, "d"), meal(120, "d")]).textContent, "Dry 120 g",
|
||||
"a meal logged without an amount adds nothing to the split");
|
||||
}
|
||||
}
|
||||
|
||||
export default report("food-kinds");
|
||||
@@ -0,0 +1,335 @@
|
||||
// The fit through the Food (grams) bars. The arithmetic is easy to get subtly
|
||||
// wrong and the result is a sentence stating a fact about the puppy, so the
|
||||
// cases that matter are the ones where it should decline to say anything.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const app = load({ names: ["foodTrend"] });
|
||||
const words = load({ names: ["foodTrendSentence"] });
|
||||
|
||||
// The 7/14/30 picker reaches the trend by deciding how many days weeklyData
|
||||
// builds — there is no second mechanism, so this is the thing to hold still.
|
||||
let windowDays = 7;
|
||||
const weekly = load({
|
||||
names: ["startOfDay", "endOfDay", "ymd", "weeklyData"],
|
||||
stubs: {
|
||||
chartDays: () => windowDays,
|
||||
isExcluded: () => false,
|
||||
eventsForDay: () => [],
|
||||
sleepMsInRange: () => 0,
|
||||
walkMsInRange: () => 0,
|
||||
},
|
||||
});
|
||||
|
||||
suite("the day picker is what sets the trend's window");
|
||||
for (const n of [7, 14, 30]) {
|
||||
windowDays = n;
|
||||
eq(weekly.weeklyData([]).length, n, `picking ${n}d gives the charts ${n} days to fit over`);
|
||||
}
|
||||
windowDays = 7;
|
||||
|
||||
// weeklyData's shape, as far as foodTrend reads it. Today is last, as there.
|
||||
const days = (grams, { excluded = [] } = {}) =>
|
||||
grams.map((g, i) => ({ grams: g, excluded: excluded.includes(i) }));
|
||||
|
||||
suite("it declines to fit when there is nothing to fit");
|
||||
{
|
||||
eq(app.foodTrend(days([300, 320, 310])), null,
|
||||
"three days is too few — today is dropped, leaving two, and two always fit perfectly");
|
||||
eq(app.foodTrend(days([300, 320, 310, 330, 340], { excluded: [0, 1] })), null,
|
||||
"marked days don't count toward the four either");
|
||||
eq(app.foodTrend(days([])), null, "an empty window fits nothing");
|
||||
}
|
||||
|
||||
suite("today is left out, being half-eaten");
|
||||
{
|
||||
// Four steady days then a partial today. Including today would tip the line
|
||||
// down; the fit should not see it at all.
|
||||
const t = app.foodTrend(days([400, 400, 400, 400, 50]));
|
||||
ok(t, "four complete days are enough");
|
||||
eq(Math.round(t.change), 0, "a flat run stays flat despite today being low");
|
||||
eq(t.last, 3, "the line stops at the last complete day, not at today");
|
||||
}
|
||||
|
||||
suite("it reports a direction only when the climb beats the scatter");
|
||||
{
|
||||
const rising = app.foodTrend(days([200, 250, 300, 350, 400, 450, 0]));
|
||||
ok(rising.clear, "a clean climb is reported");
|
||||
eq(Math.round(rising.change), 250, "…as the move across the five days it fitted, 200 g to 450 g");
|
||||
|
||||
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
|
||||
ok(falling.clear, "a clean fall is reported");
|
||||
ok(falling.change < 0, "…with a negative change");
|
||||
|
||||
// Same mean, no direction, plenty of noise: the honest answer is "steady".
|
||||
const noisy = app.foodTrend(days([200, 500, 210, 480, 190, 520, 0]));
|
||||
ok(!noisy.clear, "a see-saw is not a trend, however the slope comes out");
|
||||
|
||||
// A gentle real climb buried in large day-to-day swings: also not claimable.
|
||||
const buried = app.foodTrend(days([300, 520, 180, 540, 200, 560, 0]));
|
||||
ok(!buried.clear, "a slope smaller than the scatter is not reported as a trend");
|
||||
}
|
||||
|
||||
suite("the fitted line passes through the data");
|
||||
{
|
||||
const t = app.foodTrend(days([100, 200, 300, 400, 500, 0]));
|
||||
eq(Math.round(t.at(0)), 100, "it starts where the first day sits");
|
||||
eq(Math.round(t.at(4)), 500, "and ends where the last complete day sits");
|
||||
eq(Math.round(t.mean), 300, "the mean is the mean of the days it fitted");
|
||||
}
|
||||
|
||||
suite("marked days are skipped without shifting the line");
|
||||
{
|
||||
// The middle day is marked; the rest describe a clean 50 g/day climb. The fit
|
||||
// must ignore the hatch rather than reading it as a day of zero grams.
|
||||
const t = app.foodTrend(days([200, 250, 0, 350, 400, 450, 0], { excluded: [2] }));
|
||||
eq(Math.round(t.change), 250, "the climb is unchanged by the marked day");
|
||||
ok(t.clear, "…and it is still clear, not drowned by a false zero");
|
||||
}
|
||||
|
||||
suite("what the sentence is allowed to say");
|
||||
{
|
||||
const say = (grams, opts, win = 14) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), win);
|
||||
|
||||
const rising = say([200, 250, 300, 350, 400, 450, 0]);
|
||||
ok(/up about 250 g/.test(rising), "a clear climb gives the size of the move");
|
||||
ok(/from roughly 200 g a day to 450 g/.test(rising), "…and the figures at each end");
|
||||
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
|
||||
|
||||
// The defect this replaced: the move was quoted per week while the fit spans
|
||||
// at most five days on a 7-day window, so the figure and the two endpoints
|
||||
// disagreed and a reader who subtracted them found the sentence wrong.
|
||||
// Whatever the window, the three numbers in the sentence must reconcile.
|
||||
for (const [label, grams, win] of [
|
||||
["a steep 7-day fall", [460, 425, 390, 355, 320, 285, 0], 7],
|
||||
["a long 14-day climb", [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 0], 14],
|
||||
["a gentle 30-day climb", [...Array(29).fill(0).map((_, i) => 300 + i * 12), 0], 30],
|
||||
]) {
|
||||
const s = say(grams, undefined, win);
|
||||
const m = s.match(/about (\d+) g — from roughly (\d+) g a day to (\d+) g/);
|
||||
ok(m, `${label}: the sentence has all three figures`);
|
||||
if (m) {
|
||||
const [, moved, from, to] = m.map(Number);
|
||||
eq(moved, Math.abs(to - from), `${label}: the move is exactly the difference of the two ends`);
|
||||
ok(new RegExp(`is ${to > from ? "up" : "down"} about`).test(s),
|
||||
`${label}: and the direction matches which end is larger`);
|
||||
}
|
||||
}
|
||||
|
||||
const steady = say([300, 302, 298, 301, 299, 300, 0]);
|
||||
ok(/roughly steady/.test(steady), "a flat run is called steady");
|
||||
ok(/averaging about 300 g a day/.test(steady),
|
||||
"…and quotes the average, which is a measurement rather than model output");
|
||||
ok(!/then/.test(steady) && !/ now\b/.test(steady),
|
||||
"…but not fitted endpoints, which would dress up a line nobody should read");
|
||||
|
||||
const noisy = say([200, 500, 210, 480, 190, 520, 0]);
|
||||
ok(/roughly steady/.test(noisy) && /variation is larger/.test(noisy),
|
||||
"a see-saw says the variation beat the trend, rather than quoting a slope");
|
||||
|
||||
const none = say([300, 320, 310]);
|
||||
ok(/Not enough complete days/.test(none) && /needs four/.test(none),
|
||||
"too few days explains itself instead of leaving the chart bare");
|
||||
|
||||
// False precision would make a fit look like a reading.
|
||||
ok(/\b\d*[05] g a day/.test(rising), "figures are rounded to 10 g, not quoted to the gram");
|
||||
const falling = say([450, 400, 350, 300, 250, 200, 0]);
|
||||
ok(/down about/.test(falling), "a clear fall says down");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- by kind
|
||||
// Splitting the bars must not change what the chart says for anyone who never
|
||||
// defines a kind, and the per-kind caption must stay bounded as kinds are added.
|
||||
{
|
||||
let kinds = [];
|
||||
const split = load({
|
||||
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
|
||||
"foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
|
||||
stubs: {
|
||||
liveFoodKinds: () => kinds,
|
||||
loadFoodKinds: () => kinds,
|
||||
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
||||
},
|
||||
});
|
||||
|
||||
// days carrying a per-kind split, as weeklyData builds them.
|
||||
const byKind = (rows) => rows.map(r => ({
|
||||
grams: Object.values(r).reduce((s, v) => s + v, 0),
|
||||
gramsByKind: r,
|
||||
excluded: false,
|
||||
meals: 0, mealsMissingGrams: 0,
|
||||
}));
|
||||
|
||||
suite("an unsplit chart is unchanged");
|
||||
{
|
||||
kinds = [];
|
||||
const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
|
||||
const series = split.foodSeriesFor(days);
|
||||
eq(series.length, 1, "no kinds defined gives exactly one series");
|
||||
eq(series[0].name, "No kind", "…the unnamed one");
|
||||
const s = split.foodSeriesSentences(series, 7);
|
||||
eq(s.length, 1, "…and one sentence, as before kinds existed");
|
||||
ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
|
||||
}
|
||||
|
||||
suite("the split adds up");
|
||||
{
|
||||
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
||||
const days = byKind([
|
||||
{ d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
|
||||
{ d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
|
||||
]);
|
||||
const series = split.foodSeriesFor(days);
|
||||
eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
|
||||
for (const d of days) {
|
||||
const summed = series.reduce((acc, s) => acc + s.of(d), 0);
|
||||
eq(summed, d.grams, "each day's segments sum to the day's own total");
|
||||
}
|
||||
}
|
||||
|
||||
suite("a kind with nothing logged is left out");
|
||||
{
|
||||
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
|
||||
const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
|
||||
eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
|
||||
"an unused kind gets no segment and no legend entry");
|
||||
}
|
||||
|
||||
suite("a deleted kind's food still appears");
|
||||
{
|
||||
// The kind is gone from the picker but meals still point at it, and that
|
||||
// food is real — it has to show under the name the tombstone kept.
|
||||
kinds = [];
|
||||
const namesOnly = load({
|
||||
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
|
||||
stubs: {
|
||||
liveFoodKinds: () => [],
|
||||
// The tombstone: gone from the picker, still carrying name and colour.
|
||||
loadFoodKinds: () => [{ id: "gone", name: "Old recipe", colorIndex: 2, deleted: true }],
|
||||
},
|
||||
});
|
||||
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
|
||||
const series = namesOnly.foodSeriesFor(days);
|
||||
eq(series.map(s => s.name), ["Old recipe"],
|
||||
"it keeps its name rather than vanishing or reading as 'No kind'");
|
||||
eq(series[0].colorIndex, 2,
|
||||
"…and its colour, which grey would confuse with the 'No kind' series");
|
||||
}
|
||||
|
||||
suite("the caption stays bounded as kinds are added");
|
||||
{
|
||||
kinds = [
|
||||
{ id: "a", name: "Dry", colorIndex: 0 },
|
||||
{ id: "b", name: "Fresh", colorIndex: 1 },
|
||||
{ id: "c", name: "Raw", colorIndex: 2 },
|
||||
{ id: "e", name: "Treats", colorIndex: 3 },
|
||||
];
|
||||
// Dry climbs clearly; the rest are flat or noise.
|
||||
const days = byKind([
|
||||
{ a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
|
||||
{ a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
|
||||
{ a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
|
||||
]);
|
||||
const series = split.foodSeriesFor(days);
|
||||
const lines = split.foodSeriesSentences(series, 7);
|
||||
ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
|
||||
ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
|
||||
ok(lines.some(l => /no clear trend/.test(l)),
|
||||
"…and the rest are folded into one clause rather than a sentence each");
|
||||
}
|
||||
|
||||
suite("no kind moves at all");
|
||||
{
|
||||
kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
|
||||
const days = byKind([
|
||||
{ a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
|
||||
{ a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
|
||||
]);
|
||||
const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
|
||||
eq(lines.length, 1, "one line when nothing is claimable");
|
||||
ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the highlighted day's readout
|
||||
// Tapping a bar selects that day; this is what the selection says. It reads off
|
||||
// the existing selection rather than keeping its own, so the two cannot drift.
|
||||
{
|
||||
let kinds = [];
|
||||
let selected = "2026-09-20";
|
||||
let el = { hidden: false, textContent: "" };
|
||||
const info = load({
|
||||
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor", "renderFoodDayInfo"],
|
||||
stubs: {
|
||||
document: { getElementById: () => el },
|
||||
selectedDay: () => new Date(selected + "T12:00:00"),
|
||||
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
|
||||
liveFoodKinds: () => kinds,
|
||||
loadFoodKinds: () => kinds,
|
||||
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
|
||||
},
|
||||
});
|
||||
|
||||
const day = (n, byKind, excluded = false) => ({
|
||||
ymd: `2026-09-${String(n).padStart(2, "0")}`,
|
||||
date: new Date(2026, 8, n),
|
||||
grams: Object.values(byKind).reduce((s, v) => s + v, 0),
|
||||
gramsByKind: byKind, excluded, meals: 0, mealsMissingGrams: 0,
|
||||
});
|
||||
const read = (days) => {
|
||||
el = { hidden: false, textContent: "" };
|
||||
info.renderFoodDayInfo(days, info.foodSeriesFor(days));
|
||||
return el;
|
||||
};
|
||||
|
||||
suite("the highlighted day's breakdown");
|
||||
{
|
||||
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
||||
const days = [
|
||||
day(18, { d: 200, f: 100 }), day(19, { d: 210, f: 90 }), day(20, { d: 260, f: 100 }),
|
||||
];
|
||||
selected = "2026-09-20";
|
||||
const r = read(days);
|
||||
eq(r.hidden, false, "the selected day gets a readout");
|
||||
ok(/Dry 260 g · Fresh 100 g/.test(r.textContent), "each kind's amount, in the stack's order");
|
||||
ok(/360 g in total/.test(r.textContent), "…and the total, so you needn't add them up");
|
||||
ok(/Sep 20/.test(r.textContent), "…named, so it is clear which bar it belongs to");
|
||||
|
||||
selected = "2026-09-18";
|
||||
ok(/Dry 200 g/.test(read(days).textContent), "selecting another bar moves the readout");
|
||||
|
||||
// Out of the window entirely: the chart is not showing that day at all.
|
||||
selected = "2026-08-01";
|
||||
eq(read(days).hidden, true, "a day outside the window has no bar and so no readout");
|
||||
}
|
||||
|
||||
suite("the days that say something else");
|
||||
{
|
||||
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
|
||||
selected = "2026-09-20";
|
||||
const withEmpty = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {})];
|
||||
ok(/no food logged/.test(read(withEmpty).textContent), "a day with no food says so");
|
||||
|
||||
const withExcluded = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {}, true)];
|
||||
ok(/not counted/.test(read(withExcluded).textContent),
|
||||
"a day marked not counted says that instead of reading as empty");
|
||||
}
|
||||
|
||||
suite("an unsplit chart gets the day total too");
|
||||
{
|
||||
// No kinds defined: there is still no hover on a phone, so the figure was
|
||||
// only readable by eye off the axis.
|
||||
kinds = [];
|
||||
selected = "2026-09-20";
|
||||
const days = [day(18, { "": 300 }), day(19, { "": 320 }), day(20, { "": 340 })];
|
||||
const r = read(days);
|
||||
eq(r.hidden, false, "the readout appears without any kinds defined");
|
||||
eq(r.textContent, "Sun, Sep 20 — 340 g.", "…as the plain total, named by day");
|
||||
ok(!/No kind/.test(r.textContent),
|
||||
"…without inventing a kind name for food that has none");
|
||||
ok(!/in total/.test(r.textContent),
|
||||
"…and without saying the same number twice");
|
||||
}
|
||||
}
|
||||
|
||||
export default report("food-trend");
|
||||
@@ -0,0 +1,196 @@
|
||||
// Structural facts about the markup and stylesheet that no amount of reading
|
||||
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
|
||||
// removed, a label that truncates on a phone nobody here owns.
|
||||
//
|
||||
// The width arithmetic is an estimate, not a measurement — there is no browser
|
||||
// in this loop. Layout numbers are read out of style.css so they cannot drift
|
||||
// from the real ones, text is sized from per-character advances, and the pass
|
||||
// mark insists on a few pixels of headroom because the estimate is only good
|
||||
// to a few percent. It will catch a sixth tab or a longer label; it will not
|
||||
// settle a two-pixel question.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const html = readFileSync(join(root, "src", "index.html"), "utf8");
|
||||
const css = readFileSync(join(root, "src", "style.css"), "utf8");
|
||||
|
||||
// ---------------------------------------------------------------- stylesheet
|
||||
suite("the stylesheet is structurally intact");
|
||||
{
|
||||
let depth = 0, pairs = 0, stray = 0;
|
||||
for (let i = 0; i < css.length - 1; i++) {
|
||||
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
|
||||
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
|
||||
}
|
||||
eq(depth, 0, `comments open and close in pairs (${pairs})`);
|
||||
eq(stray, 0, "no stray comment terminators");
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
|
||||
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
|
||||
// Author rules that set a display beat the user agent's [hidden] rule, so
|
||||
// hidden elements keep rendering without this. It has caught five so far.
|
||||
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
|
||||
"the global [hidden] rule is present");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the tabs
|
||||
suite("every panel lives in exactly one tab");
|
||||
{
|
||||
const EXPECTED = [
|
||||
"overview", "sleep-wake", "history",
|
||||
"sleep-daily", "sleep-timeline", "sleep-trend",
|
||||
"walks", "walk-timeline", "walk-trend",
|
||||
"timing", "counts",
|
||||
"weight", "training", "notes",
|
||||
];
|
||||
const main = html.slice(html.indexOf(" <main>"), html.indexOf(" </main>"));
|
||||
let current = null;
|
||||
const placement = new Map();
|
||||
const wrappers = [];
|
||||
for (const line of main.split("\n")) {
|
||||
const open = line.match(/^ {6}<div class="tab-panel" data-tab="([^"]+)"/);
|
||||
if (open) { current = open[1]; wrappers.push(current); continue; }
|
||||
if (/^ {6}<\/div>/.test(line) && current) { current = null; continue; }
|
||||
const panel = line.match(/data-panel="([^"]+)"/);
|
||||
if (panel && /^ {8}<section/.test(line)) {
|
||||
if (!placement.has(panel[1])) placement.set(panel[1], []);
|
||||
placement.get(panel[1]).push(current);
|
||||
}
|
||||
}
|
||||
const missing = EXPECTED.filter(k => !placement.has(k));
|
||||
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
|
||||
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
|
||||
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
|
||||
eq(missing, [], "no panel has gone missing");
|
||||
eq(duplicated, [], "no panel appears twice");
|
||||
eq(loose, [], "no panel sits outside a tab");
|
||||
eq(unexpected, [], "no unaccounted-for panel has appeared");
|
||||
|
||||
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||
eq(buttons, wrappers, "a button for each tab, in the same order");
|
||||
const ariaOk = buttons.every(t =>
|
||||
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
|
||||
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
|
||||
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
|
||||
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
|
||||
for (const k of ["walk-timeline", "walk-trend"]) {
|
||||
ok(new RegExp(`<section[^>]*data-panel="${k}"`).test(html),
|
||||
`"${k}" is still a <section>, which renderWalkPatterns queries for`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- the widths
|
||||
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
|
||||
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
|
||||
const em = (s) => [...s].reduce((n, ch) => {
|
||||
if (ch >= "0" && ch <= "9") return n + ADV.digit;
|
||||
if (ch === ":" || ch === " ") return n + ADV.thin;
|
||||
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
|
||||
if (ch === "←" || ch === "→") return n + 0.6;
|
||||
return n + (NARROW[ch] ?? ADV.lower);
|
||||
}, 0);
|
||||
const EMOJI = 18;
|
||||
const BODY_GUTTER = 16;
|
||||
|
||||
const rule = (re) => (css.match(re) || [, ""])[1] || "";
|
||||
const px = (block, prop, dflt) => {
|
||||
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
|
||||
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
|
||||
};
|
||||
|
||||
const DEVICES = [
|
||||
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
|
||||
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
|
||||
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
|
||||
];
|
||||
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
|
||||
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
|
||||
|
||||
suite("the tab labels fit without truncating");
|
||||
{
|
||||
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+)</g)].map(m => m[1]);
|
||||
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
|
||||
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
|
||||
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
|
||||
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
|
||||
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
|
||||
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
|
||||
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
|
||||
for (const [name, w] of DEVICES) {
|
||||
const v = w <= 370 ? narrow : wide;
|
||||
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
|
||||
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
|
||||
const need = em(widest) * v.font;
|
||||
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
|
||||
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
|
||||
}
|
||||
}
|
||||
|
||||
suite("both day-bar timers fit on a row of their own");
|
||||
{
|
||||
// The bar may wrap when it must; what it must not do is squash the timers.
|
||||
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
|
||||
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
|
||||
const narrowPill = inNarrow("\\.bar-clock");
|
||||
const narrow = { font: px(narrowPill, "font-size", wide.font),
|
||||
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
|
||||
const barPadX = 10, groupGap = 6;
|
||||
const worst = "3:12:45"; // both timers past an hour
|
||||
for (const [name, w] of DEVICES) {
|
||||
const v = w <= 370 ? narrow : wide;
|
||||
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
|
||||
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
|
||||
ok(room - (2 * pill + groupGap) >= 2,
|
||||
`${String(w).padStart(4)}px ${name}: two timers fit ` +
|
||||
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything wider than the screen makes the whole document wider than the
|
||||
// viewport, and a phone responds by letting you zoom out — which is how this
|
||||
// class of bug is usually noticed, long after it was introduced.
|
||||
suite("nothing can push the page wider than the screen");
|
||||
{
|
||||
// Free text the user types has no width limit of its own. A long unbroken
|
||||
// token — a URL in a note, a chemical name off a food bag — sets a flex
|
||||
// item's content-based minimum, or simply spills out of its box, and either
|
||||
// way it widens the document. Every element that renders user input needs a
|
||||
// break rule; this is the list, and it is easier to extend than to remember.
|
||||
const USER_TEXT = [
|
||||
"\\.event \\.note", // a note on a history row
|
||||
"\\.event \\.note-text", // the Notes log
|
||||
"\\.ex-name", // exercise names
|
||||
"\\.ex-note", // exercise instructions
|
||||
"\\.guest-item-label", // the label on a guest link
|
||||
];
|
||||
for (const sel of USER_TEXT) {
|
||||
const block = rule(new RegExp(`\\n${sel}[^{]*\\{([^}]*)\\}`));
|
||||
ok(/overflow-wrap:\s*(anywhere|break-word)/.test(block),
|
||||
`${sel.replace(/\\/g, "")} can break a long unbroken word`);
|
||||
}
|
||||
|
||||
// The month grid is the one panel positioned against something narrower than
|
||||
// the page. Centred on the date button it hung off the right of a phone; it
|
||||
// is anchored to the day bar instead, which spans the content width.
|
||||
const dayCal = rule(/\n\.day-cal \{([^}]*)\}/);
|
||||
ok(/right:/.test(dayCal) && !/left:\s*50%/.test(dayCal),
|
||||
"the month grid is edge-anchored, not centred on the date button");
|
||||
ok(!/max-width:[^;]*vw/.test(dayCal),
|
||||
"…and bounded by its container rather than by the viewport");
|
||||
const main = html.slice(html.indexOf('<section class="day-bar">'),
|
||||
html.indexOf("</section>", html.indexOf('<section class="day-bar">')));
|
||||
ok(/id="day-cal"/.test(main), "…and sits inside the day bar, which is what it is measured against");
|
||||
|
||||
// A fixed width wider than the narrowest content box cannot fit by
|
||||
// definition. 320px phone, less the body's two 16px gutters.
|
||||
const NARROWEST = 320 - 2 * BODY_GUTTER;
|
||||
const tooWide = [...css.matchAll(/(?:^|[;{\s])(width|min-width):\s*(\d{3,})px/g)]
|
||||
.filter(m => Number(m[2]) > NARROWEST)
|
||||
.map(m => `${m[1]}: ${m[2]}px`);
|
||||
eq(tooWide, [], `no fixed width exceeds a ${NARROWEST}px content box`);
|
||||
}
|
||||
|
||||
export default report("layout");
|
||||
@@ -0,0 +1,98 @@
|
||||
// Long-press two rows and the app subtracts their times. The press itself
|
||||
// needs a finger, but everything it decides — which picks are held, what the
|
||||
// bar says — is ordinary logic, and that is where this can go quietly wrong.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
let rendered = 0;
|
||||
|
||||
const app = load({
|
||||
names: [
|
||||
"EVENT_LABELS", "ymd", "formatDuration", "formatTime",
|
||||
"measurePick", "toggleMeasurePick", "clearMeasure",
|
||||
"measureSummary", "measureLabel",
|
||||
],
|
||||
lets: ["measurePick"],
|
||||
stubs: { render: () => { rendered++; } },
|
||||
});
|
||||
|
||||
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
|
||||
const ate = { id: "a", type: "eat", at: at(20, 12, 10) };
|
||||
const poo = { id: "b", type: "poo", at: at(20, 15, 52) };
|
||||
const pee = { id: "c", type: "pee", at: at(20, 18, 30) };
|
||||
const lateEat = { id: "d", type: "eat", at: at(19, 18, 30) }; // the evening before
|
||||
const events = [ate, poo, pee, lateEat];
|
||||
|
||||
const pick = (...ids) => { app.set.measurePick([]); ids.forEach(app.toggleMeasurePick); };
|
||||
const held = () => app.get.measurePick();
|
||||
|
||||
suite("what a press does to the pick");
|
||||
{
|
||||
pick("a");
|
||||
eq(held(), ["a"], "one press holds one");
|
||||
pick("a", "b");
|
||||
eq(held(), ["a", "b"], "a second press holds the pair");
|
||||
|
||||
// The user's choice: a third is refused rather than rolling the pair on.
|
||||
pick("a", "b", "c");
|
||||
eq(held(), ["a", "b"], "a third press is ignored while two are held");
|
||||
|
||||
// Not a third selection but an undo of one — a mis-press costs one press
|
||||
// rather than starting over.
|
||||
pick("a", "b");
|
||||
app.toggleMeasurePick("a");
|
||||
eq(held(), ["b"], "pressing a picked row unpicks it");
|
||||
app.toggleMeasurePick("c");
|
||||
eq(held(), ["b", "c"], "…leaving room for a different second");
|
||||
|
||||
pick("a", "b");
|
||||
app.clearMeasure();
|
||||
eq(held(), [], "clearing drops both");
|
||||
}
|
||||
|
||||
suite("the reading");
|
||||
{
|
||||
const two = app.measureSummary(["a", "b"], events);
|
||||
ok(two.show && two.complete, "two picks give a complete reading");
|
||||
eq(two.duration, "3h 42m", "12:10 to 15:52 is 3h 42m");
|
||||
|
||||
// Pressed newest-first, which is the natural way to scan a log upward.
|
||||
const reversed = app.measureSummary(["b", "a"], events);
|
||||
eq(reversed.duration, "3h 42m", "the order they were pressed in doesn't change the gap");
|
||||
eq(reversed.text, two.text, "…and it still reads chronologically, earliest first");
|
||||
ok(/Ate/.test(two.text) && /Poo/.test(two.text), "both events are named");
|
||||
}
|
||||
|
||||
suite("a pair that straddles midnight");
|
||||
{
|
||||
const overnight = app.measureSummary(["d", "b"], events); // 19th 18:30 → 20th 15:52
|
||||
eq(overnight.duration, "21h 22m", "the gap crosses the day boundary correctly");
|
||||
ok(/Sep/.test(overnight.text),
|
||||
"the dates are named, since two bare times would be ambiguous across days");
|
||||
ok(!/Sep/.test(app.measureSummary(["a", "b"], events).text),
|
||||
"…but a same-day pair stays uncluttered");
|
||||
}
|
||||
|
||||
suite("an incomplete or stale pick");
|
||||
{
|
||||
const one = app.measureSummary(["a"], events);
|
||||
ok(one.show && !one.complete, "one pick shows the bar without a duration");
|
||||
ok(/long-press another/.test(one.text), "…and asks for the second");
|
||||
|
||||
eq(app.measureSummary([], events).show, false, "nothing picked hides the bar");
|
||||
|
||||
// Deleted here, or tombstoned by another device mid-measurement.
|
||||
const stale = app.measureSummary(["a", "gone"], events);
|
||||
eq(stale.ids, ["a"], "an id that no longer resolves is dropped from the pick");
|
||||
ok(!stale.complete, "…so what is left is one pick, not a broken pair");
|
||||
eq(app.measureSummary(["gone", "also-gone"], events).show, false,
|
||||
"both gone hides the bar rather than showing an empty one");
|
||||
}
|
||||
|
||||
suite("the label");
|
||||
{
|
||||
eq(app.measureLabel(ate), `Ate ${app.formatTime(ate.at)}`, "type and time");
|
||||
ok(/Sep 20/.test(app.measureLabel(ate, true)), "with the date when asked for");
|
||||
}
|
||||
|
||||
export default report("measure");
|
||||
@@ -0,0 +1,53 @@
|
||||
// Runs every check. Exits non-zero if any assertion failed.
|
||||
//
|
||||
// nix develop -c node checks/run.mjs
|
||||
//
|
||||
// These sit beside the Go tests rather than replacing them: the server has
|
||||
// `go test`, and this is the frontend's half — the arithmetic behind the
|
||||
// charts, the structure of the markup, and the width budgets that no one here
|
||||
// can see. It needs no build step and no dependencies; node is already in the
|
||||
// devShell for `node --check`.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(here, "..");
|
||||
|
||||
let failed = 0;
|
||||
|
||||
// The frontend has no build step, so a syntax error would otherwise only show
|
||||
// up in a browser nobody here is running.
|
||||
for (const file of ["app.js", "sw.js"]) {
|
||||
try {
|
||||
execFileSync(process.execPath, ["--check", join(root, "src", file)], { stdio: "pipe" });
|
||||
console.log(` ok src/${file} parses`);
|
||||
} catch (e) {
|
||||
console.log(` FAIL src/${file}\n${e.stderr?.toString() ?? e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
try {
|
||||
JSON.parse(readdirSync(join(root, "src")).includes("changelog.json")
|
||||
? (await import("node:fs")).readFileSync(join(root, "src", "changelog.json"), "utf8")
|
||||
: "[]");
|
||||
console.log(" ok src/changelog.json parses");
|
||||
} catch (e) {
|
||||
console.log(` FAIL src/changelog.json: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
const suites = readdirSync(here)
|
||||
.filter(f => f.endsWith(".mjs") && !["run.mjs", "extract.mjs", "assert.mjs"].includes(f))
|
||||
.sort();
|
||||
|
||||
for (const file of suites) {
|
||||
const mod = await import(join(here, file));
|
||||
failed += mod.default ?? 0;
|
||||
}
|
||||
|
||||
console.log(failed === 0
|
||||
? `\nall checks passed (${suites.length} suites)`
|
||||
: `\n${failed} assertion(s) FAILED`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,77 @@
|
||||
// The sleep and walk trends draw the selected day against yesterday and the
|
||||
// window average. A day marked "not counted" has to be absent from all three,
|
||||
// and the one that kept slipping through was the selected day itself — it is
|
||||
// the boldest line on the panel, so it reads as the answer.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const SEL = new Date(2026, 8, 20); // the day under the cursor
|
||||
const at = (day, hour) => new Date(2026, 8, day, hour).getTime();
|
||||
|
||||
let excluded = new Set();
|
||||
let windowDays = 7;
|
||||
|
||||
const curves = load({
|
||||
names: ["startOfDay", "pairWindows", "sleepWindows", "sleepTrendCurves"],
|
||||
stubs: {
|
||||
selectedDay: () => SEL,
|
||||
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
|
||||
isExcluded: (d) => excluded.has(d.getDate()),
|
||||
chartDays: () => windowDays,
|
||||
},
|
||||
});
|
||||
|
||||
// A night's sleep on each of several days, so every curve has something to draw.
|
||||
const slept = (day, fromHour, toHour) => ([
|
||||
{ id: `s${day}a`, type: "sleep-start", at: at(day, fromHour) },
|
||||
{ id: `s${day}b`, type: "sleep-end", at: at(day, toHour) },
|
||||
]);
|
||||
const week = [16, 17, 18, 19, 20].flatMap(d => slept(d, 1, 5));
|
||||
|
||||
suite("the selected day's own curve");
|
||||
{
|
||||
excluded = new Set();
|
||||
const c = curves.sleepTrendCurves(week);
|
||||
ok(c.today && c.today.length > 1, "a normal day is drawn");
|
||||
eq(c.dayExcluded, false, "…and not flagged as excluded");
|
||||
|
||||
excluded = new Set([20]); // the selected day
|
||||
const m = curves.sleepTrendCurves(week);
|
||||
eq(m.today, null, "a day marked 'not counted' is not drawn at all");
|
||||
eq(m.dayExcluded, true, "…and says so, so the legend can drop its chip");
|
||||
eq(m.projected, null, "…and nothing is projected from a curve that isn't there");
|
||||
ok(m.avg && m.avg.length > 1, "the average it would have been read against survives");
|
||||
ok(m.yesterday && m.yesterday.length > 1, "so does yesterday");
|
||||
}
|
||||
|
||||
suite("the comparison day and the average");
|
||||
{
|
||||
excluded = new Set([19]); // yesterday, relative to the 20th
|
||||
const c = curves.sleepTrendCurves(week);
|
||||
eq(c.yesterday, null, "a marked yesterday is dropped rather than drawn flat");
|
||||
ok(c.today && c.today.length > 1, "the selected day is unaffected by it");
|
||||
|
||||
// Every day but the selected one marked: nothing left to average over.
|
||||
excluded = new Set([16, 17, 18, 19]);
|
||||
const none = curves.sleepTrendCurves(week);
|
||||
eq(none.avg, null, "an average with no days left to average is null, not zero");
|
||||
ok(none.today && none.today.length > 1, "…and the selected day still draws");
|
||||
}
|
||||
|
||||
suite("a marked day never contributes to the average");
|
||||
{
|
||||
// The 19th sleeps far longer than the rest. With it counted the average is
|
||||
// dragged up; marked, it should leave no trace.
|
||||
const lopsided = [...[16, 17, 18].flatMap(d => slept(d, 1, 3)), ...slept(19, 1, 23), ...slept(20, 1, 3)];
|
||||
windowDays = 7;
|
||||
|
||||
excluded = new Set();
|
||||
const withIt = curves.sleepTrendCurves(lopsided).avg[24].y;
|
||||
excluded = new Set([19]);
|
||||
const without = curves.sleepTrendCurves(lopsided).avg[24].y;
|
||||
ok(withIt > without, "marking the outlier lowers the average it was inflating");
|
||||
eq(Math.round(without), 2, "…back to the two hours the remaining days actually slept");
|
||||
}
|
||||
|
||||
export default report("trend-curves");
|
||||
+19
-10
@@ -504,6 +504,7 @@ func (a *Auth) deleteAccount(userID string) error {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM events WHERE user_id = ?`,
|
||||
`DELETE FROM exercises WHERE user_id = ?`,
|
||||
`DELETE FROM food_kinds WHERE user_id = ?`,
|
||||
`DELETE FROM config WHERE user_id = ?`,
|
||||
`DELETE FROM push_subscriptions WHERE user_id = ?`,
|
||||
`DELETE FROM reminders WHERE user_id = ?`,
|
||||
@@ -554,11 +555,17 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
// link it came from — so every data path downstream (sync, photos, config) keeps
|
||||
// working untouched, and only the capability checks differ by role.
|
||||
//
|
||||
// The raw token is shown exactly once, at creation. Only its hash is stored,
|
||||
// the same way session tokens are, so a leaked database yields no usable links.
|
||||
// The token is kept, not just its hash, so Settings can show the URL again
|
||||
// whenever the owner wants to re-send it. That is a deliberate trade the way it
|
||||
// would not be for a password or a session token: a guest link grants a subset
|
||||
// of what the same database already holds in plaintext, so whoever can read
|
||||
// puppy.db gains very little from it, and the link expires and can be revoked
|
||||
// besides. The `token` column stays a hash and remains the lookup key; `secret`
|
||||
// is the copy handed back to the owner.
|
||||
|
||||
// ShareLink is the public shape of a guest link. Token is set only on the
|
||||
// response to the call that created it, and never stored in the clear.
|
||||
// ShareLink is the public shape of a guest link. Token carries the URL's secret
|
||||
// and comes back on every listing, so the owner can copy the link again rather
|
||||
// than having one chance at it when it is created.
|
||||
type ShareLink struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
@@ -585,20 +592,22 @@ func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, erro
|
||||
Token: token,
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT INTO share_links (id, user_id, token, label, created, expires) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
link.ID, userID, hashToken(token), link.Label, link.Created, link.Expires)
|
||||
`INSERT INTO share_links (id, user_id, token, secret, label, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
link.ID, userID, hashToken(token), token, link.Label, link.Created, link.Expires)
|
||||
if err != nil {
|
||||
return ShareLink{}, err
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
// listShares returns the account's links that are still usable. Revoked and
|
||||
// listShares returns the account's links that are still usable, each with its
|
||||
// URL secret so Settings can offer the link for copying at any time. Revoked and
|
||||
// lapsed ones are of no interest to the UI — the point of the list is "who can
|
||||
// get in right now".
|
||||
// get in right now". A link created before secrets were kept comes back with an
|
||||
// empty Token; the UI says so rather than showing a broken URL.
|
||||
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT id, label, created, expires, last_used
|
||||
SELECT id, label, created, expires, last_used, secret
|
||||
FROM share_links
|
||||
WHERE user_id = ? AND revoked = 0 AND expires > ?
|
||||
ORDER BY created DESC`, userID, time.Now().UnixMilli())
|
||||
@@ -609,7 +618,7 @@ func (a *Auth) listShares(userID string) ([]ShareLink, error) {
|
||||
out := make([]ShareLink, 0)
|
||||
for rows.Next() {
|
||||
var l ShareLink
|
||||
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed); err != nil {
|
||||
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed, &l.Token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
|
||||
+120
-8
@@ -230,13 +230,36 @@ func TestListSharesHidesRevokedAndExpired(t *testing.T) {
|
||||
if len(links) != 1 || links[0].ID != live.ID {
|
||||
t.Fatalf("listed %d link(s), want only the live one", len(links))
|
||||
}
|
||||
if links[0].Token != "" {
|
||||
t.Error("listing leaked a raw token")
|
||||
}
|
||||
|
||||
// Settings shows every live link's URL so it can be re-sent, which means a
|
||||
// listing has to carry the same secret the link was created with — and that
|
||||
// secret has to still work.
|
||||
func TestListSharesReturnsAWorkingURL(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
links, err := a.listShares(ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("listed %d link(s), want 1", len(links))
|
||||
}
|
||||
if links[0].Token != link.Token {
|
||||
t.Fatalf("listing returned %q, want the issued secret %q", links[0].Token, link.Token)
|
||||
}
|
||||
if _, _, ok := a.redeemShare(links[0].Token); !ok {
|
||||
t.Error("the secret handed back by the listing does not open the link")
|
||||
}
|
||||
}
|
||||
|
||||
// The raw token is only ever handed back once, at creation.
|
||||
func TestOnlyTheTokenHashIsStored(t *testing.T) {
|
||||
// The lookup column stays a hash even though the secret is kept beside it, so
|
||||
// the token in a URL is never what is matched against directly.
|
||||
func TestLookupIsStillByHash(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
|
||||
@@ -247,11 +270,36 @@ func TestOnlyTheTokenHashIsStored(t *testing.T) {
|
||||
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if stored == link.Token {
|
||||
t.Error("the raw token is stored in the clear")
|
||||
}
|
||||
if stored != hashToken(link.Token) {
|
||||
t.Error("stored token is not the hash of the issued one")
|
||||
t.Error("the lookup column is not the hash of the issued token")
|
||||
}
|
||||
}
|
||||
|
||||
// A link made before secrets were kept has no URL to show. It must still work
|
||||
// and still be revocable — only the copy-again affordance is unavailable.
|
||||
func TestLinkWithoutAStoredSecretStillWorks(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
ownerID := testOwner(t, a)
|
||||
link, err := a.createShare(ownerID, "Legacy", hoursAhead(24))
|
||||
if err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
// What the migration leaves behind for a pre-existing row.
|
||||
if _, err := a.db.Exec(`UPDATE share_links SET secret = '' WHERE id = ?`, link.ID); err != nil {
|
||||
t.Fatalf("clear secret: %v", err)
|
||||
}
|
||||
links, err := a.listShares(ownerID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(links) != 1 || links[0].Token != "" {
|
||||
t.Fatalf("want the link listed with an empty token, got %+v", links)
|
||||
}
|
||||
if _, _, ok := a.redeemShare(link.Token); !ok {
|
||||
t.Error("a link whose secret was never stored stopped working")
|
||||
}
|
||||
if err := a.revokeShare(ownerID, link.ID); err != nil {
|
||||
t.Errorf("could not revoke it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +680,70 @@ func TestGuestCannotExcludeADay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The food kinds are the owner's library, like the exercise list: a guest
|
||||
// labels a meal with a kind that exists but does not invent or rename one.
|
||||
func TestGuestCannotChangeFoodKinds(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
kinds := newFoodKindStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
if _, err := kinds.sync(ownerID, []FoodKind{
|
||||
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("owner sync: %v", err)
|
||||
}
|
||||
|
||||
// What the route hands the store for a guest: nothing incoming, everything
|
||||
// back. Mirrors the exercises guard in main.go.
|
||||
merged, err := kinds.sync(ownerID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("guest sync: %v", err)
|
||||
}
|
||||
if len(merged) != 1 || merged[0].Name != "Dry" {
|
||||
t.Fatalf("a guest should still receive the library: %+v", merged)
|
||||
}
|
||||
if !merged[0].IsDefault {
|
||||
t.Error("the default flag did not survive the round trip")
|
||||
}
|
||||
|
||||
// And the owner can still rename it, which is the other half of the rule.
|
||||
renamed, err := kinds.sync(ownerID, []FoodKind{
|
||||
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("owner rename: %v", err)
|
||||
}
|
||||
if renamed[0].Name != "Dry kibble" {
|
||||
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A meal's kind rides the event sync like any other field, and an older client
|
||||
// that doesn't know about kinds must not wipe one.
|
||||
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
store := newStore(a.db)
|
||||
ownerID := testOwner(t, a)
|
||||
|
||||
merged, err := store.sync(ownerID, "", "", []Event{
|
||||
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
|
||||
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
byID := map[string]Event{}
|
||||
for _, e := range merged {
|
||||
byID[e.ID] = e
|
||||
}
|
||||
if byID["e1"].FoodKindID != "k1" {
|
||||
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
|
||||
}
|
||||
if byID["e2"].FoodKindID != "" {
|
||||
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
|
||||
}
|
||||
}
|
||||
|
||||
// Guests still log freely — the guard is on changing what already exists.
|
||||
func TestGuestCanStillAddEvents(t *testing.T) {
|
||||
a := testAuth(t)
|
||||
|
||||
+179
-6
@@ -32,6 +32,10 @@ type Event struct {
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||
// FoodKindID names which sort of food, for "eat" events. Empty is a real
|
||||
// answer — "no kind" — and is what every meal logged before kinds existed
|
||||
// carries, so none of them needed rewriting.
|
||||
FoodKindID string `json:"foodKindId,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
// LoggedBy names the guest link an event was logged through, empty for the
|
||||
@@ -176,12 +180,13 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
|
||||
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
|
||||
// but those rows already exist and so keep their stored values.
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type, at = excluded.at, note = excluded.note,
|
||||
photo_id = excluded.photo_id, weight = excluded.weight,
|
||||
grams = excluded.grams, exercise_id = excluded.exercise_id,
|
||||
food_kind_id = excluded.food_kind_id,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > events.updated
|
||||
AND events.user_id = excluded.user_id
|
||||
@@ -204,7 +209,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID,
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -218,7 +223,7 @@ func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event,
|
||||
// all returns one user's events, tombstones included.
|
||||
func (s *Store) all(userID string) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share
|
||||
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
|
||||
FROM events WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -228,7 +233,7 @@ func (s *Store) all(userID string) ([]Event, error) {
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare,
|
||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,6 +242,100 @@ func (s *Store) all(userID string) ([]Event, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
|
||||
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
|
||||
// logged before kinds existed carries and what anyone who doesn't want to
|
||||
// classify their food keeps carrying.
|
||||
//
|
||||
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
|
||||
// tombstoned deletes — plus two fields of its own:
|
||||
//
|
||||
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
|
||||
// than in the profile because the profile is last-write-wins across the
|
||||
// whole row, and this file already carries a special case for pedigree_id
|
||||
// to stop a clock race dropping it. Per-item LWW needs no such case: two
|
||||
// devices setting different defaults resolve to the newer one.
|
||||
// - ColorIndex fixes which palette entry the charts give it, assigned at
|
||||
// creation. Deriving colour from position in the live list would silently
|
||||
// recolour every past chart the moment a kind was deleted.
|
||||
type FoodKind struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"isDefault,omitempty"`
|
||||
ColorIndex int `json:"colorIndex"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
|
||||
// Store and ExerciseStore are already near-twins, so a third in the same shape
|
||||
// is the pattern this file has established, and it leaves both working
|
||||
// collections untouched. Folding all three into one store parameterised by
|
||||
// table name is the tidier end state, and a separate job.
|
||||
type FoodKindStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newFoodKindStore(db *sql.DB) *FoodKindStore {
|
||||
return &FoodKindStore{db: db}
|
||||
}
|
||||
|
||||
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, is_default = excluded.is_default,
|
||||
color_index = excluded.color_index,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > food_kinds.updated
|
||||
AND food_kinds.user_id = excluded.user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, k := range client {
|
||||
if k.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.all(userID)
|
||||
}
|
||||
|
||||
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, is_default, color_index, updated, deleted
|
||||
FROM food_kinds WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]FoodKind, 0)
|
||||
for rows.Next() {
|
||||
var k FoodKind
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
|
||||
// UpdatedAt, same user_id guard against cross-user id collisions, same
|
||||
// tombstone propagation.
|
||||
@@ -341,7 +440,8 @@ func openDB(path string) (*sql.DB, error) {
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
logged_by TEXT NOT NULL DEFAULT '',
|
||||
logged_by_share TEXT NOT NULL DEFAULT ''
|
||||
logged_by_share TEXT NOT NULL DEFAULT '',
|
||||
food_kind_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS exercises (
|
||||
@@ -353,6 +453,16 @@ func openDB(path string) (*sql.DB, error) {
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
|
||||
CREATE TABLE IF NOT EXISTS food_kinds (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
color_index INTEGER NOT NULL DEFAULT 0,
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
@@ -377,6 +487,7 @@ func openDB(path string) (*sql.DB, error) {
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
secret TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
@@ -476,6 +587,29 @@ func migrateSchema(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Guest links are re-showable in Settings, which means keeping the token
|
||||
// itself and not only its hash (see Auth.createShare). Links made before
|
||||
// this have an empty secret and simply cannot be shown again.
|
||||
hasSecret, err := columnExists(db, "share_links", "secret")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSecret {
|
||||
if _, err := db.Exec(`ALTER TABLE share_links ADD COLUMN secret TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Which sort of food a meal was. Empty on every existing row, which is
|
||||
// exactly right: those meals have no kind, and none of them need rewriting.
|
||||
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasFoodKind {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasShareID, err := columnExists(db, "sessions", "share_id")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -636,6 +770,14 @@ type exerciseSyncResponse struct {
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
}
|
||||
|
||||
type foodKindSyncRequest struct {
|
||||
FoodKinds []FoodKind `json:"foodKinds"`
|
||||
}
|
||||
|
||||
type foodKindSyncResponse struct {
|
||||
FoodKinds []FoodKind `json:"foodKinds"`
|
||||
}
|
||||
|
||||
type cacheControlFS struct {
|
||||
root http.FileSystem
|
||||
}
|
||||
@@ -738,6 +880,7 @@ func main() {
|
||||
store := newStore(db)
|
||||
configStore := newConfigStore(db)
|
||||
exerciseStore := newExerciseStore(db)
|
||||
foodKindStore := newFoodKindStore(db)
|
||||
pedigrees := newPedManager(db)
|
||||
|
||||
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
||||
@@ -844,6 +987,36 @@ func main() {
|
||||
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
|
||||
}))
|
||||
|
||||
// POST /api/foodkinds/sync — the same contract again for the food kinds a
|
||||
// meal can be labelled with.
|
||||
mux.HandleFunc("/api/foodkinds/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req foodKindSyncRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// The library is the owner's, exactly as the exercise list is: a guest
|
||||
// labels a meal with a kind that exists, but does not invent, rename or
|
||||
// delete one. They still receive the full set, so the picker works.
|
||||
incoming := req.FoodKinds
|
||||
if isGuest(r) {
|
||||
incoming = nil
|
||||
}
|
||||
merged, err := foodKindStore.sync(userID(r), incoming)
|
||||
if err != nil {
|
||||
log.Printf("food kinds sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(foodKindSyncResponse{FoodKinds: merged})
|
||||
}))
|
||||
|
||||
// GET /api/config — return the caller's puppy profile.
|
||||
// PUT /api/config — update it (last-write-wins by updatedAt).
|
||||
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+1237
-102
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,21 @@
|
||||
[
|
||||
{ "date": "2026-09-22", "text": "Tap a bar in the Food (grams) chart and a line under it spells that day out — “Sat, Sep 20 — 340 g”, or “Dry 260 g · Fresh 100 g · 360 g in total” once you are using kinds. A phone has nothing to hover over, so the amount for a given day was previously only readable by eye off the axis. It follows whichever day is highlighted, so the ← → arrows and the date picker move it too, and it says so plainly when a day has no food logged or is marked as not counted" },
|
||||
{ "date": "2026-09-22", "text": "Meals can be labelled with a kind of food. Make up your own in Settings → Food kinds — dry, fresh, raw, whatever you feed — and pick one when you log a meal; tap the ★ beside one to have it chosen for you automatically. You can also invent a kind from inside the log dialog if you realise you need it mid-meal. All of it is optional: “No kind” is always offered, every meal you have already logged keeps working untouched, and with no kinds defined the app looks and behaves exactly as it did. Once you are using them, today's overview shows the day's food broken down — “Dry 260 g · Fresh 100 g” — under the stat tiles, and the Food (grams) chart splits each day's bar by kind with a legend, and draws a separate trend line for each, so you can see fresh creeping up while dry comes down. The sentence underneath names only the kinds that are actually moving and folds the rest into one clause, so it stays short however many kinds you have. Renaming a kind updates the meals logged as it; deleting one keeps them readable under the name it had. A guest can label a meal with a kind you have created but cannot add, rename or delete them" },
|
||||
{ "date": "2026-09-21", "text": "Fixed the figures under the Food (grams) chart contradicting each other. It read like “down about 329 g a week — roughly 460 g a day then, 320 g a day now”, where subtracting the two amounts gives 140 g, not 329 g. The rate was worked out per week while the line itself only covers the complete days in the window — at most five of them on a 7-day window, since today isn't finished — so it was stretched past the days it was measured from. It now gives the change between the two ends, which is a figure you can check by subtracting them: “down about 140 g — from roughly 460 g a day to 320 g”" },
|
||||
{ "date": "2026-09-21", "text": "You can measure the time between two events. Press and hold one row, press and hold another, and a bar along the bottom shows the gap — “3h 42m · Ate 12:10 → Poo 15:52” — which answers things like how long after a meal he needs to go out. It stays there until you clear it with the ✕, so you can change day in between and pick the second event from another day; when the pair straddles midnight the bar shows the dates too. It works on any row that is a single moment: the history log, the notes log and weigh-ins. Holding a row you already picked unpicks it, and a third pick is ignored until you clear. Tapping a row still opens it for editing as before. One cost: because holding a row now means something, you can no longer select the text of a note to copy it" },
|
||||
{ "date": "2026-09-21", "text": "A day marked “not counted” no longer appears in the Sleep trend or the Walk trend. It was already left out of the average and out of the “yesterday” comparison, but the day you were actually looking at was still drawn as the boldest line on the chart — so the one day you had said not to trust was the one the panel led with. Now it is left off and its legend chip goes with it, leaving the average and yesterday, which is what you would want to see on a day like that" },
|
||||
{ "date": "2026-09-21", "text": "The Food (grams) chart has a trend line through it now, so you can see whether he is eating more as he grows — the daily bars bounce around enough to hide a steady climb. A line under the chart says what it amounts to in figures: “daily intake is up about 120 g — from roughly 280 g a day to 400 g”. When the day-to-day variation is bigger than any trend, which is most of the time over a short window, it says so and gives the average instead — that is a real measurement, where the ends of the line would only be the line's own guess. Today is left out of the line, since the day isn't finished and including it would drag the line down every morning; days marked “not counted” are skipped too. The line follows the 7 / 14 / 30 day picker like the rest of the charts, and the sentence names the window so you can see it change when you switch. If there aren't four complete days to fit it says so rather than leaving you with an empty chart, and if some meals have no amount recorded it says how many, because those days read lower than they really were" },
|
||||
{ "date": "2026-09-20", "text": "Fixed the page being wider than the screen on a phone, which is why it had started letting you zoom out. The month grid behind the date was the main culprit: it was centred on the date button, which sits near the right edge, so part of the panel hung off the side of the screen. It is anchored to the edge of the bar now and stays on screen at any width. Also fixed a long unbroken word — a link, or something copied off a food bag — in a history note, an exercise name or its instructions pushing its row wider than the screen instead of wrapping" },
|
||||
{ "date": "2026-09-20", "text": "Fixed three things that went wrong around a day marked “not counted”. The Timing panel measured “how long since the last pee” from before the marked day rather than from the actual last one, so the marker sat far out to the right. The Sleep and Walk trends drew the marked day's own curve as a flat zero when you were looking at that day — marking a day means don't let it drag the average, not pretend nothing happened on it. And a nap that started on a marked day and ended the next morning vanished from that next day's figures, even though the next day wasn't marked and the puppy really did sleep those hours" },
|
||||
{ "date": "2026-09-09", "text": "The big asleep/awake card at the top of Today is gone, and both timers now live permanently in the frozen bar at the top — visible on every tab, wherever you have scrolled to. The card only existed on one tab and the timers hid themselves whenever it was on screen, which meant the thing you most often want at a glance was the thing you had to go and find. With only one place left to show them they have their seconds back too" },
|
||||
{ "date": "2026-09-08", "text": "The date now opens a small month grid of the app's own instead of the browser's date picker. The browser's one is a sheet that covers the screen, which is backwards when the reason to change day is to see what the numbers did on it — this one sits under the bar with the overview still visible and updating as you move. ← and → still step a day at a time; the grid is for jumping further, and the Today button now lives inside it. That is what made room for the walk timer and the sleep timer to sit on one row: the top bar no longer splits onto two rows on a phone, except on the very smallest. The two timers count in minutes now rather than seconds — the big card on Today still ticks in seconds, which is where you look if you want them" },
|
||||
{ "date": "2026-09-08", "text": "A walk in progress now has a timer in the frozen bar at the top, next to the asleep/awake one, counting from when the walk started — so you can see how long you have been out from any tab without going to look. Tapping it ends the walk, the same way tapping the sleep timer logs the sleep boundary. On a narrow phone two timers no longer fit beside the date controls, so the bar splits onto two rows while a walk is on and goes back to one when it ends. The big timer card on Today shows the walk too while there is one — you are awake on a walk either way, so the walk is the more useful of the two" },
|
||||
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
|
||||
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
|
||||
{ "date": "2026-09-07", "text": "Switching tabs no longer jumps the page back to the top. The tab row is frozen to the top of the screen, so you change tabs from wherever you have scrolled to, and being thrown back past the log buttons you had just scrolled off was more disruptive than landing part-way down the new tab" },
|
||||
{ "date": "2026-09-07", "text": "The page is split into five tabs — Today, Sleep, Walks, Habits and Growth — instead of one long column of fourteen panels. Today has the overview, sleep & wake and the day's history; Sleep and Walks each have their day-by-day chart, their when-it-happens grid and their trend; Habits has the pee, poo and meal timing and counts; Growth has weight, training and notes. Getting to the weight curve no longer means scrolling past everything else. The day bar and the log buttons sit above the tabs and stay there whichever one you're on, so logging is still one tap from anywhere. Folding a panel by tapping its heading works exactly as before, inside its tab, and the app reopens on the tab you left it on. The back button (or the back gesture) returns you to Today from wherever you are, and pressing it again leaves the app — always two presses to get out, however much you'd been flicking between tabs beforehand" },
|
||||
{ "date": "2026-09-07", "text": "Guest links can be copied whenever you want. Settings → Guest access now shows each live link's URL next to it with a Copy button, instead of showing it once when you created it and never again. If you lose the message you sent, or want to pass the same link to someone else, you can just take it again rather than making a new one and leaving whoever already had the old one locked out. Links you made before this change can't be shown — only a scrambled form of those was kept — so revoke one and create a fresh one if you need its URL back" },
|
||||
{ "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" },
|
||||
{ "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" },
|
||||
{ "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" },
|
||||
{ "date": "2026-09-04", "text": "The three day-long charts — “By hour of day”, “When sleeping” and “When walking” — now mark the current time with a small vertical line and caret. On the sleeping and walking rows it also shows where today's row stops, and it lines the same clock position up across every day above it" },
|
||||
|
||||
+214
-105
@@ -106,32 +106,64 @@
|
||||
<p id="guest-banner" class="guest-banner" hidden></p>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="day-bar">
|
||||
<!-- Compact twin of the big timer below: invisible (but keeping its
|
||||
slot) while the big card is on screen, shown once it scrolls
|
||||
away. Hidden entirely until a sleep event exists. Tapping it logs
|
||||
the boundary that flips the current state (asleep → sleep end,
|
||||
awake → sleep start). -->
|
||||
<!-- Two groups, not seven loose children: on a narrow phone a running
|
||||
walk adds a second pill and the row no longer fits, so it wraps —
|
||||
and it has to wrap between the timers and the day controls rather
|
||||
than splitting the controls across two lines. -->
|
||||
<span class="bar-timers">
|
||||
<!-- The asleep/awake timer, and the only one there is: it used to be
|
||||
the compact twin of a big card on Today, shown once that scrolled
|
||||
away, but a timer you have to scroll to is no timer at all. It is
|
||||
frozen at the top instead, on every tab. Hidden until a sleep
|
||||
event exists. Tapping it logs the boundary that flips the current
|
||||
state (asleep → sleep end, awake → sleep start). -->
|
||||
<button type="button" id="bar-clock" class="bar-clock" hidden>
|
||||
<span id="bar-clock-icon" aria-hidden="true"></span>
|
||||
<span id="bar-clock-time"></span>
|
||||
</button>
|
||||
<!-- Only while a walk is running. Tapping it ends the walk, the same
|
||||
bargain the sleep pill offers. -->
|
||||
<button type="button" id="bar-walk" class="bar-clock walking" hidden>
|
||||
<span aria-hidden="true">🦮</span>
|
||||
<span id="bar-walk-time"></span>
|
||||
</button>
|
||||
</span>
|
||||
<span class="day-nav">
|
||||
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
||||
<!-- A browser won't let us shorten the text a native date input shows,
|
||||
so the face button carries a compact year-less date and the real
|
||||
input stays (visually hidden) as the value + native picker. -->
|
||||
<span class="day-date">
|
||||
<button type="button" id="day-date-face" class="ghost"></button>
|
||||
<!-- Opens the month grid below rather than the browser's own date
|
||||
picker: that one is a full-screen sheet on a phone, and the
|
||||
point of changing day here is watching the figures underneath
|
||||
change with it. The input stays as the value and is never
|
||||
shown — every read of the selected day still goes through it. -->
|
||||
<button type="button" id="day-date-face" class="ghost"
|
||||
aria-haspopup="dialog" aria-expanded="false"></button>
|
||||
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
|
||||
</span>
|
||||
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
||||
<button type="button" id="day-today" class="ghost">Today</button>
|
||||
</section>
|
||||
</span>
|
||||
|
||||
<section id="big-clock" class="big-clock" hidden>
|
||||
<div class="bc-label" id="bc-label">—</div>
|
||||
<div class="bc-time" id="bc-time">0:00</div>
|
||||
<div class="bc-since" id="bc-since"></div>
|
||||
<!-- A child of the bar rather than of the date button, even though it
|
||||
belongs to that button: positioned against the button it would be
|
||||
centred on something near the right edge, and a 268px panel would
|
||||
hang off the side of the screen — which makes the whole page wider
|
||||
than the viewport and lets a phone zoom out. The bar spans the
|
||||
content width, so anchoring to its edge can't leave the screen. -->
|
||||
<div id="day-cal" class="day-cal" role="dialog" aria-label="Pick a day" hidden>
|
||||
<div class="day-cal-head">
|
||||
<button type="button" id="cal-prev" class="ghost icon-btn" aria-label="Previous month">‹</button>
|
||||
<span id="cal-month" class="day-cal-month" aria-live="polite"></span>
|
||||
<button type="button" id="cal-next" class="ghost icon-btn" aria-label="Next month">›</button>
|
||||
</div>
|
||||
<div id="cal-weekdays" class="day-cal-weekdays" aria-hidden="true"></div>
|
||||
<div id="cal-grid" class="day-cal-grid" role="grid"></div>
|
||||
<button type="button" id="cal-today" class="ghost day-cal-today">Today</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quick-actions">
|
||||
@@ -162,6 +194,17 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Five tabs so each screen holds one subject rather than all
|
||||
fourteen panels in one column. The day bar and the log buttons stay
|
||||
above them: logging has to be one tap from wherever you are. -->
|
||||
<nav class="tabs" role="tablist" aria-label="Sections">
|
||||
<button type="button" class="tab" role="tab" data-tab="today" id="tab-today" aria-controls="tabpanel-today" aria-selected="false" tabindex="-1">Today</button>
|
||||
<button type="button" class="tab" role="tab" data-tab="sleep" id="tab-sleep" aria-controls="tabpanel-sleep" aria-selected="false" tabindex="-1">Sleep</button>
|
||||
<button type="button" class="tab" role="tab" data-tab="walks" id="tab-walks" aria-controls="tabpanel-walks" aria-selected="false" tabindex="-1">Walks</button>
|
||||
<button type="button" class="tab" role="tab" data-tab="habits" id="tab-habits" aria-controls="tabpanel-habits" aria-selected="false" tabindex="-1">Habits</button>
|
||||
<button type="button" class="tab" role="tab" data-tab="growth" id="tab-growth" aria-controls="tabpanel-growth" aria-selected="false" tabindex="-1">Growth</button>
|
||||
</nav>
|
||||
|
||||
<!-- Page-level, not a panel's: every panel below that covers more than
|
||||
one day reads this, so it sits on its own row above them all rather
|
||||
than inside one of them, where it read as that panel's own control
|
||||
@@ -175,17 +218,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="training" data-panel="training">
|
||||
<h2>Training</h2>
|
||||
<ul id="training-list" class="training-list"></ul>
|
||||
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
|
||||
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
|
||||
<div class="chart training-chart" id="training-chart-wrap" hidden>
|
||||
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
|
||||
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tab-panel" data-tab="today" id="tabpanel-today" role="tabpanel" aria-labelledby="tab-today" hidden>
|
||||
<section class="overview" data-panel="overview">
|
||||
<!-- The toggle lives here rather than in the day bar: that bar is held
|
||||
to one row on small phones and a sixth control would break it,
|
||||
@@ -232,6 +265,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The day's food broken down by kind. Its own line rather than
|
||||
inside the Meals tile: that tile is about 90px wide, and a
|
||||
breakdown of two or three kinds will not sit in it. Hidden unless
|
||||
a meal that day actually carries a kind. -->
|
||||
<p id="stat-food-kinds" class="muted-note food-kind-split" hidden></p>
|
||||
|
||||
<div class="lasts">
|
||||
<div class="last-row"><span>Last pee</span><span id="last-pee">—</span></div>
|
||||
<div class="last-row"><span>Last poo</span><span id="last-poo">—</span></div>
|
||||
@@ -241,6 +280,87 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sleep and wake windows are the same boundaries read two ways — a wake
|
||||
window is exactly the gap between two sleeps — so they interleave into
|
||||
one alternating list rather than sitting in two panels that each show
|
||||
half the day. Every row carries its state; the open one keeps the
|
||||
highlight. -->
|
||||
<section class="sleepwake" data-panel="sleep-wake">
|
||||
<h2>Sleep & wake</h2>
|
||||
<ul id="sleep-wake-list" class="wake-list"></ul>
|
||||
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
|
||||
</section>
|
||||
|
||||
<section class="history" data-panel="history">
|
||||
<h2>History</h2>
|
||||
<ul id="event-list" class="event-list"></ul>
|
||||
<p id="empty-state" class="empty">No events logged for this day.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" data-tab="sleep" id="tabpanel-sleep" role="tabpanel" aria-labelledby="tab-sleep" hidden>
|
||||
<section class="patterns" data-panel="sleep-daily">
|
||||
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<div class="chart">
|
||||
<div class="chart-title">Hours per day</div>
|
||||
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-timeline">
|
||||
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
|
||||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. The marker is now. Tap a row to open that day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-trend">
|
||||
<h2>Sleep trend</h2>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg trend-today" id="legend-trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||||
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" data-tab="walks" id="tabpanel-walks" role="tabpanel" aria-labelledby="tab-walks" hidden>
|
||||
<section class="walks" data-panel="walks">
|
||||
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
|
||||
<ul id="walk-list" class="wake-list"></ul>
|
||||
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
|
||||
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
|
||||
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
|
||||
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The two walk patterns mirror the sleep ones on the Sleep tab, drawn
|
||||
from walk windows instead of sleep windows. Both stay hidden until
|
||||
there is a walk to draw, so they cost nothing to anyone not
|
||||
tracking walks. -->
|
||||
<section class="patterns" data-panel="walk-timeline" hidden>
|
||||
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
|
||||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. The marker is now. Tap a row to open that day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="walk-trend" hidden>
|
||||
<h2>Walk trend</h2>
|
||||
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, and the recent average"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg wtrend-today" id="legend-wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
|
||||
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" data-tab="habits" id="tabpanel-habits" role="tabpanel" aria-labelledby="tab-habits" hidden>
|
||||
<section class="timing" data-panel="timing">
|
||||
<h2>Timing <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<!-- One range chart per type, drawn by drawTimingChart: the band spans
|
||||
@@ -268,44 +388,6 @@
|
||||
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap in the window.</p>
|
||||
</section>
|
||||
|
||||
<!-- Sleep and wake windows are the same boundaries read two ways — a wake
|
||||
window is exactly the gap between two sleeps — so they interleave into
|
||||
one alternating list rather than sitting in two panels that each show
|
||||
half the day. Every row carries its state; the open one keeps the
|
||||
highlight. -->
|
||||
<section class="sleepwake" data-panel="sleep-wake">
|
||||
<h2>Sleep & wake</h2>
|
||||
<ul id="sleep-wake-list" class="wake-list"></ul>
|
||||
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-daily">
|
||||
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<div class="chart">
|
||||
<div class="chart-title">Hours per day</div>
|
||||
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-timeline">
|
||||
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
|
||||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. The marker is now. Tap a row to open that day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-trend">
|
||||
<h2>Sleep trend</h2>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||||
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
|
||||
</section>
|
||||
|
||||
<!-- One panel for the three views of the same events: how many a day,
|
||||
how much food went with them, and what hours they fall in. -->
|
||||
<section class="patterns" data-panel="counts">
|
||||
@@ -321,7 +403,16 @@
|
||||
</div>
|
||||
<div class="chart" id="grams-chart-wrap" hidden>
|
||||
<div class="chart-title">Food (grams)</div>
|
||||
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
|
||||
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day, split by kind, with a trend line through each"></svg>
|
||||
<div id="grams-legend" class="legend" hidden></div>
|
||||
<!-- The highlighted day, broken down. Tapping a bar selects that
|
||||
day (as it does on every chart), so this is what the selection
|
||||
amounts to here — there is no hover on a phone, and the bar's
|
||||
tooltip is unreachable. -->
|
||||
<p id="grams-day-info" class="muted-note food-day-info" aria-live="polite" hidden></p>
|
||||
<!-- What the trend line says, and when it is not saying anything —
|
||||
see renderFoodTrendNote. -->
|
||||
<p id="grams-note" class="muted-note" hidden></p>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<div class="chart-title">By hour of day</div>
|
||||
@@ -330,37 +421,9 @@
|
||||
<p class="muted-note">Darker = happens more often at that hour; the marker is now.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="walks" data-panel="walks">
|
||||
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
|
||||
<ul id="walk-list" class="wake-list"></ul>
|
||||
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
|
||||
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
|
||||
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
|
||||
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The two walk patterns mirror the sleep ones below, drawn from walk
|
||||
windows instead of sleep windows. Both stay hidden until there is a
|
||||
walk to draw, so they cost nothing to anyone not tracking walks. -->
|
||||
<section class="patterns" data-panel="walk-timeline" hidden>
|
||||
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
|
||||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. The marker is now. Tap a row to open that day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="walk-trend" hidden>
|
||||
<h2>Walk trend</h2>
|
||||
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, and the recent average"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
|
||||
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
|
||||
</section>
|
||||
|
||||
<div class="tab-panel" data-tab="growth" id="tabpanel-growth" role="tabpanel" aria-labelledby="tab-growth" hidden>
|
||||
<section class="weight" data-panel="weight">
|
||||
<h2>Weight</h2>
|
||||
<div class="weight-summary">
|
||||
@@ -388,17 +451,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="training" data-panel="training">
|
||||
<h2>Training</h2>
|
||||
<ul id="training-list" class="training-list"></ul>
|
||||
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
|
||||
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
|
||||
<div class="chart training-chart" id="training-chart-wrap" hidden>
|
||||
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
|
||||
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notes-log" data-panel="notes">
|
||||
<h2>Notes</h2>
|
||||
<ul id="notes-list" class="event-list"></ul>
|
||||
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
|
||||
</section>
|
||||
|
||||
<section class="history" data-panel="history">
|
||||
<h2>History</h2>
|
||||
<ul id="event-list" class="event-list"></ul>
|
||||
<p id="empty-state" class="empty">No events logged for this day.</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
@@ -489,6 +558,25 @@
|
||||
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
|
||||
</div>
|
||||
|
||||
<!-- The food kinds a meal can be labelled with. Owner-only, like the
|
||||
exercise library. Empty by default and entirely optional: an
|
||||
account with no kinds never sees a picker when logging. -->
|
||||
<div id="food-kinds-section">
|
||||
<hr class="settings-sep" />
|
||||
<h4 class="settings-subhead">Food kinds</h4>
|
||||
<p class="settings-hint">
|
||||
Label a meal with the sort of food it was — dry, fresh, whatever you
|
||||
feed. Optional: with none defined, nothing changes, and “No kind”
|
||||
stays available even once you have some.
|
||||
</p>
|
||||
<ul id="food-kind-list" class="food-kind-list"></ul>
|
||||
<p id="food-kind-empty" class="settings-hint">No kinds yet.</p>
|
||||
<div class="kind-new">
|
||||
<input type="text" id="food-kind-name" maxlength="30" autocomplete="off" placeholder="e.g. Dry" />
|
||||
<button type="button" id="food-kind-add" class="ghost">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guest links: hand a dog sitter a URL that logs events on this
|
||||
account without giving them the password. Owner-only. -->
|
||||
<div id="guest-access">
|
||||
@@ -510,14 +598,6 @@
|
||||
<button type="button" id="guest-create" class="ghost">Create link</button>
|
||||
<p id="guest-error" class="auth-error" hidden></p>
|
||||
|
||||
<!-- The URL is shown once, here, and never again: only its hash is
|
||||
stored, so it cannot be read back later. -->
|
||||
<div id="guest-new" class="guest-new" hidden>
|
||||
<p class="settings-hint">Copy it now — for safety it isn't shown again.</p>
|
||||
<code id="guest-new-url" class="guest-url"></code>
|
||||
<button type="button" id="guest-copy" class="ghost">Copy link</button>
|
||||
</div>
|
||||
|
||||
<ul id="guest-list" class="guest-list"></ul>
|
||||
<p id="guest-empty" class="settings-hint">No active links.</p>
|
||||
</div>
|
||||
@@ -587,6 +667,18 @@
|
||||
<label id="note-grams-field" hidden>Amount (g)
|
||||
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
|
||||
</label>
|
||||
<!-- Which sort of food. Only on a meal, and only once at least one kind
|
||||
exists: someone who never defines one should never see it. "No
|
||||
kind" is always an option and is where everyone starts. The chips
|
||||
are built by renderKindPicker. -->
|
||||
<div id="note-kind-field" class="kind-field" hidden>
|
||||
<span class="kind-label">Kind</span>
|
||||
<div id="note-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
|
||||
<div id="note-kind-new" class="kind-new" hidden>
|
||||
<input type="text" id="note-kind-name" maxlength="30" autocomplete="off" placeholder="New kind, e.g. Fresh" />
|
||||
<button type="button" id="note-kind-add" class="ghost">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>Note
|
||||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||||
</label>
|
||||
@@ -624,6 +716,13 @@
|
||||
<label id="edit-grams-field" hidden>Amount (g)
|
||||
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
|
||||
</label>
|
||||
<!-- The same picker, so a meal's kind can be corrected after the fact.
|
||||
No "new kind" box here: inventing one belongs where you are
|
||||
logging, not where you are fixing a typo. -->
|
||||
<div id="edit-kind-field" class="kind-field" hidden>
|
||||
<span class="kind-label">Kind</span>
|
||||
<div id="edit-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
|
||||
</div>
|
||||
<label>Note
|
||||
<textarea id="edit-note" rows="4"></textarea>
|
||||
</label>
|
||||
@@ -646,6 +745,16 @@
|
||||
</dialog>
|
||||
|
||||
<!-- Brief confirmation after a one-tap quick log, with Undo / Add note. -->
|
||||
<!-- Long-press two event rows and this holds the time between them until
|
||||
you clear it — so you can change day in between and still be measuring.
|
||||
Fixed at the bottom like the snackbar, and stays put where that one
|
||||
fades; the snackbar lifts above it when both are on screen. -->
|
||||
<div id="measure-bar" class="measure-bar" hidden role="status" aria-live="polite">
|
||||
<span id="measure-duration" class="measure-duration"></span>
|
||||
<span id="measure-detail" class="measure-detail"></span>
|
||||
<button type="button" id="measure-clear" class="measure-clear" aria-label="Clear the measurement">✕</button>
|
||||
</div>
|
||||
|
||||
<div id="snackbar" class="snackbar" hidden role="status" aria-live="polite">
|
||||
<span id="snackbar-msg" class="snackbar-msg"></span>
|
||||
<button type="button" id="snackbar-note" class="snackbar-action">Add note</button>
|
||||
|
||||
+416
-72
@@ -69,6 +69,22 @@
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* The user agent's [hidden] { display: none } loses to any author rule that
|
||||
sets a display, so every element given one here went on rendering while
|
||||
hidden. This had been patched one selector at a time as each case was
|
||||
noticed — .auth-screen[hidden], .snackbar[hidden], dialog label[hidden] and
|
||||
seven more — which left the ones nobody had hit yet still broken: the 🌳
|
||||
pedigree button, the reminder rule rows, "send a test notification", the
|
||||
pedigree disambiguation list, and Delete in both the exercise and event
|
||||
dialogs. Stating it once replaces all ten of those rules and stops the next
|
||||
hidden element joining the list.
|
||||
|
||||
!important, because the offenders aren't all the same weight:
|
||||
`#reminders-rules { display: flex }` is an id selector and outranks any
|
||||
attribute selector we could write. Nothing should ever want a hidden element
|
||||
painted, so the blunt instrument is the right one here. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -148,6 +164,83 @@ main {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
/* Sticks directly under the day bar, whose measured height JS publishes as
|
||||
--day-bar-h (its contents, and so its height, differ between phones). The
|
||||
fallback keeps the bar usable for the frame before that lands. */
|
||||
.tabs {
|
||||
position: sticky;
|
||||
/* One pixel under the day bar's height rather than exactly it: the inset is
|
||||
free to be fractional while the measured height is a whole number, and a
|
||||
sub-pixel shortfall would show as a hairline of page ground between the
|
||||
two. The overlap is invisible — the day bar paints on top, same colour. */
|
||||
top: calc(env(safe-area-inset-top, 0px) + var(--day-bar-h, 52px) - 1px);
|
||||
z-index: 55; /* under the day bar, over the panels */
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
margin: -8px 0 0;
|
||||
/* Same card as the day bar rather than the page ground, so the two read as
|
||||
one thing once they are frozen together. */
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* At rest the two are separate cards with the log buttons between them. Scroll
|
||||
the log buttons away and they meet, so the seam goes: the day bar squares its
|
||||
bottom and gives up its shadow, the tab bar squares its top, and the pair
|
||||
sits inside one rounded frame casting one shadow. JS adds .merged the moment
|
||||
they touch — CSS has no way to ask whether a sticky element is stuck. */
|
||||
.day-bar.merged {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.tabs.merged {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
.tabs .tab {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
/* Five labels across a 360px phone: let them shrink rather than wrap. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs .tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tabs .tab:hover { filter: none; }
|
||||
|
||||
/* Five labels have to fit without truncating on the narrow phones, and the
|
||||
budget is tight: at a 320px viewport (iPhone SE, smaller Androids) the body's
|
||||
own 16px gutters, the bar's padding and four gaps leave about 45px of text
|
||||
per tab, while "Growth" — the widest label, roughly 3.3em — wants 46px at the
|
||||
default size. So the same breakpoint the day bar already uses trims the type
|
||||
and the padding, which takes the widest label to about 40px against 43px of
|
||||
room even on a 280px foldable cover screen. */
|
||||
@media (max-width: 370px) {
|
||||
.tabs { gap: 2px; padding-left: 2px; padding-right: 2px; }
|
||||
.tabs .tab { font-size: 0.75rem; padding-left: 2px; padding-right: 2px; }
|
||||
}
|
||||
|
||||
/* main's own gap sits between the pinned blocks and the tab area; each tab
|
||||
then spaces its own panels the same way. */
|
||||
.tab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
@@ -177,7 +270,13 @@ body::before {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
/* Wraps only when it has to — which on a narrow phone is while a walk is
|
||||
running and there are two timers to fit. The two groups above are what
|
||||
make that wrap land in a sensible place. The bar's height changes when it
|
||||
does, and the tab bar sticks to that height, so a ResizeObserver keeps
|
||||
--day-bar-h honest (see measureDayBar). */
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
/* The face button shows the short date; the real input sits invisibly behind
|
||||
@@ -194,6 +293,74 @@ body::before {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* ---------- the month grid ---------- */
|
||||
/* Anchored under the date button rather than filling the screen the way the
|
||||
browser's own picker does: changing day is worth doing *because* of the
|
||||
figures below, so they have to stay in view while you move. Deliberately
|
||||
kept short — six rows of small cells — for the same reason. */
|
||||
/* Positioned against the day bar, not the date button (see index.html). The
|
||||
bar is exactly the content width, so pinning the panel to its inner right
|
||||
edge and capping it at the bar's own width keeps it on screen at every size.
|
||||
Centring it on the button instead let it hang off the right of a phone,
|
||||
which widens the document and lets the page zoom out. */
|
||||
.day-cal {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 10px; /* the bar's own horizontal padding */
|
||||
z-index: 70; /* over the day bar itself, which is 60 */
|
||||
width: 268px;
|
||||
max-width: calc(100% - 20px);
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.day-cal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.day-cal-month {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.day-cal-weekdays,
|
||||
.day-cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.day-cal-weekdays span {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
padding: 2px 0;
|
||||
}
|
||||
button.cal-day {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 6px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
button.cal-day.other-month { color: var(--muted); opacity: 0.5; }
|
||||
button.cal-day:disabled { opacity: 0.25; cursor: default; }
|
||||
button.cal-day:disabled:hover { filter: none; }
|
||||
/* Today is outlined, the selected day is filled — so "where I am" and "where
|
||||
now is" stay tellable apart when they are different days. */
|
||||
button.cal-day.is-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
|
||||
button.cal-day.is-selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.day-cal-today { width: 100%; margin-top: 8px; padding: 7px 10px; font-size: 0.85rem; }
|
||||
|
||||
#day-date-face {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
@@ -216,60 +383,51 @@ body::before {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* The timers sit left, the day controls right. Both are groups so that when a
|
||||
running walk makes the row too long the bar wraps between them, rather than
|
||||
stranding "Today" on a line by itself. */
|
||||
.bar-timers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.day-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* An empty timer group must not hold a line open once it has wrapped. */
|
||||
.bar-timers:empty { display: none; }
|
||||
|
||||
.bar-clock {
|
||||
display: flex;
|
||||
/* It is a button (tapping it flips the sleep state), so undo the default
|
||||
accent look — the asleep/awake classes below paint it. */
|
||||
accent look — the asleep/awake/walking classes below paint it. */
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: auto; /* pin left; the flexible gap sits between it and the day controls */
|
||||
padding: 7px 10px;
|
||||
/* Sized so two of these fit beside the day controls on one row. These are
|
||||
the only timers now, so they are what has to be readable. */
|
||||
gap: 4px;
|
||||
padding: 7px 9px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bar-clock[hidden] { display: none; }
|
||||
/* Big timer still on screen: keep the pill's slot but show nothing. */
|
||||
.bar-clock.standby { visibility: hidden; }
|
||||
.bar-clock.asleep { background: color-mix(in srgb, var(--sleep) 18%, var(--surface)); color: var(--sleep-ink); }
|
||||
/* Dark text on the yellow, the way the pee button already does it: a gold
|
||||
light enough to read as sunshine is never legible as text on a pale ground.
|
||||
12% where asleep takes 18% — equal percentages of these two hues are not
|
||||
equally strong, and yellow at 18% shouted while the blue did not. */
|
||||
.bar-clock.awake { background: color-mix(in srgb, var(--wake) 12%, var(--surface)); color: var(--wake-timer-ink); }
|
||||
|
||||
.big-clock {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.big-clock .bc-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.big-clock .bc-time {
|
||||
font-size: 3.25rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.big-clock .bc-since {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.big-clock.asleep { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--sleep) 10%, var(--surface))); }
|
||||
.big-clock.asleep .bc-time { color: var(--sleep-ink); }
|
||||
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--wake) 10%, var(--surface))); }
|
||||
.big-clock.awake .bc-time { color: var(--wake-timer-ink); }
|
||||
/* The walk timer takes the walk colour the rest of the app already uses for
|
||||
walks, so the pill says which of the two it is without needing its label. */
|
||||
.bar-clock.walking { background: color-mix(in srgb, var(--walk) 16%, var(--surface)); color: var(--walk); }
|
||||
|
||||
/* Quick actions: a stack of explicit rows (see index.html) instead of one
|
||||
auto-fit grid, so the grouping is the same at every width. Equal columns
|
||||
@@ -572,7 +730,11 @@ textarea { resize: vertical; }
|
||||
|
||||
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
|
||||
.event .label { font-weight: 600; min-width: 110px; }
|
||||
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
|
||||
/* min-width:0 and a break rule, or a long unbroken word — a URL, a chemical
|
||||
name off a food bag — sets this flex item's content-based minimum and pushes
|
||||
the whole row wider than the screen. The Notes log's own text below already
|
||||
guards against it; the History row was missed. */
|
||||
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* Notes log rows: a date instead of a time-of-day, then the note text. */
|
||||
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
@@ -619,9 +781,6 @@ dialog::backdrop { background: rgba(0,0,0,0.4); }
|
||||
dialog h3 { margin: 0 0 12px; }
|
||||
|
||||
dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; }
|
||||
/* The display rule above beats the UA [hidden] rule, so hide explicitly —
|
||||
otherwise the weight/grams fields show on event types that don't use them. */
|
||||
dialog label[hidden] { display: none; }
|
||||
dialog label input, dialog label textarea { margin-top: 4px; }
|
||||
|
||||
dialog menu {
|
||||
@@ -647,7 +806,6 @@ dialog menu {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.photo-preview[hidden] { display: none; }
|
||||
.photo-thumb { position: relative; }
|
||||
.photo-thumb img {
|
||||
display: block;
|
||||
@@ -863,8 +1021,6 @@ dialog menu {
|
||||
background: var(--bg);
|
||||
z-index: 50;
|
||||
}
|
||||
/* The display rule above beats the UA [hidden] rule, so hide explicitly. */
|
||||
.auth-screen[hidden] { display: none; }
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
@@ -952,13 +1108,19 @@ button.linklike:hover { text-decoration: underline; filter: none; }
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.exclude-btn[hidden] { display: none; }
|
||||
|
||||
.excluded-note { margin: 8px 0 0; }
|
||||
/* Sits between the stat tiles and the "last X" rows, so it reads as a
|
||||
footnote to the Meals tile above it. */
|
||||
.food-kind-split { margin: -8px 0 14px; }
|
||||
/* Tied to the bar above it, so it sits closer to the chart than the trend
|
||||
caption below and takes the accent to read as "the highlighted one". */
|
||||
.food-day-info { margin: 6px 0 0; color: var(--accent); }
|
||||
|
||||
/* The day's own figures stay readable but visibly step back, so "this one is
|
||||
not in the numbers" is legible from the day as well as from the charts. */
|
||||
.overview.day-excluded .stats,
|
||||
.overview.day-excluded .food-kind-split,
|
||||
.overview.day-excluded .last-row { opacity: 0.55; }
|
||||
|
||||
/* The hatch every chart uses for a day that doesn't count. The pattern itself
|
||||
@@ -976,34 +1138,20 @@ button.linklike:hover { text-decoration: underline; filter: none; }
|
||||
}
|
||||
|
||||
/* ---------- guest links ---------- */
|
||||
/* The one-time URL. Shown once and never again, so it gets a box of its own
|
||||
rather than sitting inline where it could be missed. */
|
||||
.guest-new {
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.guest-new .settings-hint { margin: 0 0 6px; }
|
||||
.guest-url {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.guest-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* A link is two stacked rows: who and when on top, the URL underneath. */
|
||||
.guest-item {
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.guest-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.guest-item-main {
|
||||
flex: 1;
|
||||
@@ -1021,6 +1169,32 @@ button.linklike:hover { text-decoration: underline; filter: none; }
|
||||
}
|
||||
button.guest-revoke { color: var(--danger); flex: none; }
|
||||
|
||||
/* The URL, always available so the link can be re-sent. It is 90-odd characters
|
||||
of hex, so it gets one truncated line rather than wrapping into a wall of it
|
||||
— the Copy button is the way it is meant to be taken, and tapping the text
|
||||
selects the whole thing for anywhere the clipboard API is unavailable. */
|
||||
.guest-item-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.guest-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: text;
|
||||
}
|
||||
button.guest-copy {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Only a guest ever sees this, directly under the header. No bottom margin:
|
||||
main's own top padding provides the gap to the first panel. */
|
||||
.guest-banner {
|
||||
@@ -1032,7 +1206,6 @@ button.guest-revoke { color: var(--danger); flex: none; }
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.guest-banner[hidden] { display: none; }
|
||||
|
||||
/* Who logged an event, when it came in on a guest link. Small caps so it reads
|
||||
as a margin note against the event label rather than competing with it.
|
||||
@@ -1100,7 +1273,6 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.update-banner[hidden] { display: none; }
|
||||
.update-banner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1116,7 +1288,6 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
line-height: 1.45;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.update-changelog[hidden] { display: none; }
|
||||
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
@@ -1167,10 +1338,183 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
.update-banner-btn:hover { filter: brightness(0.97); }
|
||||
|
||||
/* ---------- quick-log snackbar ---------- */
|
||||
.snackbar {
|
||||
/* ---------- food kinds ---------- */
|
||||
/* A palette of its own. The app's semantic colours are spoken for — --pee
|
||||
yellow on a food bar would actively mislead — so kinds get six hues that sit
|
||||
around --eat's orange and stay apart from each other. A kind keeps its index
|
||||
for life (see addFoodKind), so a deletion never repaints old charts; beyond
|
||||
six, kinds share, which is a gentler failure than running out. */
|
||||
:root {
|
||||
--food-0: #ff9b3d;
|
||||
--food-1: #2bb3a3;
|
||||
--food-2: #b04ecf;
|
||||
--food-3: #3f9e63;
|
||||
--food-4: #e0603c;
|
||||
--food-5: #5a7fd6;
|
||||
}
|
||||
[data-color="0"] { --food-color: var(--food-0); }
|
||||
[data-color="1"] { --food-color: var(--food-1); }
|
||||
[data-color="2"] { --food-color: var(--food-2); }
|
||||
[data-color="3"] { --food-color: var(--food-3); }
|
||||
[data-color="4"] { --food-color: var(--food-4); }
|
||||
[data-color="5"] { --food-color: var(--food-5); }
|
||||
|
||||
/* A stacked segment, and the dashed fit through that kind's own amounts. Both
|
||||
take the kind's colour from the data-color attribute set on the element. */
|
||||
.chart-svg .bar-food-kind { fill: var(--food-color, var(--muted)); }
|
||||
.chart-svg .food-trend {
|
||||
stroke: var(--food-color, var(--weight));
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 3;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
}
|
||||
.legend .sw.food-sw { background: var(--food-color, var(--muted)); }
|
||||
|
||||
.kind-field { margin-bottom: 10px; }
|
||||
.kind-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.kind-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
button.kind-chip {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
/* Names are free text, so a long one wraps the row rather than the chip. */
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* The chosen chip fills with its own colour; "No kind" has none and falls back
|
||||
to the accent, so it reads as a choice rather than as a colourless gap. */
|
||||
button.kind-chip.active {
|
||||
background: var(--food-color, var(--accent));
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kind-new {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.kind-new input { flex: 1; min-width: 0; }
|
||||
.kind-new button { flex: none; padding: 8px 14px; }
|
||||
|
||||
.food-kind-list {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
.food-kind-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.food-kind-swatch {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--food-color, var(--muted));
|
||||
}
|
||||
input.food-kind-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto; /* the global input rule sets 100%, which would push the row */
|
||||
}
|
||||
button.food-kind-default {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 4px 6px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
button.food-kind-default.active { color: var(--wake); }
|
||||
button.food-kind-delete { color: var(--danger); flex: none; }
|
||||
|
||||
/* ---------- measuring between two events ---------- */
|
||||
/* Fixed at the bottom, near the thumb, and it stays until cleared — the
|
||||
measurement is the answer to a question you asked, not a notification. */
|
||||
.measure-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0));
|
||||
transform: translateX(-50%);
|
||||
z-index: 59; /* just under the snackbar, which lifts above it */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 32px);
|
||||
padding: 8px 8px 8px 14px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.measure-duration:empty { display: none; }
|
||||
.measure-duration {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
/* The pair can be long ("Ate Sep 19 18:30 → Poo Sep 20 07:10"), and the
|
||||
duration and the clear button are what must never be squeezed out. */
|
||||
.measure-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.measure-clear {
|
||||
flex: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* A picked row. The accent ring rather than a fill, so the row's own type
|
||||
colour (its dot and any rail) still reads underneath. */
|
||||
.event.picked {
|
||||
box-shadow: inset 0 0 0 2px var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Long-press means "pick this" on these rows, so the platform's own
|
||||
long-press behaviour has to get out of the way: iOS would otherwise raise
|
||||
the text-selection callout over the row mid-press. The cost is that note
|
||||
text on a row can no longer be selected to copy. */
|
||||
.event {
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snackbar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
/* Above the measure bar when one is up, so the two never overlap. Its height
|
||||
is published by a ResizeObserver, the same trick --day-bar-h uses. */
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0) + var(--measure-bar-h, 0px));
|
||||
transform: translate(-50%, 12px);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
@@ -1187,7 +1531,6 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.snackbar[hidden] { display: none; }
|
||||
.snackbar.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
@@ -1303,7 +1646,6 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
.lg.wtrend-today .sw { background: var(--walk); }
|
||||
.lg.wtrend-yesterday .sw { background: var(--eat); }
|
||||
.lg.wtrend-avg .sw { background: var(--weight); }
|
||||
.lg[hidden] { display: none; }
|
||||
|
||||
.chart-svg .hm-cell { stroke: none; }
|
||||
/* Focused block: an accent ring, drawn at full opacity so it stays visible on
|
||||
@@ -1351,7 +1693,7 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ex-name { font-weight: 600; }
|
||||
.ex-name { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.ex-meta { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
button.ex-log {
|
||||
@@ -1372,11 +1714,13 @@ button.ex-log {
|
||||
|
||||
.ex-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }
|
||||
|
||||
Reference in New Issue
Block a user