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
+146 -16
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) {
@@ -2728,8 +2739,8 @@
function renderDayBar() {
const day = selectedDay();
const isToday = ymd(day) === ymd(new Date());
document.getElementById("day-next").disabled = isToday;
document.getElementById("day-today").disabled = isToday;
document.getElementById("day-next").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
});