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:
2026-08-01 21:59:01 -07:00
commit faf73c8be5
25 changed files with 3197 additions and 0 deletions

636
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,636 @@
/* 75 — a daily log for one person, opened on a phone.
The look is municipal pool tile: a pale aqua ground, grout lines, deep
navy-teal ink, chlorine blue for anything that fills, and the orange of
backstroke flags for the day you are standing on. */
:root {
--tile: #E6EEEF;
--grout: #C6D6DA;
--chalk: #FBFDFD;
--ink: #0E3140;
--ink-soft: #557987;
--lane: #0F7391;
--lane-wash: rgba(15, 115, 145, 0.16);
--flag: #D8471C;
--flag-wash: rgba(216, 71, 28, 0.14);
--display: "Avenir Next Condensed", "Arial Narrow", Impact, sans-serif;
--body: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--gap: 0.5rem;
--radius: 3px;
}
@media (prefers-color-scheme: dark) {
:root {
--tile: #0B1E27;
--grout: #1F3F4D;
--chalk: #102A35;
--ink: #DCEAED;
--ink-soft: #7E9EA9;
--lane: #3FA9CC;
--lane-wash: rgba(63, 169, 204, 0.20);
--flag: #FF7043;
--flag-wash: rgba(255, 112, 67, 0.18);
}
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--tile);
color: var(--ink);
font-family: var(--body);
font-size: 16px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
a { color: var(--lane); }
.sr-only {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:focus-visible {
outline: 2px solid var(--flag);
outline-offset: 2px;
}
.page {
max-width: 34rem;
margin: 0 auto;
padding: 1.25rem 1rem calc(2rem + env(safe-area-inset-bottom));
}
.page-narrow { max-width: 26rem; }
/* ----------------------------------------------------------- masthead */
.masthead { margin-bottom: 1.5rem; }
.masthead-nav {
display: grid;
grid-template-columns: 3rem 1fr 3rem;
align-items: center;
}
.step {
display: block;
font-family: var(--display);
font-size: 2.5rem;
line-height: 1;
text-align: center;
text-decoration: none;
color: var(--ink-soft);
border-radius: var(--radius);
padding: 0.25rem 0;
}
.step.is-spent { opacity: 0.2; }
.masthead-day { text-align: center; }
.eyebrow {
margin: 0;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-soft);
}
.masthead-count {
margin: 0;
font-family: var(--display);
font-size: clamp(4.5rem, 22vw, 6.5rem);
font-weight: 600;
line-height: 0.82;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.masthead-count.is-spent { color: var(--ink-soft); }
.masthead-of {
margin: 0.15rem 0 0;
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.1em;
color: var(--ink-soft);
}
.masthead-date {
margin: 0.9rem 0 0;
text-align: center;
font-size: 0.95rem;
color: var(--ink-soft);
}
.masthead-date b { color: var(--ink); }
.masthead-back { margin-left: 0.5rem; white-space: nowrap; }
.notice, .alert, .flash {
margin: 1rem 0 0;
padding: 0.65rem 0.85rem;
border-radius: var(--radius);
font-size: 0.9rem;
}
.notice { background: var(--lane-wash); }
.flash { background: var(--lane-wash); }
.alert { background: var(--flag-wash); color: var(--ink); }
/* --------------------------------------------------------------- bands
Every measurable thing is a band that fills. Protein and water fill
towards a floor to reach; calories fill towards a ceiling, so the band
itself is the budget and running past it turns the whole thing hot. */
.log {
display: grid;
gap: var(--gap);
margin: 0;
}
.band {
position: relative;
display: grid;
align-items: center;
gap: 0 0.5rem;
grid-template-columns: 1fr auto 2rem;
grid-template-areas:
"label entry tick"
"note note tick";
min-height: 4.25rem;
padding: 0.7rem 0.85rem;
background: var(--chalk);
border: 1px solid var(--grout);
border-radius: var(--radius);
overflow: hidden;
}
/* The fill runs inside a track that stops short of the tick, so a band at 99%
never slides its water line underneath the verdict. */
.band-track {
position: absolute;
inset: 0 3.35rem 0 0; /* clears the tick column, its gap and the padding */
overflow: hidden;
}
/* The water line is painted as the last two pixels of the fill's own gradient
rather than a border, so an empty band shows nothing at all. */
.band-fill {
position: absolute;
inset: 0 auto 0 0;
width: var(--fill, 0%);
background: linear-gradient(to right,
var(--lane-wash) calc(100% - 2px), var(--lane) calc(100% - 2px));
transition: width 220ms ease-out;
}
.band-label {
grid-area: label;
position: relative;
font-family: var(--display);
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.band-note {
grid-area: note;
position: relative;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-soft);
}
.band-entry {
grid-area: entry;
position: relative;
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.band-input {
width: 4.5ch;
padding: 0;
border: 0;
border-bottom: 2px solid var(--grout);
border-radius: 0;
background: transparent;
color: inherit;
font-family: var(--mono);
font-size: 1.5rem;
font-variant-numeric: tabular-nums;
text-align: right;
appearance: textfield;
}
.band-input::-webkit-inner-spin-button,
.band-input::-webkit-outer-spin-button { appearance: none; margin: 0; }
.band-input:focus { outline: 0; border-bottom-color: var(--flag); }
.band-unit {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft);
}
.band-tick {
grid-area: tick;
position: relative;
justify-self: end;
font-size: 1.5rem;
line-height: 1;
color: var(--grout);
}
.band-tick::before { content: "○"; }
.band.is-met { border-color: var(--lane); }
.band.is-met .band-tick { color: var(--lane); }
.band.is-met .band-tick::before { content: "●"; }
/* Past the ceiling there is nothing left to measure, so the meter gives way to
the whole band going hot. */
.band.is-over { border-color: var(--flag); background: var(--flag-wash); }
.band.is-over .band-fill { background: none; }
.band.is-over .band-tick { color: var(--flag); }
.band.is-over .band-tick::before { content: "▲"; }
/* the workout band is one tap on the whole thing */
.band-toggle {
grid-template-areas: "label state tick";
grid-template-rows: auto;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.band-check {
position: absolute;
opacity: 0;
width: 1px; height: 1px;
}
.band-check:focus-visible ~ .band-label {
outline: 2px solid var(--flag);
outline-offset: 3px;
}
.band-state {
grid-area: state;
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-soft);
}
.state-on { display: none; }
.band-check:checked ~ .band-state .state-on { display: inline; color: var(--lane); }
.band-check:checked ~ .band-state .state-off { display: none; }
.band-toggle:active { transform: scale(0.995); }
/* ----------------------------------------------------------- gratitude */
.gratitude {
position: relative;
margin: 0;
padding: 0.7rem 0.85rem 0.9rem;
background: var(--chalk);
border: 1px solid var(--grout);
border-radius: var(--radius);
}
.gratitude.is-met { border-color: var(--lane); }
.gratitude legend {
padding: 0 0.35rem;
margin-left: -0.35rem;
font-family: var(--display);
font-size: 1.5rem;
font-weight: 600;
}
.gratitude-hint {
margin: 0 0 0.6rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-soft);
}
.gratitude-lines {
list-style: none;
counter-reset: line;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.gratitude-lines li {
counter-increment: line;
display: grid;
grid-template-columns: 1.25rem 1fr;
align-items: center;
}
.gratitude-lines li::before {
content: counter(line);
font-family: var(--mono);
font-size: 0.8rem;
color: var(--ink-soft);
}
.gratitude-input {
width: 100%;
padding: 0.2rem 0;
border: 0;
border-bottom: 1px solid var(--grout);
border-radius: 0;
background: transparent;
color: inherit;
font-family: var(--body);
font-size: 1rem;
}
.gratitude-input:focus { outline: 0; border-bottom-color: var(--flag); }
/* --------------------------------------------------------------- saving */
.log-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.25rem;
}
.save {
display: inline-block;
padding: 0.7rem 1.4rem;
border: 0;
border-radius: var(--radius);
background: var(--ink);
color: var(--tile);
font-family: var(--display);
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.03em;
text-decoration: none;
cursor: pointer;
}
.save-quiet {
background: transparent;
color: var(--ink);
border: 1px solid var(--grout);
}
.log-status {
margin: 0;
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--lane);
}
.log-status.is-bad { color: var(--flag); }
/* Once the page can save in the background, the button is only there for the
no-JavaScript case. */
.has-sync .save-day { display: none; }
/* ----------------------------------------------------------------- card
Seventy-five tiles, fifteen across. Each one fills from the bottom by how
many of the five that day hit, so a glance shows the texture of the whole
challenge rather than a row of pass/fail. */
.card { margin-top: 2.25rem; }
.card-heading {
margin: 0 0 0.7rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.24em;
text-transform: uppercase;
font-weight: 400;
color: var(--ink-soft);
}
.card-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
/* minmax(0,…) rather than 1fr: a track that cannot shrink below its content
pushes the fifteenth column off the edge on a narrow phone. */
grid-template-columns: repeat(15, minmax(0, 1fr));
gap: 3px;
}
.tile {
position: relative;
aspect-ratio: 1;
background: var(--chalk);
border: 1px solid var(--grout);
border-radius: 1px;
overflow: hidden;
}
.tile a {
position: absolute;
inset: 0;
display: block;
}
.tile-fill {
position: absolute;
inset: auto 0 0 0;
height: calc(var(--score, 0) * 20%);
background: var(--lane);
transition: height 220ms ease-out;
}
.tile-full { border-color: var(--lane); }
.tile-ahead {
background: transparent;
border-style: dashed;
opacity: 0.5;
}
.tile-today {
outline: 2px solid var(--flag);
outline-offset: 1px;
z-index: 1;
}
.tally {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin: 1.25rem 0 0;
padding: 0.9rem 0 0;
border-top: 1px solid var(--grout);
text-align: center;
}
.tally dt {
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
}
.tally dd {
margin: 0.1rem 0 0;
font-family: var(--display);
font-size: 2.5rem;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.legend {
list-style: none;
margin: 1.1rem 0 0;
padding: 0;
display: grid;
gap: 0.3rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--ink-soft);
}
.legend li {
display: grid;
grid-template-columns: 1fr auto;
border-bottom: 1px dotted var(--grout);
padding-bottom: 0.2rem;
}
.legend b { color: var(--ink); font-variant-numeric: tabular-nums; }
/* --------------------------------------------------------------- sheets */
.sheet {
margin: 1.5rem 0 0;
padding: 1rem;
background: var(--chalk);
border: 1px solid var(--grout);
border-radius: var(--radius);
}
.sheet-heading {
margin: 0 0 0.5rem;
font-family: var(--display);
font-size: 1.5rem;
font-weight: 600;
}
.sheet-note {
margin: 0.9rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft);
}
.field { margin-bottom: 1rem; }
.field label {
display: block;
margin-bottom: 0.25rem;
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-soft);
}
.field-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.field input, .field select {
padding: 0.5rem 0.6rem;
border: 1px solid var(--grout);
border-radius: var(--radius);
background: var(--tile);
color: inherit;
font-family: var(--mono);
font-size: 1rem;
}
.field-row input { width: 7rem; }
.field-unit {
font-size: 0.8rem;
color: var(--ink-soft);
}
.restore {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--grout);
display: grid;
gap: 0.5rem;
justify-items: start;
}
.restore label {
font-family: var(--mono);
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-soft);
}
.restore input { font-size: 0.85rem; }
/* --------------------------------------------------------------- footer */
.footer {
max-width: 34rem;
margin: 0 auto;
padding: 1.5rem 1rem calc(2rem + env(safe-area-inset-bottom));
display: flex;
gap: 1.25rem;
justify-content: center;
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.footer a { color: var(--ink-soft); text-decoration: none; }
.footer a:hover { color: var(--lane); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}

161
internal/web/static/app.js Normal file
View 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();
}
});
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Nine tiles filling up">
<rect width="512" height="512" rx="96" fill="#0E3140"/>
<g fill="#FBFDFD" fill-opacity=".12">
<rect x="52" y="52" width="120" height="120" rx="6"/>
<rect x="196" y="52" width="120" height="120" rx="6"/>
<rect x="340" y="52" width="120" height="120" rx="6"/>
<rect x="52" y="196" width="120" height="120" rx="6"/>
<rect x="196" y="196" width="120" height="120" rx="6"/>
<rect x="340" y="196" width="120" height="120" rx="6"/>
<rect x="52" y="340" width="120" height="120" rx="6"/>
<rect x="196" y="340" width="120" height="120" rx="6"/>
<rect x="340" y="340" width="120" height="120" rx="6"/>
</g>
<!-- Each tile fills from the bottom, the way a day's tile does on the card. -->
<g fill="#3FA9CC">
<rect x="52" y="52" width="120" height="120" rx="6"/>
<rect x="196" y="52" width="120" height="120" rx="6"/>
<rect x="340" y="52" width="120" height="120" rx="6"/>
<rect x="52" y="196" width="120" height="120" rx="6"/>
<rect x="196" y="220" width="120" height="96"/>
<rect x="340" y="244" width="120" height="72"/>
<rect x="52" y="388" width="120" height="72"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB