Compare commits
5
Commits
556e4d75a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9ea45410 | ||
|
|
3584758b86 | ||
|
|
18d3778241 | ||
|
|
5a08fb4510 | ||
|
|
9e47aa53ff |
@@ -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
|
||||
|
||||
@@ -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");
|
||||
+222
-8
@@ -47,7 +47,7 @@ suite("today is left out, being half-eaten");
|
||||
// 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.perWeek), 0, "a flat run stays flat despite today being low");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ 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.perWeek), 350, "…at 50 g a day, which is 350 g a week");
|
||||
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.perWeek < 0, "…with a negative weekly change");
|
||||
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]));
|
||||
@@ -83,20 +83,39 @@ 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.perWeek), 350, "the climb is unchanged by the marked day");
|
||||
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) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), 14);
|
||||
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 350 g a week/.test(rising), "a clear climb gives the rate");
|
||||
ok(/200 g a day then/.test(rising) && /450 g a day now/.test(rising),
|
||||
"…and the figures at each end of the line, so it is not only a rate");
|
||||
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),
|
||||
@@ -118,4 +137,199 @@ suite("what the sentence is allowed to say");
|
||||
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");
|
||||
|
||||
@@ -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 = ?`,
|
||||
|
||||
@@ -680,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)
|
||||
|
||||
+166
-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 '',
|
||||
@@ -489,6 +599,17 @@ func migrateSchema(db *sql.DB) error {
|
||||
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
|
||||
@@ -649,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
|
||||
}
|
||||
@@ -751,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")
|
||||
@@ -857,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) {
|
||||
|
||||
+554
-28
@@ -24,6 +24,7 @@
|
||||
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
|
||||
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
|
||||
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
|
||||
const foodKindsKey = () => `puppy-tracker:${currentUser.id}:foodkinds:v1`;
|
||||
const SYNC_URL = "api/events/sync";
|
||||
const SYNC_DEBOUNCE_MS = 1200;
|
||||
const SYNC_POLL_MS = 60_000;
|
||||
@@ -340,7 +341,7 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
|
||||
function addEvent(type, note, at, { photoId, weight, grams, exerciseId, foodKindId } = {}) {
|
||||
const events = loadAll();
|
||||
const now = Date.now();
|
||||
const ev = {
|
||||
@@ -352,6 +353,8 @@
|
||||
weight: Number.isFinite(weight) ? weight : undefined,
|
||||
grams: Number.isFinite(grams) ? grams : undefined,
|
||||
exerciseId: exerciseId || "",
|
||||
// "" is a real value here — no kind — not a missing one.
|
||||
foodKindId: foodKindId || NO_KIND,
|
||||
updatedAt: now,
|
||||
// Only set when logging through a guest link, and only so the badge and
|
||||
// the "you may edit this" check work before the first sync: the server
|
||||
@@ -444,6 +447,167 @@
|
||||
return new Map(loadExercises().map(x => [x.id, x.name]));
|
||||
}
|
||||
|
||||
// The day's food, split the way the chart splits the window. The Meals tile
|
||||
// keeps the total — that is the headline figure, and the breakdown will not
|
||||
// fit in a 90px tile — so this sits under the grid instead.
|
||||
//
|
||||
// Only shown when a meal that day actually carries a kind: an account not
|
||||
// using kinds should see the overview it has always seen, and a day whose
|
||||
// meals all predate kinds is exactly that case.
|
||||
function renderDayFoodKinds(dayEvents) {
|
||||
const el = document.getElementById("stat-food-kinds");
|
||||
if (!el) return;
|
||||
const names = foodKindNames();
|
||||
const order = liveFoodKinds().map(k => k.id);
|
||||
const totals = new Map();
|
||||
let anyKind = false;
|
||||
for (const e of dayEvents) {
|
||||
if (e.type !== "eat" || !(Number.isFinite(e.grams) && e.grams > 0)) continue;
|
||||
const id = e.foodKindId || NO_KIND;
|
||||
if (id !== NO_KIND) anyKind = true;
|
||||
totals.set(id, (totals.get(id) || 0) + e.grams);
|
||||
}
|
||||
if (!anyKind) { el.hidden = true; return; }
|
||||
|
||||
// Same order as the chart's layers, with "No kind" last, so the two read
|
||||
// as the same breakdown rather than two arbitrary lists.
|
||||
const ids = [...totals.keys()].sort((a, b) => {
|
||||
if (a === NO_KIND) return 1;
|
||||
if (b === NO_KIND) return -1;
|
||||
const ia = order.indexOf(a), ib = order.indexOf(b);
|
||||
return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib);
|
||||
});
|
||||
el.textContent = ids
|
||||
.map(id => `${id === NO_KIND ? "No kind" : (names.get(id) || "Deleted kind")} ${Math.round(totals.get(id))} g`)
|
||||
.join(" · ");
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
// The chips both dialogs use. Built from the live kinds plus "No kind",
|
||||
// which always comes last and is always offered — a meal is allowed to have
|
||||
// no kind, permanently, and the picker should never imply otherwise.
|
||||
//
|
||||
// Hidden entirely when no kinds are defined, so the app looks exactly as it
|
||||
// did to anyone who never wants them. onPick receives the chosen id.
|
||||
function renderKindPicker(pickerEl, fieldEl, selectedId, onPick) {
|
||||
const kinds = liveFoodKinds();
|
||||
fieldEl.hidden = kinds.length === 0;
|
||||
if (kinds.length === 0) return;
|
||||
|
||||
pickerEl.innerHTML = "";
|
||||
const chip = (id, name, colorIndex) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "kind-chip" + (id === selectedId ? " active" : "");
|
||||
if (id !== NO_KIND) b.dataset.color = String(colorIndex % FOOD_COLORS);
|
||||
b.setAttribute("role", "radio");
|
||||
b.setAttribute("aria-checked", String(id === selectedId));
|
||||
b.textContent = name;
|
||||
b.addEventListener("click", () => onPick(id));
|
||||
pickerEl.appendChild(b);
|
||||
};
|
||||
for (const k of kinds) chip(k.id, k.name, k.colorIndex ?? 0);
|
||||
chip(NO_KIND, "No kind", 0);
|
||||
}
|
||||
|
||||
// ---------- food kinds ----------
|
||||
// A meal can be labelled with a sort of food the user names themselves
|
||||
// ("Dry", "Fresh"). The same collection contract as exercises: uuid ids, LWW
|
||||
// on updatedAt, tombstoned deletes so a meal logged against a kind that has
|
||||
// since been deleted still shows what it was.
|
||||
//
|
||||
// No kind is a real answer, not a missing one. Every meal logged before this
|
||||
// existed has no kind, and nobody is made to invent a taxonomy before they
|
||||
// can record that the dog ate — so "" is a first-class value throughout, and
|
||||
// an app with no kinds defined behaves exactly as it did.
|
||||
const NO_KIND = "";
|
||||
// How many colours the palette holds before it wraps (see --food-N in the
|
||||
// stylesheet). Kinds beyond this share a colour, which is a far better
|
||||
// failure than running out of chart.
|
||||
const FOOD_COLORS = 6;
|
||||
|
||||
function loadFoodKinds() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(foodKindsKey()));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFoodKinds(list) {
|
||||
localStorage.setItem(foodKindsKey(), JSON.stringify(list));
|
||||
}
|
||||
|
||||
// Creation order, not alphabetical: it matches the palette (colorIndex is
|
||||
// assigned in the same order) and keeps the stack's layers from reshuffling
|
||||
// under you when a kind is renamed.
|
||||
function liveFoodKinds() {
|
||||
return loadFoodKinds()
|
||||
.filter(k => !k.deleted)
|
||||
.sort((a, b) => (a.colorIndex ?? 0) - (b.colorIndex ?? 0));
|
||||
}
|
||||
|
||||
function addFoodKind(name) {
|
||||
const list = loadFoodKinds();
|
||||
// Counted over every kind ever, tombstones included, so deleting one never
|
||||
// shifts the colour of another and silently repaints old charts.
|
||||
const colorIndex = list.length;
|
||||
list.push({ id: uuid(), name, colorIndex, isDefault: false, updatedAt: Date.now() });
|
||||
saveFoodKinds(list);
|
||||
scheduleSync();
|
||||
render();
|
||||
return list[list.length - 1];
|
||||
}
|
||||
|
||||
function updateFoodKind(id, patch) {
|
||||
saveFoodKinds(loadFoodKinds().map(k =>
|
||||
k.id === id ? { ...k, ...patch, updatedAt: Date.now() } : k
|
||||
));
|
||||
scheduleSync();
|
||||
render();
|
||||
}
|
||||
|
||||
function deleteFoodKind(id) {
|
||||
// Tombstone, like an exercise: meals keep referencing the id and
|
||||
// foodKindNames still resolves it, so history stays readable.
|
||||
saveFoodKinds(loadFoodKinds().map(k =>
|
||||
k.id === id ? { ...k, deleted: true, updatedAt: Date.now() } : k
|
||||
));
|
||||
scheduleSync();
|
||||
render();
|
||||
}
|
||||
|
||||
// Exactly one kind is the default, or none — in which case new meals start
|
||||
// with no kind, which is also where everyone starts. Passing NO_KIND clears
|
||||
// it. Every other kind is unflagged in the same pass, so two devices that
|
||||
// each set a different default converge on one rather than showing two.
|
||||
function setDefaultFoodKind(id) {
|
||||
const now = Date.now();
|
||||
saveFoodKinds(loadFoodKinds().map(k => {
|
||||
const shouldBe = k.id === id;
|
||||
return k.isDefault === shouldBe ? k : { ...k, isDefault: shouldBe, updatedAt: now };
|
||||
}));
|
||||
scheduleSync();
|
||||
render();
|
||||
}
|
||||
|
||||
// Which kind a new meal starts with. Newest flag wins, so a sync race between
|
||||
// two devices that each chose a default resolves rather than picking at
|
||||
// random; no flag at all means no kind.
|
||||
function defaultFoodKindId() {
|
||||
const flagged = liveFoodKinds().filter(k => k.isDefault);
|
||||
if (flagged.length === 0) return NO_KIND;
|
||||
return flagged.reduce((a, b) => ((b.updatedAt || 0) > (a.updatedAt || 0) ? b : a)).id;
|
||||
}
|
||||
|
||||
// id -> name across *all* kinds, tombstones included, for the same reason
|
||||
// exerciseNames keeps them: a deleted kind's meals should still say what
|
||||
// they were.
|
||||
function foodKindNames() {
|
||||
return new Map(loadFoodKinds().map(k => [k.id, k.name]));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function ymd(date) {
|
||||
const y = date.getFullYear();
|
||||
@@ -770,6 +934,7 @@
|
||||
const gramsEl = document.getElementById("stat-meals-grams");
|
||||
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
|
||||
gramsEl.hidden = !(gramsTotal > 0);
|
||||
renderDayFoodKinds(dayEvents);
|
||||
// Walk time is a duration, not a count, so the tile leads with it and puts
|
||||
// "N walks" underneath — the day's exercise at a glance.
|
||||
document.getElementById("stat-walk").textContent = formatDuration(walkMsInRange(events, from, to));
|
||||
@@ -1304,6 +1469,7 @@
|
||||
const rails = historyRails(events, chronological, day);
|
||||
const dayEvents = [...chronological].reverse();
|
||||
const exNames = exerciseNames();
|
||||
const kindNames = foodKindNames();
|
||||
eventList.innerHTML = "";
|
||||
if (dayEvents.length === 0) {
|
||||
emptyState.hidden = false;
|
||||
@@ -1339,9 +1505,14 @@
|
||||
const noteEl = li.querySelector(".note");
|
||||
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
|
||||
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
|
||||
} else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
|
||||
const g = `${Math.round(ev.grams)} g`;
|
||||
noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
|
||||
} else if (ev.type === "eat") {
|
||||
// Amount and kind are both optional, so the row shows whichever it has.
|
||||
const bits = [];
|
||||
if (Number.isFinite(ev.grams) && ev.grams > 0) bits.push(`${Math.round(ev.grams)} g`);
|
||||
const kindName = ev.foodKindId ? kindNames.get(ev.foodKindId) : "";
|
||||
if (kindName) bits.push(kindName);
|
||||
if (ev.note) bits.push(ev.note);
|
||||
noteEl.textContent = bits.join(" · ");
|
||||
} else {
|
||||
noteEl.textContent = ev.note || "";
|
||||
}
|
||||
@@ -1515,6 +1686,16 @@
|
||||
// leaving the reader to assume the first.
|
||||
mealsMissingGrams: dayEvents
|
||||
.filter(e => e.type === "eat" && !(Number.isFinite(e.grams) && e.grams > 0)).length,
|
||||
// The same total, split by kind. The plain `grams` above stays: it is
|
||||
// still what the axis, the tooltip and the day's overview want, and
|
||||
// keeping both means the split can never disagree with the total.
|
||||
gramsByKind: dayEvents
|
||||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||
.reduce((acc, e) => {
|
||||
const id = e.foodKindId || NO_KIND;
|
||||
acc[id] = (acc[id] || 0) + e.grams;
|
||||
return acc;
|
||||
}, {}),
|
||||
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
|
||||
});
|
||||
}
|
||||
@@ -1734,12 +1915,14 @@
|
||||
// up over the day — a moving line that reflects the clock rather than the
|
||||
// puppy. The line is drawn only across the days it was fitted on, so it never
|
||||
// implies it knows about the ones it skipped.
|
||||
function foodTrend(days) {
|
||||
// valueOf picks which figure to fit: the day total by default, or one
|
||||
// kind's share of it when the bars are split.
|
||||
function foodTrend(days, valueOf = (d) => d.grams) {
|
||||
const pts = [];
|
||||
days.forEach((d, i) => {
|
||||
if (d.excluded) return;
|
||||
if (i === days.length - 1) return; // today, still being eaten
|
||||
pts.push({ x: i, y: d.grams });
|
||||
pts.push({ x: i, y: valueOf(d) });
|
||||
});
|
||||
// Two points always fit a line perfectly and say nothing; four is the least
|
||||
// that can show a direction rather than a coincidence.
|
||||
@@ -1765,7 +1948,11 @@
|
||||
return {
|
||||
at: (i) => slope * i + intercept,
|
||||
first, last,
|
||||
perWeek: slope * 7, // change in a day's intake from one week to the next
|
||||
// How much the daily figure moved across the days actually fitted. Not a
|
||||
// per-week rate: on a 7-day window today is never fitted, so the span is
|
||||
// at most five days and a weekly figure would be extrapolated past the
|
||||
// data — leaving a sentence whose own endpoints contradicted it.
|
||||
change: slope * (last - first),
|
||||
mean: my,
|
||||
clear: rise > residualSD, // the climb outruns the scatter
|
||||
};
|
||||
@@ -1786,6 +1973,7 @@
|
||||
|
||||
const gap = days.length > 14 ? 2 : 4;
|
||||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||||
const series = foodSeriesFor(days);
|
||||
|
||||
const parts = [];
|
||||
for (let i = 0; i <= ySteps; i++) {
|
||||
@@ -1809,12 +1997,34 @@
|
||||
}
|
||||
if (d.excluded) {
|
||||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||||
} else {
|
||||
} else if (series.length <= 1) {
|
||||
// Nothing to split: one bar, exactly as before kinds existed. This is
|
||||
// the shape an account that never defines a kind always sees.
|
||||
parts.push(
|
||||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||||
`<title>${escapeText(title)}</title></rect>`
|
||||
);
|
||||
} else {
|
||||
// Stacked, in the series' own order so the layers never reshuffle
|
||||
// between days. Segments are squared off; only the whole bar is
|
||||
// rounded, or every layer would show a notch.
|
||||
let below = 0;
|
||||
series.forEach((s, si) => {
|
||||
const g = s.of(d);
|
||||
if (!(g > 0)) return;
|
||||
const segH = (g / yMax) * innerH;
|
||||
const segY = MT + innerH - ((below + g) / yMax) * innerH;
|
||||
below += g;
|
||||
const rounded = si === series.length - 1 || below >= d.grams - 0.001;
|
||||
parts.push(
|
||||
`<rect class="bar bar-food-kind ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||||
(s.colorIndex == null ? "" : `data-color="${s.colorIndex % FOOD_COLORS}" `) +
|
||||
`x="${x}" y="${segY.toFixed(1)}" width="${barW}" height="${Math.max(0, segH).toFixed(1)}" ` +
|
||||
`rx="${rounded ? 3 : 0}">` +
|
||||
`<title>${escapeText(`${title} · ${s.name} ${Math.round(g)} g`)}</title></rect>`
|
||||
);
|
||||
});
|
||||
}
|
||||
if (showDayLabel(i, days.length) || isSel) {
|
||||
parts.push(
|
||||
@@ -1824,22 +2034,142 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The trend goes on top of the bars, and only across the days it was fitted
|
||||
// on. Clamped to the plot area so a steep fit can't draw outside the axes.
|
||||
const trend = foodTrend(days);
|
||||
if (trend) {
|
||||
// A trend line per series, on top of the bars and only across the days it
|
||||
// was fitted on. Clamped to the plot area so a steep fit can't draw outside
|
||||
// the axes.
|
||||
//
|
||||
// Each line sits at its own kind's daily amount, not at the top of that
|
||||
// kind's segment — the segment's height is what the kind ate, but its
|
||||
// position is an accident of what is stacked beneath it. So a line can
|
||||
// cross a segment it doesn't belong to; it is dashed and in the kind's own
|
||||
// colour, and the sentence underneath names the figures either way.
|
||||
const cx = (i) => ML + i * (barW + gap) + barW / 2;
|
||||
const cy = (g) => MT + innerH * (1 - Math.min(Math.max(g, 0), yMax) / yMax);
|
||||
for (const s of series) {
|
||||
if (!s.trend) continue;
|
||||
parts.push(
|
||||
`<line class="food-trend" x1="${cx(trend.first).toFixed(1)}" y1="${cy(trend.at(trend.first)).toFixed(1)}" ` +
|
||||
`x2="${cx(trend.last).toFixed(1)}" y2="${cy(trend.at(trend.last)).toFixed(1)}"/>`
|
||||
`<line class="food-trend" ` +
|
||||
(s.colorIndex == null ? "" : `data-color="${s.colorIndex % FOOD_COLORS}" `) +
|
||||
`x1="${cx(s.trend.first).toFixed(1)}" y1="${cy(s.trend.at(s.trend.first)).toFixed(1)}" ` +
|
||||
`x2="${cx(s.trend.last).toFixed(1)}" y2="${cy(s.trend.at(s.trend.last)).toFixed(1)}"/>`
|
||||
);
|
||||
}
|
||||
renderFoodTrendNote(days, trend);
|
||||
|
||||
renderFoodLegend(series);
|
||||
renderFoodDayInfo(days, series);
|
||||
renderFoodTrendNote(days, series);
|
||||
|
||||
setChartSVG(svg, parts);
|
||||
}
|
||||
|
||||
// Which kinds the window actually holds food for, in a stable order: the
|
||||
// kinds in creation order, then "No kind" last. A kind with nothing logged
|
||||
// this window is left out rather than shown as an empty legend entry.
|
||||
//
|
||||
// With no kinds defined this returns a single unnamed series, which is what
|
||||
// makes the whole feature invisible to anyone not using it.
|
||||
function foodSeriesFor(days) {
|
||||
// Every kind ever, tombstones included — a deleted one keeps both its name
|
||||
// and its colour, which is what lets its food stay identifiable below.
|
||||
const all = new Map(loadFoodKinds().map(k => [k.id, k]));
|
||||
const used = (id) => days.some(d => (d.gramsByKind?.[id] || 0) > 0);
|
||||
const out = [];
|
||||
for (const k of liveFoodKinds()) {
|
||||
if (!used(k.id)) continue;
|
||||
out.push({
|
||||
id: k.id, name: k.name, colorIndex: k.colorIndex ?? 0,
|
||||
of: (d) => d.gramsByKind?.[k.id] || 0,
|
||||
});
|
||||
}
|
||||
// Kinds deleted since, but still on meals in this window: their food is
|
||||
// real and has to appear somewhere, under the name and colour the tombstone
|
||||
// kept. Falling back to grey here would be wrong twice over — it is also
|
||||
// "No kind"'s colour, so the two series would be indistinguishable in both
|
||||
// the stack and the legend.
|
||||
for (const d of days) {
|
||||
for (const id of Object.keys(d.gramsByKind || {})) {
|
||||
if (id === NO_KIND || out.some(s => s.id === id)) continue;
|
||||
if (!used(id)) continue;
|
||||
const gone = all.get(id);
|
||||
out.push({
|
||||
id,
|
||||
name: gone?.name || "Deleted kind",
|
||||
colorIndex: gone ? (gone.colorIndex ?? 0) : null,
|
||||
of: (dd) => dd.gramsByKind?.[id] || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (used(NO_KIND) || out.length === 0) {
|
||||
out.push({
|
||||
id: NO_KIND, name: "No kind", colorIndex: null,
|
||||
of: (d) => d.gramsByKind?.[NO_KIND] || 0,
|
||||
});
|
||||
}
|
||||
for (const s of out) s.trend = foodTrend(days, s.of);
|
||||
return out;
|
||||
}
|
||||
|
||||
// What the highlighted bar holds, in words. Tapping a bar selects that day
|
||||
// — the behaviour every chart already has — so this reads off the selection
|
||||
// rather than keeping a second one of its own, which would then have to be
|
||||
// kept in step with it.
|
||||
//
|
||||
// Shown whether or not the bars are split. A phone has nothing to hover, so
|
||||
// the bar's tooltip is unreachable and the day's figure was otherwise only
|
||||
// readable by eye off the axis — that is as true of one bar as of a stack.
|
||||
function renderFoodDayInfo(days, series) {
|
||||
const el = document.getElementById("grams-day-info");
|
||||
if (!el) return;
|
||||
const day = days.find(d => d.ymd === ymd(selectedDay()));
|
||||
if (!day) { el.hidden = true; return; } // not a day this window draws
|
||||
|
||||
const date = day.date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
el.hidden = false;
|
||||
if (day.excluded) {
|
||||
el.textContent = `${date} — not counted.`;
|
||||
return;
|
||||
}
|
||||
// Series order, so this reads as the stack above it read bottom to top.
|
||||
const parts = series
|
||||
.map(s => ({ name: s.name, g: s.of(day) }))
|
||||
.filter(p => p.g > 0)
|
||||
.map(p => `${p.name} ${Math.round(p.g)} g`);
|
||||
const total = `${Math.round(day.grams)} g`;
|
||||
|
||||
if (parts.length === 0) {
|
||||
el.textContent = `${date} — no food logged.`;
|
||||
} else if (series.length <= 1) {
|
||||
// Unsplit: the one part *is* the total, and naming it twice — "No kind
|
||||
// 340 g · 340 g in total" — would be daft.
|
||||
el.textContent = `${date} — ${total}.`;
|
||||
} else {
|
||||
// The total is worth repeating beside the parts: it is what the bar's
|
||||
// height shows, and it saves adding them up.
|
||||
el.textContent = `${date} — ${parts.join(" · ")} · ${total} in total.`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFoodLegend(series) {
|
||||
const el = document.getElementById("grams-legend");
|
||||
if (!el) return;
|
||||
// One series is the unsplit chart; a legend naming it would be noise.
|
||||
el.hidden = series.length <= 1;
|
||||
if (el.hidden) return;
|
||||
el.innerHTML = "";
|
||||
for (const s of series) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "lg";
|
||||
const sw = document.createElement("span");
|
||||
sw.className = "sw food-sw";
|
||||
if (s.colorIndex != null) sw.dataset.color = String(s.colorIndex % FOOD_COLORS);
|
||||
chip.appendChild(sw);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = s.name;
|
||||
chip.appendChild(text);
|
||||
el.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
// Says what the line means, and what it cannot mean. Kept in words under the
|
||||
// chart rather than as a figure on it: "up 40 g a week" is a claim, and it
|
||||
// needs the room to be qualified.
|
||||
@@ -1850,36 +2180,75 @@
|
||||
//
|
||||
// Figures are rounded to 10 g. The fitted endpoints are model output, not
|
||||
// measurements — quoting "287 g" would dress a guess up as a reading.
|
||||
function foodTrendSentence(trend, windowDays) {
|
||||
// bare: drop the "Over the last N days" opener, for when the caller is
|
||||
// listing several kinds and has already said which window they share.
|
||||
function foodTrendSentence(trend, windowDays, { bare = false } = {}) {
|
||||
const window = `the last ${windowDays} days`;
|
||||
const opener = bare ? "" : `Over ${window}, `;
|
||||
if (!trend) {
|
||||
// Says why there is no line. Without this the chart looks broken on a
|
||||
// short window, or on one where most days are marked.
|
||||
return `Not enough complete days in ${window} to draw a trend — it needs four, and today doesn't count until it's over.`;
|
||||
}
|
||||
const round10 = (v) => Math.round(Math.max(0, v) / 10) * 10;
|
||||
const perWeek = Math.round(Math.abs(trend.perWeek));
|
||||
// Under a twentieth of a typical day, a week apart, is not a trend anyone
|
||||
// could act on, whatever the arithmetic says.
|
||||
const slight = perWeek < 5 || perWeek < trend.mean * 0.05;
|
||||
// Under a twentieth of a typical day is not a move anyone could act on,
|
||||
// whatever the arithmetic says.
|
||||
const slight = Math.abs(trend.change) < 5 || Math.abs(trend.change) < trend.mean * 0.05;
|
||||
if (!trend.clear || slight) {
|
||||
// The average is a real measurement and survives the noise; the fitted
|
||||
// endpoints would not, so they are not quoted here.
|
||||
return `Over ${window}, daily intake is roughly steady, averaging about ` +
|
||||
`${round10(trend.mean)} g a day — day-to-day variation is larger than any trend.`;
|
||||
return `${opener}${bare ? "roughly steady" : "daily intake is roughly steady"}, averaging about ` +
|
||||
`${round10(trend.mean)} g a day${bare ? "" : " — day-to-day variation is larger than any trend"}.`;
|
||||
}
|
||||
return `Over ${window}, daily intake is ${trend.perWeek > 0 ? "up" : "down"} about ` +
|
||||
`${perWeek} g a week — roughly ${round10(trend.at(trend.first))} g a day then, ` +
|
||||
`${round10(trend.at(trend.last))} g a day now.`;
|
||||
// The change is derived from the *rounded* ends rather than from the slope,
|
||||
// so that subtracting the two figures on screen gives exactly the figure
|
||||
// quoted. A reader who checks the arithmetic has to find it correct.
|
||||
const from = round10(trend.at(trend.first));
|
||||
const to = round10(trend.at(trend.last));
|
||||
return `${opener}${bare ? "" : "daily intake is "}${to > from ? "up" : "down"} about ` +
|
||||
`${Math.abs(to - from)} g — from roughly ${from} g a day to ${to} g.`;
|
||||
}
|
||||
|
||||
function renderFoodTrendNote(days, trend) {
|
||||
// One sentence per kind would grow with the list and bury the answer, so
|
||||
// only kinds with something to report get a sentence of their own and the
|
||||
// rest are folded into a clause. Both rules are foodTrend's, not new ones:
|
||||
// a kind with too few days has no fit, and one whose move is under its own
|
||||
// scatter is not claimed.
|
||||
function foodSeriesSentences(series, windowDays) {
|
||||
// Unsplit: the original single sentence, unchanged.
|
||||
if (series.length <= 1) return [foodTrendSentence(series[0]?.trend ?? null, windowDays)];
|
||||
|
||||
const moving = series.filter(s => s.trend && foodTrendMoves(s.trend));
|
||||
const rest = series.filter(s => !moving.includes(s));
|
||||
const out = moving.map(s => `${s.name}: ${foodTrendSentence(s.trend, windowDays, { bare: true })}`);
|
||||
|
||||
if (rest.length > 0) {
|
||||
const names = rest.map(s => s.name);
|
||||
const list = names.length === 1 ? names[0]
|
||||
: `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
||||
out.push(moving.length === 0
|
||||
? `Over the last ${windowDays} days, no kind shows a trend bigger than its day-to-day variation (${list}).`
|
||||
: `${list} ${rest.length === 1 ? "shows" : "show"} no clear trend.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Whether the sentence for this fit would name a direction, so the caller can
|
||||
// decide which kinds are worth a sentence of their own. Same two tests the
|
||||
// sentence itself applies.
|
||||
function foodTrendMoves(trend) {
|
||||
if (!trend) return false;
|
||||
const slight = Math.abs(trend.change) < 5 || Math.abs(trend.change) < trend.mean * 0.05;
|
||||
return trend.clear && !slight;
|
||||
}
|
||||
|
||||
function renderFoodTrendNote(days, series) {
|
||||
const note = document.getElementById("grams-note");
|
||||
if (!note) return;
|
||||
// The window comes from the 7/14/30 picker, and naming it is the only way
|
||||
// the reader can tell that switching it changed the answer — the line
|
||||
// itself often moves too little to notice.
|
||||
const lines = [foodTrendSentence(trend, chartDays())];
|
||||
const lines = foodSeriesSentences(series, chartDays());
|
||||
|
||||
const missing = days.reduce((s, d) => s + (d.mealsMissingGrams || 0), 0);
|
||||
if (missing > 0) {
|
||||
@@ -3060,6 +3429,8 @@
|
||||
// After the lists, so a pick whose event has gone is dropped in the same
|
||||
// pass that stops drawing it as picked.
|
||||
renderMeasureBar(events);
|
||||
// Adding or renaming a kind re-renders; keep the open Settings list in step.
|
||||
if (settingsDialog.open) renderFoodKindSettings();
|
||||
// These take the whole list even though they aggregate, because each
|
||||
// already knows about marked days and does something more precise with
|
||||
// them than dropping their events would:
|
||||
@@ -3173,6 +3544,16 @@
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
|
||||
const kindRes = await fetch("api/foodkinds/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// A guest reads the library but never writes to it, same as exercises.
|
||||
body: JSON.stringify({ foodKinds: isGuest() ? [] : loadFoodKinds() }),
|
||||
});
|
||||
if (kindRes.status === 401) { handleLoggedOut(); return; }
|
||||
if (!kindRes.ok) throw new Error(`HTTP ${kindRes.status}`);
|
||||
const kindBody = await kindRes.json();
|
||||
|
||||
const exRes = await fetch("api/exercises/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -3184,6 +3565,9 @@
|
||||
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
|
||||
const exBody = await exRes.json();
|
||||
|
||||
if (Array.isArray(kindBody.foodKinds)) {
|
||||
mergeSynced(kindBody.foodKinds, loadFoodKinds, saveFoodKinds, isGuest);
|
||||
}
|
||||
if (Array.isArray(exBody.exercises)) {
|
||||
// Same reasoning as the events below: a guest's local exercise edits
|
||||
// can never land, so the server's copy is always the truth.
|
||||
@@ -3268,6 +3652,36 @@
|
||||
const noteWeightField = document.getElementById("note-weight-field");
|
||||
const noteWeight = document.getElementById("note-weight");
|
||||
const noteGramsField = document.getElementById("note-grams-field");
|
||||
const noteKindField = document.getElementById("note-kind-field");
|
||||
const noteKindPicker = document.getElementById("note-kind-picker");
|
||||
const noteKindNew = document.getElementById("note-kind-new");
|
||||
const noteKindName = document.getElementById("note-kind-name");
|
||||
// Which kind the dialog currently has selected. Held here rather than read
|
||||
// off the DOM so the picker can be redrawn (after adding a kind) without
|
||||
// losing the choice.
|
||||
let notePickedKind = NO_KIND;
|
||||
|
||||
function drawNoteKindPicker() {
|
||||
renderKindPicker(noteKindPicker, noteKindField, notePickedKind, (id) => {
|
||||
notePickedKind = id;
|
||||
drawNoteKindPicker();
|
||||
});
|
||||
}
|
||||
|
||||
// Inventing a kind mid-log: it is created, selected, and the box clears, so
|
||||
// you carry on logging the meal you came here for.
|
||||
document.getElementById("note-kind-add").addEventListener("click", () => {
|
||||
const name = noteKindName.value.trim();
|
||||
if (!name) return;
|
||||
notePickedKind = addFoodKind(name).id;
|
||||
noteKindName.value = "";
|
||||
drawNoteKindPicker();
|
||||
});
|
||||
noteKindName.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault(); // the dialog's default button would otherwise save
|
||||
document.getElementById("note-kind-add").click();
|
||||
});
|
||||
const noteGrams = document.getElementById("note-grams");
|
||||
let pendingType = null;
|
||||
let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
|
||||
@@ -3345,6 +3759,13 @@
|
||||
noteWeight.value = "";
|
||||
noteGramsField.hidden = !isEat;
|
||||
noteGrams.value = "";
|
||||
// A new meal starts on whichever kind is the default, which may well be no
|
||||
// kind — that is the starting state and stays a legitimate choice.
|
||||
notePickedKind = defaultFoodKindId();
|
||||
noteKindNew.hidden = isGuest(); // inventing a kind is the owner's
|
||||
noteKindName.value = "";
|
||||
if (isEat) drawNoteKindPicker();
|
||||
else noteKindField.hidden = true;
|
||||
clearNotePhotos();
|
||||
noteDialog.showModal();
|
||||
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
|
||||
@@ -3414,7 +3835,7 @@
|
||||
}
|
||||
photoIds.push(id);
|
||||
}
|
||||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
|
||||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams, foodKindId: notePickedKind });
|
||||
pendingType = null;
|
||||
clearNotePhotos();
|
||||
noteDialog.close();
|
||||
@@ -3433,6 +3854,17 @@
|
||||
const editTime = document.getElementById("edit-time");
|
||||
const editNote = document.getElementById("edit-note");
|
||||
const editLoggedBy = document.getElementById("edit-logged-by");
|
||||
const editKindField = document.getElementById("edit-kind-field");
|
||||
const editKindPicker = document.getElementById("edit-kind-picker");
|
||||
let editPickedKind = NO_KIND;
|
||||
let editType = "";
|
||||
|
||||
function drawEditKindPicker() {
|
||||
renderKindPicker(editKindPicker, editKindField, editPickedKind, (id) => {
|
||||
editPickedKind = id;
|
||||
drawEditKindPicker();
|
||||
});
|
||||
}
|
||||
const editReadOnly = document.getElementById("edit-readonly");
|
||||
const editTitle = document.getElementById("edit-title");
|
||||
const editDelete = document.getElementById("edit-delete");
|
||||
@@ -3486,6 +3918,7 @@
|
||||
|
||||
async function openEditDialog(ev) {
|
||||
editingId = ev.id;
|
||||
editType = ev.type;
|
||||
editLoggedBy.hidden = !ev.loggedBy;
|
||||
if (ev.loggedBy) editLoggedBy.textContent = `Logged by ${ev.loggedBy} on a guest link.`;
|
||||
setEditReadOnly(!canEditEvent(ev));
|
||||
@@ -3496,6 +3929,10 @@
|
||||
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
|
||||
editGramsField.hidden = ev.type !== "eat";
|
||||
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
|
||||
// The kind as it stands, so leaving the dialog alone changes nothing.
|
||||
editPickedKind = ev.foodKindId || NO_KIND;
|
||||
if (ev.type === "eat") drawEditKindPicker();
|
||||
else editKindField.hidden = true;
|
||||
resetEditPhotos();
|
||||
for (const id of photoIdsOf(ev)) {
|
||||
editPhotos.push({ id, url: await photoSrc(id) });
|
||||
@@ -3557,6 +3994,8 @@
|
||||
photoIds.push(id);
|
||||
}
|
||||
patch.photoId = photoIds.join(",");
|
||||
// Only meals carry a kind; writing it on other types would be noise.
|
||||
if (editType === "eat") patch.foodKindId = editPickedKind;
|
||||
updateEvent(editingId, patch);
|
||||
editingId = null;
|
||||
resetEditPhotos();
|
||||
@@ -3626,6 +4065,9 @@
|
||||
settingsProfile.hidden = guest;
|
||||
settingsDanger.hidden = guest;
|
||||
guestAccess.hidden = guest;
|
||||
// The library is the owner's, like the exercise list.
|
||||
foodKindSection.hidden = guest;
|
||||
if (!guest) renderFoodKindSettings();
|
||||
settingsDialog.showModal();
|
||||
refreshRemindersUI();
|
||||
if (!guest) {
|
||||
@@ -3680,6 +4122,89 @@
|
||||
settingsDialog.close();
|
||||
});
|
||||
|
||||
// ---------- food kinds in Settings ----------
|
||||
// The library: add, rename, delete, and choose which one a new meal starts
|
||||
// on. Renaming is in-place rather than through a dialog — there is one field
|
||||
// to change, and a dialog for a single text box is a tax.
|
||||
const foodKindSection = document.getElementById("food-kinds-section");
|
||||
const foodKindList = document.getElementById("food-kind-list");
|
||||
const foodKindEmpty = document.getElementById("food-kind-empty");
|
||||
const foodKindName = document.getElementById("food-kind-name");
|
||||
|
||||
function renderFoodKindSettings() {
|
||||
const kinds = liveFoodKinds();
|
||||
foodKindEmpty.hidden = kinds.length > 0;
|
||||
foodKindList.innerHTML = "";
|
||||
const defaultId = defaultFoodKindId();
|
||||
|
||||
for (const k of kinds) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "food-kind-item";
|
||||
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "food-kind-swatch";
|
||||
swatch.dataset.color = String((k.colorIndex ?? 0) % FOOD_COLORS);
|
||||
li.appendChild(swatch);
|
||||
|
||||
// The name is the input: typing renames it, which is the whole edit.
|
||||
const name = document.createElement("input");
|
||||
name.type = "text";
|
||||
name.className = "food-kind-name";
|
||||
name.value = k.name;
|
||||
name.maxLength = 30;
|
||||
name.setAttribute("aria-label", `Name of ${k.name}`);
|
||||
const commit = () => {
|
||||
const next = name.value.trim();
|
||||
if (!next || next === k.name) { name.value = k.name; return; }
|
||||
updateFoodKind(k.id, { name: next });
|
||||
};
|
||||
name.addEventListener("blur", commit);
|
||||
name.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); name.blur(); } });
|
||||
li.appendChild(name);
|
||||
|
||||
// Tapping the star sets the default; tapping the one that already is
|
||||
// clears it, which is how you get back to starting on "No kind".
|
||||
const star = document.createElement("button");
|
||||
star.type = "button";
|
||||
star.className = "food-kind-default" + (k.id === defaultId ? " active" : "");
|
||||
star.textContent = k.id === defaultId ? "★" : "☆";
|
||||
star.title = k.id === defaultId
|
||||
? "The default for a new meal — tap to start on No kind instead"
|
||||
: "Make this the default for a new meal";
|
||||
star.setAttribute("aria-pressed", String(k.id === defaultId));
|
||||
star.addEventListener("click", () => setDefaultFoodKind(k.id === defaultId ? NO_KIND : k.id));
|
||||
li.appendChild(star);
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "linklike food-kind-delete";
|
||||
del.textContent = "✕";
|
||||
del.setAttribute("aria-label", `Delete ${k.name}`);
|
||||
del.addEventListener("click", () => {
|
||||
// Meals keep the id and the tombstone keeps the name, so nothing in
|
||||
// the history becomes unreadable — worth saying, since "delete" on a
|
||||
// thing other records point at sounds more destructive than it is.
|
||||
if (!confirm(`Delete the kind “${k.name}”? Meals already logged as it keep their label.`)) return;
|
||||
deleteFoodKind(k.id);
|
||||
});
|
||||
li.appendChild(del);
|
||||
|
||||
foodKindList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("food-kind-add").addEventListener("click", () => {
|
||||
const name = foodKindName.value.trim();
|
||||
if (!name) return;
|
||||
addFoodKind(name);
|
||||
foodKindName.value = "";
|
||||
});
|
||||
foodKindName.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
document.getElementById("food-kind-add").click();
|
||||
});
|
||||
|
||||
// ---------- guest links ----------
|
||||
// Hand someone a URL that logs events on this account without giving them the
|
||||
// password. The server holds only a hash of the token (server/auth.go), so the
|
||||
@@ -5573,6 +6098,7 @@
|
||||
localStorage.removeItem(eventsKey());
|
||||
localStorage.removeItem(configKey());
|
||||
localStorage.removeItem(exercisesKey());
|
||||
localStorage.removeItem(foodKindsKey());
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
[
|
||||
{ "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 40 g a week — roughly 280 g a day then, 400 g a day now”. 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-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" },
|
||||
|
||||
+51
-1
@@ -265,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>
|
||||
@@ -397,7 +403,13 @@
|
||||
</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, with a trend line through them"></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>
|
||||
@@ -546,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">
|
||||
@@ -636,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>
|
||||
@@ -673,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>
|
||||
|
||||
+115
-11
@@ -1110,10 +1110,17 @@ button.linklike:hover { text-decoration: underline; filter: 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
|
||||
@@ -1331,6 +1338,114 @@ input.switch:checked::after { transform: translateX(18px); }
|
||||
.update-banner-btn:hover { filter: brightness(0.97); }
|
||||
|
||||
/* ---------- quick-log 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. */
|
||||
@@ -1464,17 +1579,6 @@ button.measure-clear {
|
||||
.chart-svg .now-rule { stroke: var(--text); stroke-width: 1; opacity: 0.75; pointer-events: none; }
|
||||
.chart-svg .now-rule-cap { fill: var(--text); opacity: 0.75; pointer-events: none; }
|
||||
|
||||
/* The fit through the food bars. Dashed and in the weight colour rather than
|
||||
the food one: it is a reading of the bars, not another bar, and the same
|
||||
teal carries the other charts' "this is a derived line" (see .trend-avg). */
|
||||
.chart-svg .food-trend {
|
||||
stroke: var(--weight);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 3;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
|
||||
.chart-svg .trend-today {
|
||||
stroke: var(--sleep);
|
||||
|
||||
Reference in New Issue
Block a user