Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Heldt a0e084dae8 Extend the daily charts from 7 to 14 days
Sleep, daily counts and food grams now share a 14-day window like the
pattern charts. Bar gaps tighten to fit 14 columns and the x-axis
labels today plus every second day so labels don't collide; tooltips
and click-through per bar are unchanged.
2026-07-13 20:45:22 +00:00
Alexander Heldt c103082ca5 Replace the big clock with a timer pill in the frozen day bar
The full-width asleep/awake card becomes a compact colored pill next to
the day controls: state emoji plus the live counter, with "since" moved
to the tooltip. One glance at the pinned bar now gives both the day and
the current state.
2026-07-13 20:40:01 +00:00
Alexander Heldt 0009e63d23 Freeze the day picker at the top of the page
The day bar moves above everything else and sticks while scrolling, so
switching days never requires scrolling back up. A fixed strip painted
over the safe-area inset keeps content from peeking through above the
stuck bar on notched phones in standalone mode.
2026-07-13 20:39:06 +00:00
Alexander Heldt a202f3e929 Support multiple photos per event
photoId now holds one or more photo UUIDs, comma-separated. The server
never interprets the field (photos are uploaded and served individually
by UUID), so no schema change is needed and legacy single-photo events
are already valid one-element lists.

Both dialogs let you keep adding photos, previewed as thumbnails with a
per-photo remove button; the file input allows multi-select and no
longer forces the camera, so the gallery is available too. History rows
show every photo, each opening in the lightbox.
2026-07-13 20:34:07 +00:00
5 changed files with 221 additions and 165 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ type Event struct {
Type string `json:"type"` Type string `json:"type"`
At int64 `json:"at"` At int64 `json:"at"`
Note string `json:"note"` Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
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 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
+147 -116
View File
@@ -674,28 +674,27 @@
let bigClockSince = 0; let bigClockSince = 0;
function renderBigClock(events) { function renderBigClock(events) {
const card = document.getElementById("big-clock"); const pill = document.getElementById("bar-clock");
const label = document.getElementById("bc-label"); const icon = document.getElementById("bar-clock-icon");
const time = document.getElementById("bc-time"); const time = document.getElementById("bar-clock-time");
const since = document.getElementById("bc-since");
const { state, since: ts } = currentSleepState(events); const { state, since: ts } = currentSleepState(events);
bigClockState = state; bigClockState = state;
bigClockSince = ts; bigClockSince = ts;
if (!state) { if (!state) {
card.hidden = true; pill.hidden = true;
return; return;
} }
card.hidden = false; pill.hidden = false;
card.classList.toggle("asleep", state === "asleep"); pill.classList.toggle("asleep", state === "asleep");
card.classList.toggle("awake", state === "awake"); pill.classList.toggle("awake", state === "awake");
label.textContent = state === "asleep" ? "Asleep for" : "Awake for"; icon.textContent = state === "asleep" ? "😴" : "☀️";
time.textContent = formatCounter(Date.now() - ts); pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}`;
since.textContent = `since ${formatTime(ts)}`; time.textContent = formatCounter(Date.now() - ts);
} }
function tickBigClock() { function tickBigClock() {
if (!bigClockState) return; if (!bigClockState) return;
const time = document.getElementById("bc-time"); const time = document.getElementById("bar-clock-time");
if (time) time.textContent = formatCounter(Date.now() - bigClockSince); if (time) time.textContent = formatCounter(Date.now() - bigClockSince);
} }
@@ -779,18 +778,18 @@
} }
li.addEventListener("click", () => openEditDialog(ev)); li.addEventListener("click", () => openEditDialog(ev));
if (ev.photoId) { for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img"); const img = document.createElement("img");
img.className = "thumb"; img.className = "thumb";
img.alt = "photo"; img.alt = "photo";
img.loading = "lazy"; img.loading = "lazy";
img.dataset.photoId = ev.photoId; img.dataset.photoId = pid;
img.addEventListener("click", (e) => { img.addEventListener("click", (e) => {
e.stopPropagation(); // don't open the edit dialog e.stopPropagation(); // don't open the edit dialog
openLightbox(ev.photoId); openLightbox(pid);
}); });
li.appendChild(img); li.appendChild(img);
photoSrc(ev.photoId).then(url => { if (url) img.src = url; }); photoSrc(pid).then(url => { if (url) img.src = url; });
} }
eventList.appendChild(li); eventList.appendChild(li);
@@ -815,12 +814,14 @@
if (e.target === lightbox) lightbox.close(); if (e.target === lightbox) lightbox.close();
}); });
// ---------- weekly charts ---------- // ---------- daily charts (last 14 days) ----------
const CHART_DAYS = 14;
function weeklyData(events) { function weeklyData(events) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const now = Date.now(); const now = Date.now();
const days = []; const days = [];
for (let i = 6; i >= 0; i--) { for (let i = CHART_DAYS - 1; i >= 0; i--) {
const d = new Date(today); const d = new Date(today);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const from = startOfDay(d).getTime(); const from = startOfDay(d).getTime();
@@ -847,6 +848,12 @@
return date.toLocaleDateString(undefined, { weekday: "short" }); return date.toLocaleDateString(undefined, { weekday: "short" });
} }
// With 14 columns there is no room for a label under every bar, so label
// today and every second day counting back from it.
function showDayLabel(i, len) {
return (len - 1 - i) % 2 === 0;
}
// Pick a chart Y maximum and tick count so every tick label is a clean // Pick a chart Y maximum and tick count so every tick label is a clean
// whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps): // whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps):
// 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond. // 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond.
@@ -912,7 +919,7 @@
const rawMax = Math.max(...days.map(d => d.sleepHours)); const rawMax = Math.max(...days.map(d => d.sleepHours));
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax); const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
const gap = 6; const gap = 4;
const barW = (innerW - (days.length - 1) * gap) / days.length; const barW = (innerW - (days.length - 1) * gap) / days.length;
const parts = []; const parts = [];
@@ -935,10 +942,12 @@
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` + `x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>` `<title>${escapeText(title)}</title></rect>`
); );
parts.push( if (showDayLabel(i, days.length)) {
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` + parts.push(
`${escapeText(dayLabel(d.date, isToday))}</text>` `<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
); `${escapeText(dayLabel(d.date, isToday))}</text>`
);
}
}); });
setChartSVG(svg, parts); setChartSVG(svg, parts);
@@ -954,8 +963,8 @@
const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals])); const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals]));
const { yMax, steps: ySteps } = niceAxis(rawMax); const { yMax, steps: ySteps } = niceAxis(rawMax);
const groupGap = 6; const groupGap = 4;
const innerBarGap = 2; const innerBarGap = 1.5;
const groupW = (innerW - (days.length - 1) * groupGap) / days.length; const groupW = (innerW - (days.length - 1) * groupGap) / days.length;
const barW = (groupW - 2 * innerBarGap) / 3; const barW = (groupW - 2 * innerBarGap) / 3;
@@ -990,10 +999,12 @@
); );
}); });
parts.push( if (showDayLabel(i, days.length)) {
`<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` + parts.push(
`${escapeText(dayLabel(d.date, isToday))}</text>` `<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
); `${escapeText(dayLabel(d.date, isToday))}</text>`
);
}
}); });
setChartSVG(svg, parts); setChartSVG(svg, parts);
@@ -1014,7 +1025,7 @@
const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams))); const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams)));
const gap = 6; const gap = 4;
const barW = (innerW - (days.length - 1) * gap) / days.length; const barW = (innerW - (days.length - 1) * gap) / days.length;
const parts = []; const parts = [];
@@ -1037,10 +1048,12 @@
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` + `x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>` `<title>${escapeText(title)}</title></rect>`
); );
parts.push( if (showDayLabel(i, days.length)) {
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` + parts.push(
`${escapeText(dayLabel(d.date, isToday))}</text>` `<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
); `${escapeText(dayLabel(d.date, isToday))}</text>`
);
}
}); });
setChartSVG(svg, parts); setChartSVG(svg, parts);
@@ -1695,27 +1708,60 @@
const noteTitle = document.getElementById("note-title"); const noteTitle = document.getElementById("note-title");
const notePhotoInput = document.getElementById("note-photo-input"); const notePhotoInput = document.getElementById("note-photo-input");
const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoBtn = document.getElementById("note-photo-btn");
const notePhotoClear = document.getElementById("note-photo-clear");
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 noteGramsField = document.getElementById("note-grams-field");
const noteGrams = document.getElementById("note-grams"); const noteGrams = document.getElementById("note-grams");
let pendingType = null; let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
let notePhotoURL = null; // current preview object URL
// Whether the user has manually touched the date/time fields. While false the // Whether the user has manually touched the date/time fields. While false the
// dialog logs at the exact current millisecond rather than the minute-floored // dialog logs at the exact current millisecond rather than the minute-floored
// input, so a just-logged event doesn't look up to ~59s old. // input, so a just-logged event doesn't look up to ~59s old.
let noteTimeEdited = false; let noteTimeEdited = false;
function clearNotePhoto() { // An event can carry several photos: photoId holds their UUIDs
notePhotoBlob = null; // comma-separated. A legacy single id is just a one-element list, and the
if (notePhotoURL) { URL.revokeObjectURL(notePhotoURL); notePhotoURL = null; } // server passes the string through untouched (photos themselves are
notePhotoPreview.hidden = true; // uploaded and fetched individually by UUID).
function photoIdsOf(ev) {
return (ev.photoId || "").split(",").filter(Boolean);
}
// A preview thumbnail with a remove button; shared by both dialogs.
function photoThumb(url, onRemove) {
const wrap = document.createElement("div");
wrap.className = "photo-thumb";
const img = document.createElement("img");
img.alt = "";
if (url) img.src = url;
const rm = document.createElement("button");
rm.type = "button";
rm.className = "photo-thumb-remove";
rm.setAttribute("aria-label", "Remove photo");
rm.textContent = "×";
rm.addEventListener("click", onRemove);
wrap.appendChild(img);
wrap.appendChild(rm);
return wrap;
}
function renderNotePhotos() {
notePhotoPreview.innerHTML = ""; notePhotoPreview.innerHTML = "";
notePhotoClear.hidden = true; notePhotoPreview.hidden = notePhotos.length === 0;
notePhotoBtn.textContent = "📷 Add photo"; notePhotos.forEach((p, i) => {
notePhotoPreview.appendChild(photoThumb(p.url, () => {
URL.revokeObjectURL(p.url);
notePhotos.splice(i, 1);
renderNotePhotos();
}));
});
}
function clearNotePhotos() {
for (const p of notePhotos) URL.revokeObjectURL(p.url);
notePhotos = [];
renderNotePhotos();
notePhotoInput.value = ""; notePhotoInput.value = "";
} }
@@ -1733,7 +1779,7 @@
noteWeight.value = ""; noteWeight.value = "";
noteGramsField.hidden = !isEat; noteGramsField.hidden = !isEat;
noteGrams.value = ""; noteGrams.value = "";
clearNotePhoto(); clearNotePhotos();
noteDialog.showModal(); noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50); setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
} }
@@ -1760,21 +1806,17 @@
}); });
notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
notePhotoClear.addEventListener("click", () => clearNotePhoto());
notePhotoInput.addEventListener("change", async (e) => { notePhotoInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; for (const file of Array.from(e.target.files || [])) {
if (!file) return; try {
try { const blob = await resizeImage(file);
notePhotoBlob = await resizeImage(file); notePhotos.push({ blob, url: URL.createObjectURL(blob) });
if (notePhotoURL) URL.revokeObjectURL(notePhotoURL); } catch (err) {
notePhotoURL = URL.createObjectURL(notePhotoBlob); alert("Couldn't process that photo: " + err.message);
notePhotoPreview.innerHTML = `<img alt="" src="${notePhotoURL}">`; }
notePhotoPreview.hidden = false;
notePhotoClear.hidden = false;
notePhotoBtn.textContent = "📷 Replace photo";
} catch (err) {
alert("Couldn't process that photo: " + err.message);
} }
notePhotoInput.value = "";
renderNotePhotos();
}); });
document.getElementById("note-save").addEventListener("click", async (e) => { document.getElementById("note-save").addEventListener("click", async (e) => {
@@ -1792,24 +1834,25 @@
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; } if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
grams = Math.round(g); grams = Math.round(g);
} }
let photoId = ""; const photoIds = [];
if (notePhotoBlob) { for (const p of notePhotos) {
photoId = uuid(); const id = uuid();
try { await putPhoto(photoId, notePhotoBlob, false); } try { await putPhoto(id, p.blob, false); }
catch (err) { catch (err) {
alert("Couldn't store photo locally: " + err.message); alert("Couldn't store photo locally: " + err.message);
return; return;
} }
photoIds.push(id);
} }
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId, weight, grams }); addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
pendingType = null; pendingType = null;
clearNotePhoto(); clearNotePhotos();
noteDialog.close(); noteDialog.close();
}); });
noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
pendingType = null; pendingType = null;
clearNotePhoto(); clearNotePhotos();
noteDialog.close(); noteDialog.close();
}); });
@@ -1822,27 +1865,32 @@
const editDelete = document.getElementById("edit-delete"); const editDelete = document.getElementById("edit-delete");
const editPhotoInput = document.getElementById("edit-photo-input"); const editPhotoInput = document.getElementById("edit-photo-input");
const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoBtn = document.getElementById("edit-photo-btn");
const editPhotoClear = document.getElementById("edit-photo-clear");
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 editGramsField = document.getElementById("edit-grams-field");
const editGrams = document.getElementById("edit-grams"); const editGrams = document.getElementById("edit-grams");
let editingId = null; let editingId = null;
let editPhotoId = ""; // current photoId for this event // The dialog's working set of photos, in display order. Existing photos are
let editPhotoBlob = null; // new blob chosen in this session // { id, url } (url from photoSrc's page-lifetime cache — never revoked here);
let editPhotoURL = null; // newly picked ones are { blob, url } with a fresh object URL we own.
let editPhotoCleared = false; // user removed an existing photo let editPhotos = [];
function setEditPreviewFromURL(url) { function renderEditPhotos() {
if (!url) { editPhotoPreview.hidden = true; editPhotoPreview.innerHTML = ""; return; } editPhotoPreview.innerHTML = "";
editPhotoPreview.innerHTML = `<img alt="" src="${url}">`; editPhotoPreview.hidden = editPhotos.length === 0;
editPhotoPreview.hidden = false; editPhotos.forEach((p, i) => {
editPhotoPreview.appendChild(photoThumb(p.url, () => {
if (p.blob) URL.revokeObjectURL(p.url);
editPhotos.splice(i, 1);
renderEditPhotos();
}));
});
} }
function clearEditPhotoLocalState() { function resetEditPhotos() {
editPhotoBlob = null; for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url);
if (editPhotoURL) { URL.revokeObjectURL(editPhotoURL); editPhotoURL = null; } editPhotos = [];
editPhotoInput.value = ""; editPhotoInput.value = "";
} }
@@ -1855,44 +1903,26 @@
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"; editGramsField.hidden = ev.type !== "eat";
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : ""; editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
editPhotoId = ev.photoId || ""; resetEditPhotos();
editPhotoCleared = false; for (const id of photoIdsOf(ev)) {
clearEditPhotoLocalState(); editPhotos.push({ id, url: await photoSrc(id) });
if (editPhotoId) {
const url = await photoSrc(editPhotoId);
setEditPreviewFromURL(url);
editPhotoBtn.textContent = "📷 Replace photo";
editPhotoClear.hidden = false;
} else {
setEditPreviewFromURL(null);
editPhotoBtn.textContent = "📷 Add photo";
editPhotoClear.hidden = true;
} }
renderEditPhotos();
editDialog.showModal(); editDialog.showModal();
} }
editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); editPhotoBtn.addEventListener("click", () => editPhotoInput.click());
editPhotoClear.addEventListener("click", () => {
editPhotoCleared = true;
clearEditPhotoLocalState();
setEditPreviewFromURL(null);
editPhotoBtn.textContent = "📷 Add photo";
editPhotoClear.hidden = true;
});
editPhotoInput.addEventListener("change", async (e) => { editPhotoInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; for (const file of Array.from(e.target.files || [])) {
if (!file) return; try {
try { const blob = await resizeImage(file);
editPhotoBlob = await resizeImage(file); editPhotos.push({ blob, url: URL.createObjectURL(blob) });
if (editPhotoURL) URL.revokeObjectURL(editPhotoURL); } catch (err) {
editPhotoURL = URL.createObjectURL(editPhotoBlob); alert("Couldn't process that photo: " + err.message);
setEditPreviewFromURL(editPhotoURL); }
editPhotoCleared = true; // a new photo supersedes any existing one
editPhotoBtn.textContent = "📷 Replace photo";
editPhotoClear.hidden = false;
} catch (err) {
alert("Couldn't process that photo: " + err.message);
} }
editPhotoInput.value = "";
renderEditPhotos();
}); });
editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
@@ -1917,24 +1947,25 @@
patch.grams = Math.round(g); patch.grams = Math.round(g);
} }
} }
if (editPhotoBlob) { const photoIds = [];
const newId = uuid(); for (const p of editPhotos) {
try { await putPhoto(newId, editPhotoBlob, false); } if (p.id) { photoIds.push(p.id); continue; }
const id = uuid();
try { await putPhoto(id, p.blob, false); }
catch (err) { alert("Couldn't store photo locally: " + err.message); return; } catch (err) { alert("Couldn't store photo locally: " + err.message); return; }
patch.photoId = newId; photoIds.push(id);
} else if (editPhotoCleared) {
patch.photoId = "";
} }
patch.photoId = photoIds.join(",");
updateEvent(editingId, patch); updateEvent(editingId, patch);
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
@@ -1944,7 +1975,7 @@
deleteEvent(editingId); deleteEvent(editingId);
} }
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
+4
View File
@@ -1,4 +1,8 @@
[ [
{ "date": "2026-07-13", "text": "The sleep, daily counts and food charts now cover the last 14 days instead of 7" },
{ "date": "2026-07-13", "text": "The awake/asleep timer lives in the frozen top bar" },
{ "date": "2026-07-13", "text": "The day picker is a bar frozen at the top of the page" },
{ "date": "2026-07-13", "text": "Attach multiple photos to an event — the photo picker now also offers the gallery with multi-select" },
{ "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-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" },
+19 -21
View File
@@ -77,10 +77,17 @@
</header> </header>
<main> <main>
<section id="big-clock" class="big-clock" hidden> <section class="day-bar">
<div class="bc-label" id="bc-label"></div> <button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<div class="bc-time" id="bc-time">0:00</div> <input type="date" id="day-picker" />
<div class="bc-since" id="bc-since"></div> <button type="button" id="day-today" class="ghost">Today</button>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
<!-- Compact asleep/awake counter (the old big clock); hidden until a
sleep event exists. Hover/long-press shows "since" via title. -->
<div id="bar-clock" class="bar-clock" hidden>
<span id="bar-clock-icon" aria-hidden="true"></span>
<span id="bar-clock-time"></span>
</div>
</section> </section>
<section class="quick-actions"> <section class="quick-actions">
@@ -107,13 +114,6 @@
</div> </div>
</section> </section>
<section class="day-bar">
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<input type="date" id="day-picker" />
<button type="button" id="day-today" class="ghost">Today</button>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
</section>
<section class="overview" data-panel="overview"> <section class="overview" data-panel="overview">
<h2 id="overview-title">Today's overview</h2> <h2 id="overview-title">Today's overview</h2>
<div class="stats"> <div class="stats">
@@ -176,14 +176,14 @@
</section> </section>
<section class="weekly" data-panel="weekly"> <section class="weekly" data-panel="weekly">
<h2>Last 7 days</h2> <h2>Last 14 days</h2>
<div class="chart"> <div class="chart">
<div class="chart-title">Sleep (hours)</div> <div class="chart-title">Sleep (hours)</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day for the last 7 days"></svg> <svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day for the last 14 days"></svg>
</div> </div>
<div class="chart"> <div class="chart">
<div class="chart-title">Daily counts</div> <div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day for the last 7 days"></svg> <svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day for the last 14 days"></svg>
<div class="legend"> <div class="legend">
<span class="lg pee"><span class="sw"></span>Pees</span> <span class="lg pee"><span class="sw"></span>Pees</span>
<span class="lg poo"><span class="sw"></span>Poos</span> <span class="lg poo"><span class="sw"></span>Poos</span>
@@ -192,7 +192,7 @@
</div> </div>
<div class="chart" id="grams-chart-wrap" hidden> <div class="chart" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div> <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> <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 14 days"></svg>
</div> </div>
</section> </section>
@@ -332,9 +332,8 @@
<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>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="note-photo-input" accept="image/*" multiple hidden />
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="note-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="note-photo-preview" class="photo-preview" hidden></div> <div id="note-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
@@ -363,9 +362,8 @@
<textarea id="edit-note" rows="4"></textarea> <textarea id="edit-note" rows="4"></textarea>
</label> </label>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="edit-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="edit-photo-preview" class="photo-preview" hidden></div> <div id="edit-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
+50 -27
View File
@@ -122,15 +122,28 @@ section {
padding: 16px; padding: 16px;
} }
.big-clock { /* Paints over the notch/status-bar strip so content scrolling behind the
text-align: center; sticky day bar never peeks through above it. Zero-height where there is no
padding: 24px 16px; safe-area inset. */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
height: env(safe-area-inset-top, 0px);
background: var(--bg);
z-index: 65;
} }
.day-bar { .day-bar {
position: sticky;
top: env(safe-area-inset-top, 0px);
z-index: 60;
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap;
padding: 8px 10px; padding: 8px 10px;
} }
.day-bar input[type="date"] { .day-bar input[type="date"] {
@@ -146,30 +159,20 @@ section {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.big-clock .bc-label { .bar-clock {
font-size: 0.8rem; display: flex;
text-transform: uppercase; align-items: center;
letter-spacing: 0.08em; gap: 6px;
color: var(--muted); padding: 7px 12px;
margin-bottom: 6px; border-radius: 999px;
}
.big-clock .bc-time {
font-size: 3.25rem;
font-weight: 700; font-weight: 700;
font-size: 0.9rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.05; flex-shrink: 0;
letter-spacing: -0.01em;
} }
.big-clock .bc-since { .bar-clock[hidden] { display: none; }
margin-top: 6px; .bar-clock.asleep { background: color-mix(in srgb, var(--sleep) 18%, var(--surface)); color: var(--sleep); }
font-size: 0.8rem; .bar-clock.awake { background: color-mix(in srgb, var(--accent) 18%, var(--surface)); color: var(--accent); }
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.big-clock.asleep { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--sleep) 10%, var(--surface))); }
.big-clock.asleep .bc-time { color: var(--sleep); }
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--accent) 10%, var(--surface))); }
.big-clock.awake .bc-time { color: var(--accent); }
.grid { .grid {
display: grid; display: grid;
@@ -432,13 +435,33 @@ dialog menu {
.photo-preview { .photo-preview {
margin-top: 8px; margin-top: 8px;
width: 100%; width: 100%;
display: flex;
flex-wrap: wrap;
gap: 10px;
} }
.photo-preview img { .photo-preview[hidden] { display: none; }
.photo-thumb { position: relative; }
.photo-thumb img {
display: block; display: block;
max-width: 100%; width: 76px;
max-height: 240px; height: 76px;
object-fit: cover;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--accent-soft);
}
.photo-thumb-remove {
position: absolute;
top: -7px;
right: -7px;
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
background: var(--danger);
color: #fff;
font-size: 14px;
line-height: 1;
} }
.event .thumb { .event .thumb {