Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0e084dae8 | |||
| c103082ca5 | |||
| 0009e63d23 | |||
| a202f3e929 |
+1
-1
@@ -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
|
||||||
|
|||||||
+128
-97
@@ -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" ? "😴" : "☀️";
|
||||||
|
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}`;
|
||||||
time.textContent = formatCounter(Date.now() - ts);
|
time.textContent = formatCounter(Date.now() - ts);
|
||||||
since.textContent = `since ${formatTime(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>`
|
||||||
);
|
);
|
||||||
|
if (showDayLabel(i, days.length)) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
`${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 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (showDayLabel(i, days.length)) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
`<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
`${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>`
|
||||||
);
|
);
|
||||||
|
if (showDayLabel(i, days.length)) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||||||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
`${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 {
|
||||||
notePhotoBlob = await resizeImage(file);
|
const blob = await resizeImage(file);
|
||||||
if (notePhotoURL) URL.revokeObjectURL(notePhotoURL);
|
notePhotos.push({ blob, url: URL.createObjectURL(blob) });
|
||||||
notePhotoURL = URL.createObjectURL(notePhotoBlob);
|
|
||||||
notePhotoPreview.innerHTML = `<img alt="" src="${notePhotoURL}">`;
|
|
||||||
notePhotoPreview.hidden = false;
|
|
||||||
notePhotoClear.hidden = false;
|
|
||||||
notePhotoBtn.textContent = "📷 Replace photo";
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Couldn't process that photo: " + err.message);
|
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 {
|
||||||
editPhotoBlob = await resizeImage(file);
|
const blob = await resizeImage(file);
|
||||||
if (editPhotoURL) URL.revokeObjectURL(editPhotoURL);
|
editPhotos.push({ blob, url: URL.createObjectURL(blob) });
|
||||||
editPhotoURL = URL.createObjectURL(editPhotoBlob);
|
|
||||||
setEditPreviewFromURL(editPhotoURL);
|
|
||||||
editPhotoCleared = true; // a new photo supersedes any existing one
|
|
||||||
editPhotoBtn.textContent = "📷 Replace photo";
|
|
||||||
editPhotoClear.hidden = false;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Couldn't process that photo: " + err.message);
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user