Replace the date picker with a month grid of our own

The day bar had run out of room: two timers and four day controls came to more
than the bar's width on every phone, so it wrapped onto two rows whenever a walk
was running. Shaving pixels off both groups was not enough — even the most
compact form of it only fitted a 428px iPhone Plus.

The way out was structural. The browser's date picker is a sheet that covers
the screen, which is backwards here: the reason to change day is to see what
the figures did on it, and a modal hides exactly the thing you opened it for.
So the date button now opens a small panel under the bar instead — month name
with arrows, locale-ordered weekday initials, six rows of days — with the
overview still on screen and updating as you move through it.

That also solved the width, because Today belongs inside the grid rather than
beside it. The day controls drop from about 204px to 111px, which is enough for
both timers on one row from 320px up; only a 280px foldable cover still wraps.
The bar keeps its wrapping for that case and for large system font sizes.

The hidden input stays as the value everything reads — selectedDay() and every
caller are untouched, and only the input's own picker is no longer opened.

Six rows always, so the panel does not change height from month to month.
Future days are disabled, matching the bar's → being disabled on today. The
week starts where Intl says it does for the reader's locale, falling back to
Monday. Arrow keys walk the grid and pull the neighbouring month into view at
the edges.

The grid arithmetic is checked rather than eyeballed, both week starts across
every month of a year, leap years and year boundaries — including February
2026, which begins on a Sunday and so needs six leading days from January under
Monday weeks. That is the case a naive `1 - getDay()` gets wrong, and it would
have been invisible until somebody happened to open that month.
This commit is contained in:
Alexander Heldt
2026-09-09 04:03:09 +00:00
parent fba0f73717
commit fbacd97da7
5 changed files with 248 additions and 29 deletions
+14 -8
View File
@@ -53,14 +53,20 @@ of them, because logging has to be one tap from wherever you are.
- The day bar carries up to two timers, each of which logs the boundary that
ends what it is counting when tapped: the asleep/awake one, and — only while
a walk is running — the walk. Two of them no longer fit beside the day
controls on a narrow phone, so the bar wraps; the timers and the controls are
each a group, so it wraps between them rather than stranding "Today" on a
line of its own. The bar's height changes when it does and the tab bar sticks
to that height, which is why `--day-bar-h` is kept current by a
`ResizeObserver` rather than measured once. The big card on Today shows
whichever of the two is the more immediate — the walk, when there is one,
since you are necessarily awake on a walk.
a walk is running — the walk. They count in minutes; the big card on Today
keeps the seconds, and shows whichever of the two is the more immediate — the
walk, when there is one, since you are necessarily awake on a walk.
- **The day picker is a month grid of the app's own**, not the browser's. The
native one is a sheet covering the screen, and the reason to change day is to
see what the figures did on it — so this is a small panel under the bar, with
the overview still visible and updating as you move. `←` and `→` stay in the
bar for the common ±1 day; the grid handles jumps and carries *Today*, which
is what freed the width to fit two timers on one row. The hidden
`<input type="date">` remains the value everything reads; only its own picker
is no longer opened.
- The bar can still wrap, and does below about 300px. Its height changes when
it does and the tab bar sticks to that height, which is why `--day-bar-h` is
kept current by a `ResizeObserver` rather than measured once.
- Each tab is a `.tab-panel` wrapper around the existing sections. The
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
+145 -15
View File
@@ -629,6 +629,18 @@
return { walking: latest.type === "walk-start", since: latest.at };
}
// Minutes rather than seconds, for the frozen bar only. Two timers and the
// day controls only fit on one row if the timers are short, and a counter
// ticking every second at pill size is motion rather than information — the
// big card keeps its seconds, which is where you look if you want them.
function formatPillDuration(ms) {
if (ms < 0) ms = 0;
const totalMin = Math.floor(ms / 60000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h}h${String(m).padStart(2, "0")}` : `${m}m`;
}
function formatCounter(ms) {
if (ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
@@ -1048,8 +1060,7 @@
pill.hidden = false;
pill.classList.toggle("asleep", state === "asleep");
pill.classList.toggle("awake", state === "awake");
const counter = formatCounter(Date.now() - ts);
ptime.textContent = counter;
ptime.textContent = formatPillDuration(Date.now() - ts);
icon.textContent = state === "asleep" ? "😴" : "☀️";
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`;
@@ -1085,7 +1096,7 @@
const wtime = document.getElementById("bar-walk-time");
if (!walkSince) { pill.hidden = true; return; }
pill.hidden = false;
wtime.textContent = formatCounter(Date.now() - walkSince);
wtime.textContent = formatPillDuration(Date.now() - walkSince);
pill.title = `Walking since ${formatTime(walkSince)} — tap to log walk end`;
pill.setAttribute("aria-label", `Walking since ${formatTime(walkSince)}. Log walk end.`);
}
@@ -1093,11 +1104,11 @@
function tickBigClock() {
if (bigClockState) {
const ptime = document.getElementById("bar-clock-time");
if (ptime) ptime.textContent = formatCounter(Date.now() - bigClockSince);
if (ptime) ptime.textContent = formatPillDuration(Date.now() - bigClockSince);
}
if (walkSince) {
const wtime = document.getElementById("bar-walk-time");
if (wtime) wtime.textContent = formatCounter(Date.now() - walkSince);
if (wtime) wtime.textContent = formatPillDuration(Date.now() - walkSince);
}
// The card follows whichever of the two it is currently showing.
if (bigClockState || walkSince) {
@@ -2729,7 +2740,7 @@
const day = selectedDay();
const isToday = ymd(day) === ymd(new Date());
document.getElementById("day-next").disabled = isToday;
document.getElementById("day-today").disabled = isToday;
document.getElementById("cal-today").disabled = isToday;
// Year-less date on the picker's face — the year is implicit and the
// saved width keeps the bar on one row on small phones.
document.getElementById("day-date-face").textContent =
@@ -4951,13 +4962,136 @@
// The face opens the hidden input's native picker (iOS 16+ has showPicker;
// focus() is the fallback and is what pops the picker on older iOS anyway).
document.getElementById("day-date-face").addEventListener("click", () => {
// ---------- the month grid ----------
// The browser's own date picker is a sheet that covers the screen, which is
// exactly wrong here: the reason to change day is to see what the figures
// did on it, and a modal hides them. This one is a small panel under the
// bar, so the overview stays visible and updates as you move.
const dayCal = document.getElementById("day-cal");
const dayFace = document.getElementById("day-date-face");
const calMonthEl = document.getElementById("cal-month");
const calWeekdays = document.getElementById("cal-weekdays");
const calGrid = document.getElementById("cal-grid");
let calMonth = null; // first of the month on show
// Which weekday a week starts on, per the reader's locale — Monday in most of
// Europe, Sunday in the US. Intl knows; older engines don't, and the app's own
// day boundaries are local-midnight either way, so Monday is the fallback.
function firstDayOfWeek() {
try {
if (typeof dayPicker.showPicker === "function") dayPicker.showPicker();
else dayPicker.focus();
} catch {
dayPicker.focus();
const loc = new Intl.Locale(navigator.language);
const info = loc.weekInfo || (typeof loc.getWeekInfo === "function" ? loc.getWeekInfo() : null);
if (info && info.firstDay) return info.firstDay % 7; // Intl 1..7 (Mon..Sun) → JS 1..0
} catch { /* not supported; fall through */ }
return 1;
}
function renderCalendar() {
const start = firstDayOfWeek();
const selected = ymd(selectedDay());
const todayYmd = ymd(new Date());
calMonthEl.textContent = calMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" });
// Weekday initials, taken from a real week so they follow the locale.
calWeekdays.innerHTML = "";
for (let i = 0; i < 7; i++) {
const d = new Date(2026, 1, 1 + ((start + i - new Date(2026, 1, 1).getDay()) + 7) % 7);
const cell = document.createElement("span");
cell.textContent = d.toLocaleDateString(undefined, { weekday: "narrow" });
calWeekdays.appendChild(cell);
}
// Always six rows, so the panel doesn't change height from month to month.
const gridStart = new Date(calMonth);
gridStart.setDate(1 - ((calMonth.getDay() - start + 7) % 7));
calGrid.innerHTML = "";
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
const key = ymd(d);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cal-day";
btn.textContent = d.getDate();
btn.dataset.day = key;
if (d.getMonth() !== calMonth.getMonth()) btn.classList.add("other-month");
if (key === todayYmd) btn.classList.add("is-today");
if (key === selected) btn.classList.add("is-selected");
btn.setAttribute("aria-selected", String(key === selected));
// There is nothing to show for a day that hasn't happened, and the bar's
// → is disabled on today for the same reason.
if (key > todayYmd) btn.disabled = true;
btn.setAttribute("aria-label",
d.toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" }));
calGrid.appendChild(btn);
}
}
function openCalendar() {
calMonth = startOfDay(selectedDay());
calMonth.setDate(1);
renderCalendar();
dayCal.hidden = false;
dayFace.setAttribute("aria-expanded", "true");
const sel = calGrid.querySelector(".is-selected") || calGrid.querySelector(".cal-day:not([disabled])");
if (sel) sel.focus();
}
function closeCalendar({ refocus = true } = {}) {
if (dayCal.hidden) return;
dayCal.hidden = true;
dayFace.setAttribute("aria-expanded", "false");
if (refocus) dayFace.focus();
}
function pickDay(key) {
dayPicker.value = key;
closeCalendar();
render();
}
dayFace.addEventListener("click", () => {
if (dayCal.hidden) openCalendar(); else closeCalendar();
});
function shiftCalMonth(months) {
calMonth = new Date(calMonth.getFullYear(), calMonth.getMonth() + months, 1);
renderCalendar();
}
document.getElementById("cal-prev").addEventListener("click", () => shiftCalMonth(-1));
document.getElementById("cal-next").addEventListener("click", () => shiftCalMonth(1));
document.getElementById("cal-today").addEventListener("click", () => pickDay(ymd(new Date())));
calGrid.addEventListener("click", (e) => {
const btn = e.target.closest(".cal-day");
if (btn && !btn.disabled) pickDay(btn.dataset.day);
});
// Arrow keys walk the grid, which is what makes it usable without a mouse;
// moving off the edge of the month brings the neighbouring one into view.
calGrid.addEventListener("keydown", (e) => {
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
if (!step) return;
e.preventDefault();
const from = e.target.closest(".cal-day");
if (!from) return;
const [y, m, d] = from.dataset.day.split("-").map(Number);
const to = new Date(y, m - 1, d + step);
if (ymd(to) > ymd(new Date())) return; // nothing beyond today
if (to.getMonth() !== calMonth.getMonth() || to.getFullYear() !== calMonth.getFullYear()) {
calMonth = new Date(to.getFullYear(), to.getMonth(), 1);
}
renderCalendar();
const next = calGrid.querySelector(`[data-day="${ymd(to)}"]`);
if (next) next.focus();
});
dayCal.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.stopPropagation(); closeCalendar(); }
});
document.addEventListener("click", (e) => {
if (dayCal.hidden) return;
if (!dayCal.contains(e.target) && e.target !== dayFace) closeCalendar({ refocus: false });
});
function shiftSelectedDay(days) {
@@ -4968,10 +5102,6 @@
}
document.getElementById("day-prev").addEventListener("click", () => shiftSelectedDay(-1));
document.getElementById("day-next").addEventListener("click", () => shiftSelectedDay(+1));
document.getElementById("day-today").addEventListener("click", () => {
dayPicker.value = ymd(new Date());
render();
});
document.getElementById("exclude-day").addEventListener("click", () => {
toggleExcludedDay(selectedDay()); // addEvent/deleteEvent re-render for us
});
+1
View File
@@ -1,4 +1,5 @@
[
{ "date": "2026-09-08", "text": "The date now opens a small month grid of the app's own instead of the browser's date picker. The browser's one is a sheet that covers the screen, which is backwards when the reason to change day is to see what the numbers did on it — this one sits under the bar with the overview still visible and updating as you move. ← and → still step a day at a time; the grid is for jumping further, and the Today button now lives inside it. That is what made room for the walk timer and the sleep timer to sit on one row: the top bar no longer splits onto two rows on a phone, except on the very smallest. The two timers count in minutes now rather than seconds — the big card on Today still ticks in seconds, which is where you look if you want them" },
{ "date": "2026-09-08", "text": "A walk in progress now has a timer in the frozen bar at the top, next to the asleep/awake one, counting from when the walk started — so you can see how long you have been out from any tab without going to look. Tapping it ends the walk, the same way tapping the sleep timer logs the sleep boundary. On a narrow phone two timers no longer fit beside the date controls, so the bar splits onto two rows while a walk is on and goes back to one when it ends. The big timer card on Today shows the walk too while there is one — you are awake on a walk either way, so the walk is the more useful of the two" },
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
+17 -2
View File
@@ -135,11 +135,26 @@
so the face button carries a compact year-less date and the real
input stays (visually hidden) as the value + native picker. -->
<span class="day-date">
<button type="button" id="day-date-face" class="ghost"></button>
<!-- Opens the month grid below rather than the browser's own date
picker: that one is a full-screen sheet on a phone, and the
point of changing day here is watching the figures underneath
change with it. The input stays as the value and is never
shown — every read of the selected day still goes through it. -->
<button type="button" id="day-date-face" class="ghost"
aria-haspopup="dialog" aria-expanded="false"></button>
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
<div id="day-cal" class="day-cal" role="dialog" aria-label="Pick a day" hidden>
<div class="day-cal-head">
<button type="button" id="cal-prev" class="ghost icon-btn" aria-label="Previous month"></button>
<span id="cal-month" class="day-cal-month" aria-live="polite"></span>
<button type="button" id="cal-next" class="ghost icon-btn" aria-label="Next month"></button>
</div>
<div id="cal-weekdays" class="day-cal-weekdays" aria-hidden="true"></div>
<div id="cal-grid" class="day-cal-grid" role="grid"></div>
<button type="button" id="cal-today" class="ghost day-cal-today">Today</button>
</div>
</span>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
<button type="button" id="day-today" class="ghost">Today</button>
</span>
</section>
+70 -3
View File
@@ -293,6 +293,70 @@ body::before {
opacity: 0;
pointer-events: none;
}
/* ---------- the month grid ---------- */
/* Anchored under the date button rather than filling the screen the way the
browser's own picker does: changing day is worth doing *because* of the
figures below, so they have to stay in view while you move. Deliberately
kept short six rows of small cells for the same reason. */
.day-cal {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 70; /* over the day bar itself, which is 60 */
width: 268px;
max-width: calc(100vw - 24px);
padding: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.day-cal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 6px;
}
.day-cal-month {
font-weight: 700;
font-size: 0.9rem;
}
.day-cal-weekdays,
.day-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day-cal-weekdays span {
text-align: center;
font-size: 0.7rem;
color: var(--muted);
padding: 2px 0;
}
button.cal-day {
background: transparent;
color: var(--text);
font-weight: 500;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
padding: 6px 0;
border-radius: 8px;
}
button.cal-day.other-month { color: var(--muted); opacity: 0.5; }
button.cal-day:disabled { opacity: 0.25; cursor: default; }
button.cal-day:disabled:hover { filter: none; }
/* Today is outlined, the selected day is filled so "where I am" and "where
now is" stay tellable apart when they are different days. */
button.cal-day.is-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
button.cal-day.is-selected {
background: var(--accent);
color: #fff;
font-weight: 700;
}
.day-cal-today { width: 100%; margin-top: 8px; padding: 7px 10px; font-size: 0.85rem; }
#day-date-face {
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -341,11 +405,14 @@ body::before {
background: var(--surface);
color: var(--text);
align-items: center;
gap: 6px;
padding: 7px 10px;
/* Sized so two of these fit beside the day controls on one row. The big card
is where a timer is meant to be read at size; this is a glance, which is
also why the pills count in minutes and the card keeps the seconds. */
gap: 4px;
padding: 7px 9px;
border-radius: 999px;
font-weight: 700;
font-size: 1rem;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}