diff --git a/README.md b/README.md
index fbb1a34..0c79d37 100644
--- a/README.md
+++ b/README.md
@@ -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
+ `` 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
diff --git a/src/app.js b/src/app.js
index 619b52c..903c65d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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
});
diff --git a/src/changelog.json b/src/changelog.json
index 26a1230..2667b8a 100644
--- a/src/changelog.json
+++ b/src/changelog.json
@@ -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" },
diff --git a/src/index.html b/src/index.html
index cc90f55..6b1d606 100644
--- a/src/index.html
+++ b/src/index.html
@@ -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. -->
-
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/src/style.css b/src/style.css
index 1e03467..a48fe9e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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;
}