Track food eaten by weight in grams
Logging a meal now opens the dialog (like weigh-ins) with an optional Amount (g) field; existing meals can get an amount via the edit dialog, where clearing the field drops it. The overview's Meals tile shows the day's total grams, and the weekly card gains a Food (grams) chart with a self-scaling axis that stays hidden until any meal has an amount. Events carry a new grams field (REAL column, auto-migrated); addEvent's growing optional parameters are folded into an options object.
This commit is contained in:
+17
-6
@@ -30,6 +30,7 @@ type Event struct {
|
|||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
PhotoID string `json:"photoId,omitempty"`
|
PhotoID string `json:"photoId,omitempty"`
|
||||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
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
|
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
@@ -127,12 +128,12 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
|||||||
// clobber another's row even if a client forges a colliding event ID —
|
// clobber another's row even if a client forges a colliding event ID —
|
||||||
// the row stays put and, because reads are scoped, stays invisible to them.
|
// the row stays put and, because reads are scoped, stays invisible to them.
|
||||||
stmt, err := tx.Prepare(`
|
stmt, err := tx.Prepare(`
|
||||||
INSERT INTO events (id, type, at, note, photo_id, weight, exercise_id, updated, deleted, user_id)
|
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
type = excluded.type, at = excluded.at, note = excluded.note,
|
type = excluded.type, at = excluded.at, note = excluded.note,
|
||||||
photo_id = excluded.photo_id, weight = excluded.weight,
|
photo_id = excluded.photo_id, weight = excluded.weight,
|
||||||
exercise_id = excluded.exercise_id,
|
grams = excluded.grams, exercise_id = excluded.exercise_id,
|
||||||
updated = excluded.updated, deleted = excluded.deleted
|
updated = excluded.updated, deleted = excluded.deleted
|
||||||
WHERE excluded.updated > events.updated
|
WHERE excluded.updated > events.updated
|
||||||
AND events.user_id = excluded.user_id`)
|
AND events.user_id = excluded.user_id`)
|
||||||
@@ -146,7 +147,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := stmt.Exec(
|
if _, err := stmt.Exec(
|
||||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
|
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -160,7 +161,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
|
|||||||
// all returns one user's events, tombstones included.
|
// all returns one user's events, tombstones included.
|
||||||
func (s *Store) all(userID string) ([]Event, error) {
|
func (s *Store) all(userID string) ([]Event, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, type, at, note, photo_id, weight, exercise_id, updated, deleted
|
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
|
||||||
FROM events WHERE user_id = ?`, userID)
|
FROM events WHERE user_id = ?`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -170,7 +171,7 @@ func (s *Store) all(userID string) ([]Event, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Event
|
var e Event
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
|
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -277,6 +278,7 @@ func openDB(path string) (*sql.DB, error) {
|
|||||||
note TEXT NOT NULL DEFAULT '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
photo_id TEXT NOT NULL DEFAULT '',
|
photo_id TEXT NOT NULL DEFAULT '',
|
||||||
weight REAL NOT NULL DEFAULT 0,
|
weight REAL NOT NULL DEFAULT 0,
|
||||||
|
grams REAL NOT NULL DEFAULT 0,
|
||||||
exercise_id TEXT NOT NULL DEFAULT '',
|
exercise_id TEXT NOT NULL DEFAULT '',
|
||||||
updated INTEGER NOT NULL DEFAULT 0,
|
updated INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted INTEGER NOT NULL DEFAULT 0,
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -348,6 +350,15 @@ func migrateSchema(db *sql.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hasGrams, err := columnExists(db, "events", "grams")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasGrams {
|
||||||
|
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN grams REAL NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
oldConfig, err := columnExists(db, "config", "id")
|
oldConfig, err := columnExists(db, "config", "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+101
-6
@@ -257,7 +257,7 @@
|
|||||||
return `${wk} · ${mo} old`;
|
return `${wk} · ${mo} old`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEvent(type, note, at, photoId, weight, exerciseId) {
|
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
|
||||||
const events = loadAll();
|
const events = loadAll();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ev = {
|
const ev = {
|
||||||
@@ -267,6 +267,7 @@
|
|||||||
note: note || "",
|
note: note || "",
|
||||||
photoId: photoId || "",
|
photoId: photoId || "",
|
||||||
weight: Number.isFinite(weight) ? weight : undefined,
|
weight: Number.isFinite(weight) ? weight : undefined,
|
||||||
|
grams: Number.isFinite(grams) ? grams : undefined,
|
||||||
exerciseId: exerciseId || "",
|
exerciseId: exerciseId || "",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -590,6 +591,12 @@
|
|||||||
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
|
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
|
||||||
document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
|
document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
|
||||||
document.getElementById("stat-meals").textContent = count("eat");
|
document.getElementById("stat-meals").textContent = count("eat");
|
||||||
|
const gramsTotal = dayEvents
|
||||||
|
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||||
|
.reduce((s, e) => s + e.grams, 0);
|
||||||
|
const gramsEl = document.getElementById("stat-meals-grams");
|
||||||
|
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
|
||||||
|
gramsEl.hidden = !(gramsTotal > 0);
|
||||||
document.getElementById("stat-pees").textContent = count("pee");
|
document.getElementById("stat-pees").textContent = count("pee");
|
||||||
document.getElementById("stat-poos").textContent = count("poo");
|
document.getElementById("stat-poos").textContent = count("poo");
|
||||||
document.getElementById("stat-training").textContent = count("training");
|
document.getElementById("stat-training").textContent = count("training");
|
||||||
@@ -827,6 +834,9 @@
|
|||||||
pees: dayEvents.filter(e => e.type === "pee").length,
|
pees: dayEvents.filter(e => e.type === "pee").length,
|
||||||
poos: dayEvents.filter(e => e.type === "poo").length,
|
poos: dayEvents.filter(e => e.type === "poo").length,
|
||||||
meals: dayEvents.filter(e => e.type === "eat").length,
|
meals: dayEvents.filter(e => e.type === "eat").length,
|
||||||
|
grams: dayEvents
|
||||||
|
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||||||
|
.reduce((s, e) => s + e.grams, 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return days;
|
return days;
|
||||||
@@ -854,6 +864,18 @@
|
|||||||
return { yMax: m, steps: m / 5 };
|
return { yMax: m, steps: m / 5 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grams axis: 0-based with a "nice" step so tick labels stay round whatever
|
||||||
|
// the daily totals are (tens of grams for a tiny puppy, hundreds+ later).
|
||||||
|
function niceAxisGrams(rawMax) {
|
||||||
|
if (!(rawMax > 0)) return { yMax: 100, steps: 2 };
|
||||||
|
const rawStep = rawMax / 4;
|
||||||
|
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||||||
|
const norm = rawStep / mag;
|
||||||
|
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
|
||||||
|
const yMax = Math.ceil(rawMax / step) * step;
|
||||||
|
return { yMax, steps: Math.max(1, Math.round(yMax / step)) };
|
||||||
|
}
|
||||||
|
|
||||||
// Sleep-specific axis: always 2-hour granularity, capped at 24h/day,
|
// Sleep-specific axis: always 2-hour granularity, capped at 24h/day,
|
||||||
// for a more readable picture of typical 10–18 h puppy sleep.
|
// for a more readable picture of typical 10–18 h puppy sleep.
|
||||||
function niceAxisSleepHours(rawMax) {
|
function niceAxisSleepHours(rawMax) {
|
||||||
@@ -977,10 +999,58 @@
|
|||||||
setChartSVG(svg, parts);
|
setChartSVG(svg, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grams of food per day. Hidden entirely until any meal in the window has an
|
||||||
|
// amount logged, so the weekly card doesn't grow an empty chart.
|
||||||
|
function drawGramsChart(days) {
|
||||||
|
const wrap = document.getElementById("grams-chart-wrap");
|
||||||
|
const svg = document.getElementById("chart-grams");
|
||||||
|
if (!days.some(d => d.grams > 0)) { wrap.hidden = true; return; }
|
||||||
|
wrap.hidden = false;
|
||||||
|
|
||||||
|
const W = 320, H = 160;
|
||||||
|
const ML = 34, MR = 6, MT = 10, MB = 26;
|
||||||
|
const innerW = W - ML - MR;
|
||||||
|
const innerH = H - MT - MB;
|
||||||
|
|
||||||
|
const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams)));
|
||||||
|
|
||||||
|
const gap = 6;
|
||||||
|
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i <= ySteps; i++) {
|
||||||
|
const y = MT + innerH * (1 - i / ySteps);
|
||||||
|
const v = yMax * i / ySteps;
|
||||||
|
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||||||
|
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||||||
|
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
days.forEach((d, i) => {
|
||||||
|
const isToday = i === days.length - 1;
|
||||||
|
const x = ML + i * (barW + gap);
|
||||||
|
const h = (d.grams / yMax) * innerH;
|
||||||
|
const y = MT + innerH - h;
|
||||||
|
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`;
|
||||||
|
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>`
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
|
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setChartSVG(svg, parts);
|
||||||
|
}
|
||||||
|
|
||||||
function renderWeekly(events) {
|
function renderWeekly(events) {
|
||||||
const days = weeklyData(events);
|
const days = weeklyData(events);
|
||||||
drawSleepChart(days);
|
drawSleepChart(days);
|
||||||
drawCountsChart(days);
|
drawCountsChart(days);
|
||||||
|
drawGramsChart(days);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- pattern charts (last 14 days) ----------
|
// ---------- pattern charts (last 14 days) ----------
|
||||||
@@ -1379,7 +1449,7 @@
|
|||||||
logBtn.textContent = "Log";
|
logBtn.textContent = "Log";
|
||||||
logBtn.addEventListener("click", (e) => {
|
logBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ev = addEvent("training", "", Date.now(), "", undefined, ex.id);
|
const ev = addEvent("training", "", Date.now(), { exerciseId: ex.id });
|
||||||
showSnackbar(`${ex.name} logged`, ev);
|
showSnackbar(`${ex.name} logged`, ev);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1629,6 +1699,8 @@
|
|||||||
const notePhotoPreview = document.getElementById("note-photo-preview");
|
const notePhotoPreview = document.getElementById("note-photo-preview");
|
||||||
const noteWeightField = document.getElementById("note-weight-field");
|
const noteWeightField = document.getElementById("note-weight-field");
|
||||||
const noteWeight = document.getElementById("note-weight");
|
const noteWeight = document.getElementById("note-weight");
|
||||||
|
const noteGramsField = document.getElementById("note-grams-field");
|
||||||
|
const noteGrams = document.getElementById("note-grams");
|
||||||
let pendingType = null;
|
let pendingType = null;
|
||||||
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
|
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
|
||||||
let notePhotoURL = null; // current preview object URL
|
let notePhotoURL = null; // current preview object URL
|
||||||
@@ -1656,11 +1728,14 @@
|
|||||||
noteTime.value = toTimeInput(now);
|
noteTime.value = toTimeInput(now);
|
||||||
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
|
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
|
||||||
const isWeight = type === "weight";
|
const isWeight = type === "weight";
|
||||||
|
const isEat = type === "eat";
|
||||||
noteWeightField.hidden = !isWeight;
|
noteWeightField.hidden = !isWeight;
|
||||||
noteWeight.value = "";
|
noteWeight.value = "";
|
||||||
|
noteGramsField.hidden = !isEat;
|
||||||
|
noteGrams.value = "";
|
||||||
clearNotePhoto();
|
clearNotePhoto();
|
||||||
noteDialog.showModal();
|
noteDialog.showModal();
|
||||||
setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50);
|
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
function noteDialogAt() {
|
function noteDialogAt() {
|
||||||
@@ -1711,6 +1786,12 @@
|
|||||||
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
|
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
|
||||||
weight = Math.round(weight * 100) / 100;
|
weight = Math.round(weight * 100) / 100;
|
||||||
}
|
}
|
||||||
|
let grams;
|
||||||
|
if (pendingType === "eat" && noteGrams.value.trim() !== "") {
|
||||||
|
const g = parseFloat(noteGrams.value);
|
||||||
|
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||||||
|
grams = Math.round(g);
|
||||||
|
}
|
||||||
let photoId = "";
|
let photoId = "";
|
||||||
if (notePhotoBlob) {
|
if (notePhotoBlob) {
|
||||||
photoId = uuid();
|
photoId = uuid();
|
||||||
@@ -1720,7 +1801,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight);
|
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId, weight, grams });
|
||||||
pendingType = null;
|
pendingType = null;
|
||||||
clearNotePhoto();
|
clearNotePhoto();
|
||||||
noteDialog.close();
|
noteDialog.close();
|
||||||
@@ -1745,6 +1826,8 @@
|
|||||||
const editPhotoPreview = document.getElementById("edit-photo-preview");
|
const editPhotoPreview = document.getElementById("edit-photo-preview");
|
||||||
const editWeightField = document.getElementById("edit-weight-field");
|
const editWeightField = document.getElementById("edit-weight-field");
|
||||||
const editWeight = document.getElementById("edit-weight");
|
const editWeight = document.getElementById("edit-weight");
|
||||||
|
const editGramsField = document.getElementById("edit-grams-field");
|
||||||
|
const editGrams = document.getElementById("edit-grams");
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
let editPhotoId = ""; // current photoId for this event
|
let editPhotoId = ""; // current photoId for this event
|
||||||
let editPhotoBlob = null; // new blob chosen in this session
|
let editPhotoBlob = null; // new blob chosen in this session
|
||||||
@@ -1770,6 +1853,8 @@
|
|||||||
editNote.value = ev.note || "";
|
editNote.value = ev.note || "";
|
||||||
editWeightField.hidden = ev.type !== "weight";
|
editWeightField.hidden = ev.type !== "weight";
|
||||||
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
|
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 : "";
|
||||||
editPhotoId = ev.photoId || "";
|
editPhotoId = ev.photoId || "";
|
||||||
editPhotoCleared = false;
|
editPhotoCleared = false;
|
||||||
clearEditPhotoLocalState();
|
clearEditPhotoLocalState();
|
||||||
@@ -1823,6 +1908,15 @@
|
|||||||
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
|
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
|
||||||
patch.weight = Math.round(kg * 100) / 100;
|
patch.weight = Math.round(kg * 100) / 100;
|
||||||
}
|
}
|
||||||
|
if (!editGramsField.hidden) {
|
||||||
|
if (editGrams.value.trim() === "") {
|
||||||
|
patch.grams = undefined; // cleared → drop the amount
|
||||||
|
} else {
|
||||||
|
const g = parseFloat(editGrams.value);
|
||||||
|
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||||||
|
patch.grams = Math.round(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (editPhotoBlob) {
|
if (editPhotoBlob) {
|
||||||
const newId = uuid();
|
const newId = uuid();
|
||||||
try { await putPhoto(newId, editPhotoBlob, false); }
|
try { await putPhoto(newId, editPhotoBlob, false); }
|
||||||
@@ -2142,8 +2236,9 @@
|
|||||||
document.querySelectorAll("button.action").forEach(btn => {
|
document.querySelectorAll("button.action").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const type = btn.dataset.type;
|
const type = btn.dataset.type;
|
||||||
// Weigh-ins need a typed value, so they keep the full dialog.
|
// Weigh-ins need a typed value and meals ask for grams, so those two
|
||||||
if (type === "weight") { openNoteDialog(type); return; }
|
// keep the full dialog.
|
||||||
|
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
|
||||||
quickLog(type);
|
quickLog(type);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
{ "date": "2026-07-13", "text": "Track food by weight: logging a meal asks for grams (optional), with a daily total in the overview and a weekly chart" },
|
||||||
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
|
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
|
||||||
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
|
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
|
||||||
{ "date": "2026-07-12", "text": "The update banner now lists what changed in the new version" },
|
{ "date": "2026-07-12", "text": "The update banner now lists what changed in the new version" },
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Meals</div>
|
<div class="stat-label">Meals</div>
|
||||||
<div class="stat-value" id="stat-meals">0</div>
|
<div class="stat-value" id="stat-meals">0</div>
|
||||||
|
<div class="stat-sub" id="stat-meals-grams" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Pees</div>
|
<div class="stat-label">Pees</div>
|
||||||
@@ -189,6 +190,10 @@
|
|||||||
<span class="lg eat"><span class="sw"></span>Meals</span>
|
<span class="lg eat"><span class="sw"></span>Meals</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chart" id="grams-chart-wrap" hidden>
|
||||||
|
<div class="chart-title">Food (grams)</div>
|
||||||
|
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day for the last 7 days"></svg>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="patterns" data-panel="sleep-timeline">
|
<section class="patterns" data-panel="sleep-timeline">
|
||||||
@@ -320,6 +325,9 @@
|
|||||||
<label id="note-weight-field" hidden>Weight (kg)
|
<label id="note-weight-field" hidden>Weight (kg)
|
||||||
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
|
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
|
||||||
</label>
|
</label>
|
||||||
|
<label id="note-grams-field" hidden>Amount (g)
|
||||||
|
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
|
||||||
|
</label>
|
||||||
<label>Note
|
<label>Note
|
||||||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||||||
</label>
|
</label>
|
||||||
@@ -348,6 +356,9 @@
|
|||||||
<label id="edit-weight-field" hidden>Weight (kg)
|
<label id="edit-weight-field" hidden>Weight (kg)
|
||||||
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
|
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
|
||||||
</label>
|
</label>
|
||||||
|
<label id="edit-grams-field" hidden>Amount (g)
|
||||||
|
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
|
||||||
|
</label>
|
||||||
<label>Note
|
<label>Note
|
||||||
<textarea id="edit-note" rows="4"></textarea>
|
<textarea id="edit-note" rows="4"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -232,6 +232,12 @@ button.danger { background: var(--danger); }
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Reference in New Issue
Block a user