Track five things a day for seventy-five days
The workout, the protein, the calories, the water, and three lines of gratitude. One page per day, a card of seventy-five tiles that fill by how many of the five landed, and a missed day left as a gap rather than a reset. Single Go binary with no dependencies. The log is one JSON file on a mounted volume, written atomically and never silently replaced when it fails to parse, since it is the one thing here that cannot be recreated. Auth is Traefik basic auth at the ingress; the app has no login of its own, so every POST checks Sec-Fetch-Site to stop another origin posting with those credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
161
internal/web/static/app.js
Normal file
161
internal/web/static/app.js
Normal file
@@ -0,0 +1,161 @@
|
||||
// Progressive enhancement only. With JavaScript off the form posts, the page
|
||||
// reloads, and everything still works; this saves in the background instead,
|
||||
// so ticking a box does not cost a page load first thing in the morning.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var form = document.querySelector("[data-log]");
|
||||
if (!form) {
|
||||
confirmDestructiveForms();
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.add("has-sync");
|
||||
|
||||
var status = form.querySelector("[data-status]");
|
||||
var todayTile = document.querySelector(".tile-today");
|
||||
var timer = null;
|
||||
var dirty = false;
|
||||
|
||||
// ------------------------------------------------------------- painting
|
||||
// The meters redraw from the values already on the page rather than from
|
||||
// the response, so the band moves as you type instead of after a round trip.
|
||||
|
||||
function paintMeter(band) {
|
||||
var input = band.querySelector(".band-input");
|
||||
var target = parseInt(band.getAttribute("data-target"), 10);
|
||||
var ceiling = band.getAttribute("data-mode") === "ceiling";
|
||||
var raw = input.value.trim();
|
||||
var value = raw === "" ? null : parseInt(raw, 10);
|
||||
|
||||
if (value === null || isNaN(value) || !target) {
|
||||
band.style.setProperty("--fill", "0%");
|
||||
band.classList.remove("is-met", "is-over");
|
||||
return false;
|
||||
}
|
||||
|
||||
band.style.setProperty("--fill", Math.min(100, (value * 100) / target) + "%");
|
||||
var met = ceiling ? value <= target : value >= target;
|
||||
band.classList.toggle("is-met", met);
|
||||
band.classList.toggle("is-over", ceiling && value > target);
|
||||
return met;
|
||||
}
|
||||
|
||||
function gratitudeMet() {
|
||||
var lines = form.querySelectorAll(".gratitude-input");
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].value.trim() === "") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function repaint() {
|
||||
var score = 0;
|
||||
|
||||
var workout = form.querySelector(".band-check");
|
||||
if (workout) {
|
||||
form.querySelector(".band-toggle").classList.toggle("is-met", workout.checked);
|
||||
if (workout.checked) score++;
|
||||
}
|
||||
|
||||
var bands = form.querySelectorAll(".band-meter");
|
||||
for (var i = 0; i < bands.length; i++) {
|
||||
if (paintMeter(bands[i])) score++;
|
||||
}
|
||||
|
||||
var gratitude = form.querySelector(".gratitude");
|
||||
var gratitudeOK = gratitudeMet();
|
||||
if (gratitude) gratitude.classList.toggle("is-met", gratitudeOK);
|
||||
if (gratitudeOK) score++;
|
||||
|
||||
// Only the day being edited is on screen, so only its tile can change.
|
||||
if (todayTile && todayTile.getAttribute("data-date") === currentDate()) {
|
||||
todayTile.style.setProperty("--score", score);
|
||||
todayTile.classList.toggle("tile-full", score === 5);
|
||||
}
|
||||
}
|
||||
|
||||
function currentDate() {
|
||||
return form.getAttribute("action").replace("/d/", "");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- saving
|
||||
|
||||
function say(message, bad) {
|
||||
if (!status) return;
|
||||
status.textContent = message;
|
||||
status.classList.toggle("is-bad", !!bad);
|
||||
}
|
||||
|
||||
function save() {
|
||||
dirty = false;
|
||||
say("Saving");
|
||||
|
||||
fetch(form.getAttribute("action"), {
|
||||
method: "POST",
|
||||
headers: { "X-Sync": "1" },
|
||||
body: new FormData(form),
|
||||
keepalive: true
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
if (!response.ok) throw new Error(body.error || "That did not save.");
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
say("Saved");
|
||||
})
|
||||
.catch(function (err) {
|
||||
// Leaving the entry on screen means nothing typed is lost; the button
|
||||
// is still there to retry with.
|
||||
say(err.message || "Not saved — still on this device only", true);
|
||||
dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
function queue() {
|
||||
dirty = true;
|
||||
say("");
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(save, 600);
|
||||
}
|
||||
|
||||
form.addEventListener("input", function () {
|
||||
repaint();
|
||||
queue();
|
||||
});
|
||||
|
||||
// A checkbox is a decision, not a draft: write it straight away.
|
||||
form.addEventListener("change", function (event) {
|
||||
repaint();
|
||||
if (event.target.type === "checkbox") {
|
||||
clearTimeout(timer);
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
// Backgrounding the tab on a phone is how most edits end. Flush first.
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden" && dirty) {
|
||||
clearTimeout(timer);
|
||||
save();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function () {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
repaint();
|
||||
confirmDestructiveForms();
|
||||
|
||||
function confirmDestructiveForms() {
|
||||
document.addEventListener("submit", function (event) {
|
||||
var message = event.target.getAttribute("data-confirm");
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user