diff --git a/src/app.js b/src/app.js
index 1077b7c..364df9e 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1060,8 +1060,47 @@
goalEl.hidden = !goal;
}
+ // Which history rows sit inside a sleep or walk window, so the list can run a
+ // dotted rail down the margin from a start row to its end row. It reuses the
+ // windows the Sleep and Walks panels already draw, so a pair that crosses
+ // midnight is treated the same way here as there. Rows logged in between โ a
+ // pee taken on a walk โ fall inside the bracket, which is the point: the rail
+ // says "this happened during that", not merely "these two are a pair".
+ //
+ // Roles are named for where the row sits in the *rendered* list, which runs
+ // newest-first, so a span's chronologically last event is its top row. A span
+ // whose boundary isn't itself a row here (an ongoing walk, or one that runs
+ // past midnight) leaves that end open, and the rail runs off the list edge
+ // rather than stopping at a row that didn't end anything.
+ function historyRails(events, dayEvents, day) {
+ const rails = new Map();
+ const spans = [
+ ...sleepWindowsForDay(events, day).map(w => ({ ...w, kind: "sleep" })),
+ ...walkWindowsForDay(events, day).map(w => ({ ...w, kind: "walk" })),
+ ];
+ for (const span of spans) {
+ const inside = dayEvents.filter(e => e.at >= span.start && e.at <= span.end);
+ if (inside.length < 2) continue; // nothing to tie to
+ const closedTop = inside[inside.length - 1].at >= span.end;
+ const closedBottom = inside[0].at <= span.start;
+ inside.forEach((e, i) => {
+ const role =
+ i === inside.length - 1 ? (closedTop ? "top" : "mid")
+ : i === 0 ? (closedBottom ? "bottom" : "mid")
+ : "mid";
+ // Sleep is added first, so the (in practice impossible) overlap of a
+ // walk and a sleep paints as the walk.
+ rails.set(e.id, { kind: span.kind, role });
+ });
+ }
+ return rails;
+ }
+
function renderHistory(events) {
- const dayEvents = eventsForDay(events, selectedDay()).reverse();
+ const day = selectedDay();
+ const chronological = eventsForDay(events, day);
+ const rails = historyRails(events, chronological, day);
+ const dayEvents = [...chronological].reverse();
const exNames = exerciseNames();
eventList.innerHTML = "";
if (dayEvents.length === 0) {
@@ -1078,6 +1117,8 @@
li.className = "event";
li.dataset.type = ev.type;
li.dataset.id = ev.id;
+ const rail = rails.get(ev.id);
+ if (rail) li.classList.add("rail", `rail-${rail.kind}`, `rail-${rail.role}`);
li.innerHTML = `
${formatTime(ev.at)}
diff --git a/src/changelog.json b/src/changelog.json
index 00c10bb..739816e 100644
--- a/src/changelog.json
+++ b/src/changelog.json
@@ -1,4 +1,5 @@
[
+ { "date": "2026-08-31", "text": "A sleep or walk pair is now tied together in the History log by a dotted rail down the side, in that pair's colour, running from the start row to the end row. Anything logged in between sits inside the bracket, so a pee taken on a walk reads as having happened during it. A pair still in progress, or one that ran over from yesterday, leaves its end of the rail open" },
{ "date": "2026-08-31", "text": "Added walks: tap ๐ฆฎ Walk start when you head out and ๐ Walk end when you get home, and the time in between is counted as exercise. The pair gets its own row under the sleep buttons. Today's total and the number of walks show in the overview, every walk of the day is listed in a new Walks section (with the age-based rule of thumb of about five minutes per month of age, twice a day), and the daily charts gain a โWalks (minutes)โ bar chart once you have logged one. Like sleep, only the boundary that makes sense is tappable โ no walk running means Walk end is greyed out" },
{ "date": "2026-08-31", "text": "The Log event buttons are grouped into rows now instead of flowing into one grid: ๐ด Sleep start and โฐ Sleep end side by side on their own row, then ๐ฝ๏ธ Ate / ๐ง Pee / ๐ฉ Poo three across, then โ๏ธ Weigh-in and ๐ Note. The two halves of a sleep pair can no longer end up split across a wrap, and no button is left stranded alone on the last row" },
{ "date": "2026-08-24", "text": "The timing charts now show the longest gap as well: each bar keeps its solid stretch from the shortest to the typical gap and fades on out to the longest one of the week. To stop the nightly long gap from squashing the daytime range into a sliver, the bars are stretched so the typical gap always sits dead centre โ left of the middle is sooner than usual, right of it is longer, in every row" },
diff --git a/src/style.css b/src/style.css
index 19373f5..7af3cdf 100644
--- a/src/style.css
+++ b/src/style.css
@@ -496,6 +496,25 @@ textarea { resize: vertical; }
border-radius: 50%;
flex-shrink: 0;
}
+
+/* Rail bracketing a sleep or walk pair in the history log (see historyRails).
+ It is drawn in the section's own padding, left of the cards, so tying rows
+ together costs no layout: nothing indents and no row moves. The anchored end
+ stops at the row's middle, level with its dot; the other ends overshoot the
+ 6px list gap by 4px each so consecutive segments overlap into one line. */
+.event.rail { position: relative; }
+.event.rail::before {
+ content: "";
+ position: absolute;
+ left: -10px;
+ width: 0;
+ border-left: 2px dotted var(--rail, var(--border));
+}
+.event.rail-top::before { top: 50%; bottom: -4px; }
+.event.rail-mid::before { top: -4px; bottom: -4px; }
+.event.rail-bottom::before { top: -4px; bottom: 50%; }
+.event.rail-sleep { --rail: color-mix(in srgb, var(--sleep) 70%, transparent); }
+.event.rail-walk { --rail: color-mix(in srgb, var(--walk) 70%, transparent); }
.event[data-type="sleep-start"] .dot,
.event[data-type="sleep-end"] .dot { background: var(--sleep); }
.event[data-type="eat"] .dot { background: var(--eat); }