diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 95944b652..2cb857975 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -14,9 +14,10 @@ updates:
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
#
- # The Next.js line travels alone: while a 16.4 patch is still being
- # re-measured, a red Next bump inside the group holds every harmless
- # update in it red as well.
+ # The Next.js line travels alone. Its bumps are the ones that move
+ # URL state after history traversal (see the note on 16.4 below), and a
+ # red Next bump inside the group would hold every harmless update in
+ # it red as well.
production-weekly:
dependency-type: "production"
update-types:
@@ -32,19 +33,18 @@ updates:
exclude-patterns:
- "eslint-config-next"
- "@next/bundle-analyzer"
- ignore:
- # Next 16.4.0 breaks URL state after history traversal: with a sheet
- # opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
- # do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
- # repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
- # equal). 16.3.8 carries every published security fix, so the line
- # waits for a 16.4 patch; the two packages that move with it wait too.
- - dependency-name: "next"
- versions: ["16.4.0"]
- - dependency-name: "eslint-config-next"
- versions: ["16.4.0"]
- - dependency-name: "@next/bundle-analyzer"
- versions: ["16.4.0"]
+ # No ignore for the Next.js line any more. 16.4.0 lost URL state after
+ # history traversal: with a sheet opened through `?doc=`, Back left it
+ # open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
+ # runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
+ # same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
+ # journey failed once in CI). Both were ours to fix: the sheet followed
+ # `useSearchParams`, which 16.4 updates after the write it copies, and a
+ # day step wrote the URL before Next had adopted the previous step, whose
+ # late adoption then put the older URL back. The sheet now follows the
+ # address bar and a step waits for the layer. Measured after both fixes:
+ # 200 of 200 repeated runs of the three journeys, and another 80 of 80
+ # each for the `?doc=` and day-key journeys.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e0f73c02..0b09a56d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
# Changelog
+## [1.44.1] — 2026-10-22
+
+### Security
+
+- **Next.js 16.4.2.** The framework fixed two critical advisories that
+ allow unauthenticated remote code execution, one in `use cache` functions
+ that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image
+ optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity
+ denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog
+ uses neither `use cache` nor the image component, and it defines no Server
+ Actions and refuses requests that call one. Every instance runs the same
+ framework, though, so it updates anyway.
+- **The image optimizer is off.** Nothing in HealthLog renders through it,
+ so `/_next/image` now answers 404 instead of processing whatever image a
+ request names. Icons and logos load as before.
+- **nodemailer 10.1.7** fixes several advisories in how email addresses are
+ parsed (GHSA-8c8m-p3m8-cjg8 among them).
+
+### Fixed
+
+- **`[` and `]` move the open day on every press.** Pressed quickly in a
+ row, one press could be lost and the day ended one short. A step now
+ starts from the day in the address bar and waits until the previous one
+ is shown.
+- **Back closes a document opened from the vault.** Pressed right after
+ opening a document, Back could leave its sheet open. The sheet now follows
+ the address bar directly.
+
+### Upgrade notes
+
+- None. Pull the new image and restart.
+
## [1.44.0] — 2026-10-22
### Added
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 6cc0471ec..da8fbac66 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.44.0
+ version: 1.44.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/next.config.ts b/next.config.ts
index a844e2081..055b1dcf7 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -35,6 +35,16 @@ const nextConfig: NextConfig = {
// still covers the full program.
typescript: { tsconfigPath: "tsconfig.build.json" },
poweredByHeader: false,
+ // The image optimizer is off. Nothing renders through `next/image`: logos,
+ // icons and avatars are plain `
` elements served from `public/` or
+ // from an authenticated route. With the optimizer on, `/_next/image` still
+ // fetched, decoded and re-encoded whatever local path a request named,
+ // which is surface this app has no use for and where the framework has
+ // shipped critical advisories. Off, Next answers that route with 404
+ // before it reads a single parameter. `next-image-optimizer-guard.test.ts`
+ // holds this: it fails when `next/image` is imported while the optimizer
+ // is off, and when the optimizer comes back with SVG input allowed.
+ images: { unoptimized: true },
// v1.4.38.4 — expose the package.json version to the client bundle
// so the `` can compare the shell-baked version
// against the live `/api/version` response and trigger a self-heal
diff --git a/package.json b/package.json
index 15a5b2028..5f044d763 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.44.0",
+ "version": "1.44.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -79,8 +79,8 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.56.0",
"mmdb-lib": "^3.0.3",
- "next": "16.3.8",
- "nodemailer": "10.1.0",
+ "next": "16.4.2",
+ "nodemailer": "10.1.7",
"otpauth": "^9.5.2",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
@@ -106,7 +106,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
- "@next/bundle-analyzer": "^16.3.8",
+ "@next/bundle-analyzer": "16.4.2",
"@playwright/test": "^1.64.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
@@ -121,7 +121,7 @@
"@vitest/coverage-v8": "^5.0.4",
"ajv": "^8.20.0",
"eslint": "^9",
- "eslint-config-next": "16.3.8",
+ "eslint-config-next": "16.4.2",
"knip": "^6.41.0",
"lefthook": "^2.2.2",
"pdf-parse": "^2.4.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4a51aab68..a33b8b3b9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -120,11 +120,11 @@ importers:
specifier: ^3.0.3
version: 3.0.3
next:
- specifier: 16.3.8
- version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ specifier: 16.4.2
+ version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nodemailer:
- specifier: 10.1.0
- version: 10.1.0
+ specifier: 10.1.7
+ version: 10.1.7
otpauth:
specifier: ^9.5.2
version: 9.5.2
@@ -196,8 +196,8 @@ importers:
specifier: ^4.13.0
version: 4.13.0(playwright-core@1.64.0)
'@next/bundle-analyzer':
- specifier: ^16.3.8
- version: 16.3.8
+ specifier: 16.4.2
+ version: 16.4.2
'@playwright/test':
specifier: ^1.64.0
version: 1.64.0
@@ -241,8 +241,8 @@ importers:
specifier: ^9
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-config-next:
- specifier: 16.3.8
- version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
+ specifier: 16.4.2
+ version: 16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
knip:
specifier: ^6.41.0
version: 6.41.0
@@ -1075,13 +1075,6 @@ packages:
resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==}
engines: {node: '>= 10'}
- '@napi-rs/wasm-runtime@1.2.4':
- resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
- engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
- peerDependencies:
- '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
- '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
-
'@napi-rs/wasm-runtime@1.2.5':
resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -1089,63 +1082,63 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
- '@next/bundle-analyzer@16.3.8':
- resolution: {integrity: sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==}
+ '@next/bundle-analyzer@16.4.2':
+ resolution: {integrity: sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==}
- '@next/env@16.3.8':
- resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
+ '@next/env@16.4.2':
+ resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
- '@next/eslint-plugin-next@16.3.8':
- resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
+ '@next/eslint-plugin-next@16.4.2':
+ resolution: {integrity: sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==}
- '@next/swc-darwin-arm64@16.3.8':
- resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
+ '@next/swc-darwin-arm64@16.4.2':
+ resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.3.8':
- resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
+ '@next/swc-darwin-x64@16.4.2':
+ resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.3.8':
- resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
+ '@next/swc-linux-arm64-gnu@16.4.2':
+ resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.3.8':
- resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
+ '@next/swc-linux-arm64-musl@16.4.2':
+ resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.3.8':
- resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
+ '@next/swc-linux-x64-gnu@16.4.2':
+ resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.3.8':
- resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
+ '@next/swc-linux-x64-musl@16.4.2':
+ resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.3.8':
- resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
+ '@next/swc-win32-arm64-msvc@16.4.2':
+ resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.3.8':
- resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
+ '@next/swc-win32-x64-msvc@16.4.2':
+ resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3348,11 +3341,6 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.11.26:
- resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
baseline-browser-mapping@2.11.27:
resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==}
engines: {node: '>=6.0.0'}
@@ -3459,9 +3447,6 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001814:
- resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
-
caniuse-lite@1.0.30001815:
resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==}
@@ -3961,8 +3946,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-config-next@16.3.8:
- resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
+ eslint-config-next@16.4.2:
+ resolution: {integrity: sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -5177,11 +5162,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@3.3.19:
- resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
nanoid@3.3.20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5199,8 +5179,8 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
- next@16.3.8:
- resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
+ next@16.4.2:
+ resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5241,8 +5221,8 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'}
- nodemailer@10.1.0:
- resolution: {integrity: sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==}
+ nodemailer@10.1.7:
+ resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==}
engines: {node: '>=20.0.0'}
non-error@0.1.0:
@@ -7727,7 +7707,7 @@ snapshots:
'@napi-rs/canvas-win32-arm64-msvc': 1.0.11
'@napi-rs/canvas-win32-x64-msvc': 1.0.11
- '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
@@ -7748,44 +7728,44 @@ snapshots:
'@tybys/wasm-util': 0.10.4
optional: true
- '@next/bundle-analyzer@16.3.8':
+ '@next/bundle-analyzer@16.4.2':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
- '@next/env@16.3.8': {}
+ '@next/env@16.4.2': {}
- '@next/eslint-plugin-next@16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
+ '@next/eslint-plugin-next@16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
fast-glob: 3.3.1
transitivePeerDependencies:
- eslint
- '@next/swc-darwin-arm64@16.3.8':
+ '@next/swc-darwin-arm64@16.4.2':
optional: true
- '@next/swc-darwin-x64@16.3.8':
+ '@next/swc-darwin-x64@16.4.2':
optional: true
- '@next/swc-linux-arm64-gnu@16.3.8':
+ '@next/swc-linux-arm64-gnu@16.4.2':
optional: true
- '@next/swc-linux-arm64-musl@16.3.8':
+ '@next/swc-linux-arm64-musl@16.4.2':
optional: true
- '@next/swc-linux-x64-gnu@16.3.8':
+ '@next/swc-linux-x64-gnu@16.4.2':
optional: true
- '@next/swc-linux-x64-musl@16.3.8':
+ '@next/swc-linux-x64-musl@16.4.2':
optional: true
- '@next/swc-win32-arm64-msvc@16.3.8':
+ '@next/swc-win32-arm64-msvc@16.4.2':
optional: true
- '@next/swc-win32-x64-msvc@16.3.8':
+ '@next/swc-win32-x64-msvc@16.4.2':
optional: true
'@noble/hashes@2.4.0': {}
@@ -9625,7 +9605,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
- '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
@@ -9991,8 +9971,6 @@ snapshots:
base64-js@1.5.1: {}
- baseline-browser-mapping@2.11.26: {}
-
baseline-browser-mapping@2.11.27: {}
bcrypt-pbkdf@1.0.2:
@@ -10120,8 +10098,6 @@ snapshots:
camelcase@5.3.1: {}
- caniuse-lite@1.0.30001814: {}
-
caniuse-lite@1.0.30001815: {}
canvg@3.0.11:
@@ -10644,9 +10620,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
+ eslint-config-next@16.4.2(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
dependencies:
- '@next/eslint-plugin-next': 16.3.8(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
+ '@next/eslint-plugin-next': 16.4.2(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.10(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
@@ -11907,8 +11883,6 @@ snapshots:
nanoid@3.3.18: {}
- nanoid@3.3.19: {}
-
nanoid@3.3.20: {}
napi-postinstall@0.3.4: {}
@@ -11917,25 +11891,25 @@ snapshots:
negotiator@1.0.0: {}
- next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
- '@next/env': 16.3.8
+ '@next/env': 16.4.2
'@swc/helpers': 0.5.23
- baseline-browser-mapping: 2.11.26
- caniuse-lite: 1.0.30001814
+ baseline-browser-mapping: 2.11.27
+ caniuse-lite: 1.0.30001815
postcss: 8.5.23
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.3.0)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.3.8
- '@next/swc-darwin-x64': 16.3.8
- '@next/swc-linux-arm64-gnu': 16.3.8
- '@next/swc-linux-arm64-musl': 16.3.8
- '@next/swc-linux-x64-gnu': 16.3.8
- '@next/swc-linux-x64-musl': 16.3.8
- '@next/swc-win32-arm64-msvc': 16.3.8
- '@next/swc-win32-x64-msvc': 16.3.8
+ '@next/swc-darwin-arm64': 16.4.2
+ '@next/swc-darwin-x64': 16.4.2
+ '@next/swc-linux-arm64-gnu': 16.4.2
+ '@next/swc-linux-arm64-musl': 16.4.2
+ '@next/swc-linux-x64-gnu': 16.4.2
+ '@next/swc-linux-x64-musl': 16.4.2
+ '@next/swc-win32-arm64-msvc': 16.4.2
+ '@next/swc-win32-x64-msvc': 16.4.2
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.64.0
sharp: 0.35.5(@types/node@26.6.5)
@@ -11959,7 +11933,7 @@ snapshots:
node-releases@2.0.58: {}
- nodemailer@10.1.0: {}
+ nodemailer@10.1.7: {}
non-error@0.1.0: {}
@@ -12300,7 +12274,7 @@ snapshots:
postcss@8.5.23:
dependencies:
- nanoid: 3.3.19
+ nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
diff --git a/public/sw.js b/public/sw.js
index 58b5aebf1..9d6ebe388 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.44.0";
+ /* @sw-version-fallback */ "v1.44.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/__tests__/next-image-optimizer-guard.test.ts b/src/__tests__/next-image-optimizer-guard.test.ts
new file mode 100644
index 000000000..a1732e4e6
--- /dev/null
+++ b/src/__tests__/next-image-optimizer-guard.test.ts
@@ -0,0 +1,110 @@
+/**
+ * The image optimizer stays off while nothing uses it.
+ *
+ * `next.config.ts` sets `images.unoptimized`, which makes Next answer
+ * `/_next/image` with 404 before it reads a parameter, so the route that
+ * fetches, decodes and re-encodes images is not reachable at all. Two ways
+ * to undo that quietly, and this pins both:
+ *
+ * 1. A component imports `next/image` (or `next/legacy/image`) while the
+ * optimizer is off. It would render, unoptimized, and the next person
+ * to notice the missing optimization would switch the optimizer back on
+ * without the review this flag deserves. Using `next/image` means
+ * revisiting this decision, so the import fails here first.
+ * 2. The optimizer comes back on with `dangerouslyAllowSVG`. An SVG is a
+ * document, not a picture, and the framework's own advisories have been
+ * about exactly that input. Re-enabling the optimizer is allowed; doing
+ * it with SVG input allowed is not.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import {
+ requireFloor,
+ stripComments,
+ walkSourceFiles,
+} from "./helpers/source-files";
+
+const ROOT = process.cwd();
+const SRC = join(ROOT, "src");
+
+type ImagesConfig = {
+ unoptimized?: boolean;
+ dangerouslyAllowSVG?: boolean;
+};
+
+async function imagesConfig(): Promise {
+ const mod = (await import("../../next.config")) as {
+ default: { images?: ImagesConfig };
+ };
+ return mod.default.images ?? {};
+}
+
+// Any import of the module: static, dynamic, `require`, side effect or
+// re-export. Two literals with one shape rather than one built from a string,
+// so nothing has to be escaped into a pattern.
+const NEXT_IMAGE_IMPORT =
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/(?:legacy\/)?image["']/m;
+const NEXT_LINK_IMPORT =
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)["']next\/link["']/m;
+
+/** Every non-generated, non-test source file under `src/` matching `re`. */
+function importers(re: RegExp): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((rel) => !rel.startsWith("generated/"))
+ .filter((rel) => !rel.includes("__tests__/"))
+ .filter((rel) =>
+ re.test(stripComments(readFileSync(join(SRC, rel), "utf8"))),
+ );
+}
+
+describe("next/image optimizer", () => {
+ it("the import matcher recognises every form of the import", () => {
+ // The sweep below passes when it matches nothing, so the matcher has to
+ // be shown to match before an empty result means anything.
+ for (const line of [
+ 'import Image from "next/image";',
+ "import Image from 'next/legacy/image';",
+ 'const Image = await import("next/image");',
+ 'const Image = require("next/image");',
+ 'import "next/image";',
+ 'export { default } from "next/image";',
+ ]) {
+ expect(NEXT_IMAGE_IMPORT.test(line), line).toBe(true);
+ }
+ for (const line of [
+ "// `next/image` is wrong here",
+ 'const path = "/_next/image?url=x";',
+ 'import { foo } from "next/image-extra";',
+ ]) {
+ expect(NEXT_IMAGE_IMPORT.test(stripComments(line)), line).toBe(false);
+ }
+ });
+
+ it("the same sweep finds the imports of a sibling module", () => {
+ // An empty result for `next/image` is only worth something if the walk,
+ // the comment stripping and the matcher together do find real imports.
+ // `next/link` is imported the same way all over the tree.
+ requireFloor("next/link importers", importers(NEXT_LINK_IMPORT), 100);
+ });
+
+ it("is off, and then nothing imports next/image", async () => {
+ const images = await imagesConfig();
+ if (images.unoptimized !== true) return;
+ expect(importers(NEXT_IMAGE_IMPORT)).toEqual([]);
+ });
+
+ it("is never on with SVG input allowed", async () => {
+ const images = await imagesConfig();
+ if (images.unoptimized === true) return;
+ expect(images.dangerouslyAllowSVG ?? false).toBe(false);
+ });
+
+ it("is off today", async () => {
+ // The two cases above each skip on the other branch. This pins which
+ // branch the tree is on, so flipping it is a visible change to this
+ // file and not a silent switch between the two.
+ expect((await imagesConfig()).unoptimized).toBe(true);
+ });
+});
diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts
index 32eef666c..dcfaaa23c 100644
--- a/src/components/day/__tests__/day-layer-controller.test.ts
+++ b/src/components/day/__tests__/day-layer-controller.test.ts
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
*
* - opening adds exactly one entry, so Back closes the layer;
* - opening another day while one is open swaps it in place;
- * - stepping to the neighbouring day never adds an entry, however often;
+ * - stepping to the neighbouring day never adds an entry, however often,
+ * starts from the day in the URL and never passes today;
* - closing a day the layer opened goes Back (the next Back leaves the
* page); closing a deep link only drops the parameter and stays.
*/
@@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) {
let browser: ReturnType;
+const TODAY = "2026-01-10";
+
beforeEach(() => {
browser = installFakeBrowser("/insights/blood-pressure?range=90");
});
@@ -88,18 +91,63 @@ describe("day layer history", () => {
it("steps without adding history entries", async () => {
const { openDay, stepDay } = await import("../day-layer-controller");
openDay("2026-01-03");
- stepDay("2026-01-03", -1);
- stepDay("2026-01-02", -1);
- stepDay("2026-01-01", 1);
+ stepDay(-1, TODAY);
+ stepDay(-1, TODAY);
+ stepDay(1, TODAY);
expect(browser.length()).toBe(2);
expect(browser.current()).toContain("day=2026-01-02");
});
+ it("steps from the day in the URL, so quick steps in a row all count", async () => {
+ // Nothing republishes the open day between these calls, which is what a
+ // fast key repeat looks like while the layer is still rendering the
+ // previous step. Each step must still move from where the last one went.
+ const { openDay, stepDay } = await import("../day-layer-controller");
+ openDay("2026-01-03");
+ expect(stepDay(1, TODAY)).toBe("2026-01-04");
+ expect(stepDay(1, TODAY)).toBe("2026-01-05");
+ expect(browser.current()).toContain("day=2026-01-05");
+ });
+
+ it("holds a step while the layer still shows another day, then makes it", async () => {
+ // The layer publishes the day it shows only once Next has adopted the
+ // URL. A step written before that can be put back by Next's own write,
+ // so it waits for the layer and goes in one write when it catches up.
+ const { openDay, publishOpenDay, stepDay } =
+ await import("../day-layer-controller");
+ openDay("2026-01-03");
+ publishOpenDay("2026-01-03");
+ expect(stepDay(-1, TODAY)).toBe("2026-01-02");
+ // The layer still shows the 3rd: these two wait.
+ expect(stepDay(1, TODAY)).toBeNull();
+ expect(stepDay(1, TODAY)).toBeNull();
+ expect(browser.current()).toContain("day=2026-01-02");
+ publishOpenDay("2026-01-02");
+ expect(browser.current()).toContain("day=2026-01-04");
+ expect(browser.length()).toBe(2);
+ publishOpenDay(null);
+ });
+
+ it("never steps past today, however often it is asked", async () => {
+ const { openDay, stepDay } = await import("../day-layer-controller");
+ openDay("2026-01-09");
+ expect(stepDay(1, TODAY)).toBe(TODAY);
+ expect(stepDay(1, TODAY)).toBeNull();
+ expect(stepDay(1, TODAY)).toBeNull();
+ expect(browser.current()).toContain(`day=${TODAY}`);
+ });
+
+ it("does nothing while no day is open", async () => {
+ const { stepDay } = await import("../day-layer-controller");
+ expect(stepDay(-1, TODAY)).toBeNull();
+ expect(browser.current()).toBe("/insights/blood-pressure?range=90");
+ });
+
it("closes a pushed day with Back, landing on the page without it", async () => {
const { closeDay, openDay, stepDay } =
await import("../day-layer-controller");
openDay("2026-01-03");
- stepDay("2026-01-03", 1);
+ stepDay(1, TODAY);
closeDay();
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
});
@@ -107,7 +155,7 @@ describe("day layer history", () => {
it("closes a deep link in place: the parameter goes, the page stays", async () => {
browser = installFakeBrowser("/?day=2026-01-03");
const { closeDay, stepDay } = await import("../day-layer-controller");
- stepDay("2026-01-03", -1);
+ stepDay(-1, TODAY);
expect(browser.current()).toBe("/?day=2026-01-02");
closeDay();
expect(browser.current()).toBe("/");
diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts
index 1431ce594..6ce4eb5b7 100644
--- a/src/components/day/day-layer-controller.ts
+++ b/src/components/day/day-layer-controller.ts
@@ -2,11 +2,12 @@
import { useSyncExternalStore } from "react";
-import type { DateKey } from "@/lib/day/contract";
+import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
dayHistoryState,
historyEntryOwnedByLayer,
+ parseDayParam,
shiftDateKey,
withDayHref,
} from "./day-url";
@@ -100,6 +101,13 @@ export function publishOpenDay(date: DateKey | null): void {
if (memory.open === date) return;
memory.open = date;
emit();
+ // The layer has caught up with the URL: the steps that waited for it go
+ // now, in one write, from the day it shows.
+ const waiting = pendingStep;
+ pendingStep = null;
+ if (waiting !== null && date !== null) {
+ stepDay(waiting.delta, waiting.today);
+ }
}
/**
@@ -155,9 +163,50 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
}
}
-/** Move the open day by `delta` calendar days, without a new history entry. */
-export function stepDay(from: DateKey, delta: number): DateKey {
- const next = shiftDateKey(from, delta);
+/**
+ * Steps asked for while the layer had not yet shown the previous one. Kept
+ * as one sum, with the `today` of the latest request.
+ */
+let pendingStep: { delta: number; today: DateKey } | null = null;
+
+/**
+ * Move the open day by `delta` calendar days, without a new history entry,
+ * and never past `today`. Null when nothing is written now: no day is open,
+ * the step would pass today, or it waits for the previous one.
+ *
+ * Two things keep quick steps (a key held down, `]` pressed twice) from
+ * losing one.
+ *
+ * The step starts from the day in the URL, not from the day a caller last
+ * rendered, which reaches it through `useSearchParams`, a render and an
+ * effect and can be a step behind.
+ *
+ * And a step does not write while the layer still shows a different day
+ * than the URL. The write goes past Next's router, which adopts it a render
+ * later, and since Next 16.4 that render can land after a second write and
+ * put the first one's URL back: `[` then `]` pressed quickly ended a day
+ * short. So a step that finds the layer behind waits, and `publishOpenDay`
+ * makes it as soon as the layer shows the day the URL names.
+ */
+export function stepDay(delta: number, today: DateKey): DateKey | null {
+ if (typeof window === "undefined") return null;
+ const from = parseDayParam(
+ new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM),
+ today,
+ );
+ if (from === null) {
+ pendingStep = null;
+ return null;
+ }
+ const total = (pendingStep?.delta ?? 0) + delta;
+ if (memory.open !== null && memory.open !== from) {
+ pendingStep = { delta: total, today };
+ return null;
+ }
+ pendingStep = null;
+ const target = shiftDateKey(from, total);
+ const next = target > today ? today : target;
+ if (next === from) return null;
jumpDay(next);
return next;
}
diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx
index 8c75f2ea3..7a6676e14 100644
--- a/src/components/day/day-layer.tsx
+++ b/src/components/day/day-layer.tsx
@@ -348,8 +348,7 @@ function DayLayer() {
const onStep = useCallback(
(delta: number) => {
if (date === null) return;
- if (delta > 0 && date >= today) return;
- stepDay(date, delta);
+ stepDay(delta, today);
},
[date, today],
);
diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx
index 187ce48a0..ffbb2e535 100644
--- a/src/components/documents/documents-view.tsx
+++ b/src/components/documents/documents-view.tsx
@@ -31,7 +31,14 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
@@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12;
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
+/**
+ * The query string in the address bar, read at render time.
+ *
+ * The open sheet follows `?doc=`, and `useSearchParams` is not a faithful
+ * copy of it: Next 16.4 applies the copy of a `pushState` later than the write,
+ * and a Back pressed in between moves the address bar from `?doc=` to the
+ * page without it while `useSearchParams` can read "no document" on both sides.
+ * The sheet then saw no change and stayed open. `window.location` is never
+ * behind; `popstate` re-reads it on every traversal, and every other render
+ * (including the ones Next's own navigations cause) reads it afresh.
+ */
+function subscribeToTraversal(onChange: () => void): () => void {
+ window.addEventListener("popstate", onChange);
+ return () => window.removeEventListener("popstate", onChange);
+}
+
+function readLocationSearch(): string {
+ return window.location.search;
+}
+
export function documentSelectionHistoryState(
documentId: string,
): Record {
@@ -818,7 +845,13 @@ export function DocumentsView() {
const [detailOpen, setDetailOpen] = useState(false);
const handedOffDocumentRef = useRef(null);
- const rawDocParam = searchParams.get("doc");
+ // Before hydration the server's view of the query stands in for the URL.
+ const locationSearch = useSyncExternalStore(
+ subscribeToTraversal,
+ readLocationSearch,
+ () => searchParams.toString(),
+ );
+ const rawDocParam = new URLSearchParams(locationSearch).get("doc");
const docParam =
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
? rawDocParam
@@ -837,14 +870,14 @@ export function DocumentsView() {
const openDetail = useCallback(
(id: string) => {
- const href = documentSelectionHref(pathname, searchParams.toString(), id);
+ const href = documentSelectionHref(pathname, window.location.search, id);
if (href === null) return;
handedOffDocumentRef.current = null;
setDetailId(id);
setDetailOpen(true);
window.history.pushState(documentSelectionHistoryState(id), "", href);
},
- [pathname, searchParams],
+ [pathname],
);
const handleDetailOpenChange = useCallback((open: boolean) => {
@@ -881,7 +914,7 @@ export function DocumentsView() {
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
window.history,
pathname,
- searchParams.toString(),
+ window.location.search,
docParam,
handedOffDocumentRef.current,
coachLaunch?.closeIntent,
@@ -925,7 +958,6 @@ export function DocumentsView() {
docParam,
moduleEnabled,
pathname,
- searchParams,
]);
// Hover/focus intent prefetches the detail METADATA (never the blob —
diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx
index 71eb14229..1e5953c65 100644
--- a/src/components/layout/global-shortcuts.tsx
+++ b/src/components/layout/global-shortcuts.tsx
@@ -192,12 +192,12 @@ export function GlobalShortcuts() {
setShortcutsHelpOpen(true);
return;
case "day": {
- const date = state.openDay;
- if (date === null) return;
+ if (state.openDay === null) return;
event.preventDefault();
- // No day after today: the layer refuses it, so the step does too.
- if (action.delta > 0 && date >= state.today) return;
- stepDay(date, action.delta);
+ // The step reads the day from the URL, which is ahead of
+ // `openDay` while a previous step is still rendering, and refuses
+ // a day after today itself.
+ stepDay(action.delta, state.today);
return;
}
case "day-toggle":