Commit Graph
17 Commits
Author SHA1 Message Date
Marc-André Bombeck 594898d350 Anchor the document visit-suggestion on a full instant
The suggestion anchored on the document's own date, which the DTO carries
as a date only. The suggest endpoint takes a full instant, so every
document asked the question as `2025-10-14` and got 422 back; the field
then rendered nothing and no document ever offered the visit its date
falls near. Lift the date to noon UTC before asking, the shape the two
lab moments already send.

The e2e that should have caught it never authenticated: the visits spec
carried no storage state, so its `page` redirected to login and its
`request` reads answered 401. It also asserts a global verdict — one
visit in the document's window — so clear the account's visits before
each test, run the file serially, and keep it to one project, since two
projects mutating the shared account would leave two candidates in the
window.

The accessibility mock lagged the same feature: the detail sheet reads
`encounterLinks` off the document and the mock omitted it, so the sheet
threw before it painted. Add the field the server always sends.
2026-08-08 18:46:55 +02:00
Marc-André Bombeck 9eb5db4c35 Close the same hydration race on the three remaining menu triggers
The add-measurement journey and the two accessibility steps that open a
dropdown all did what the water journey did: wait for the network, click the
trigger, then wait for a menu item. All three clicked markup React had not
wired up yet, and the add-measurement one timed out on the trunk's own runner
for exactly that reason.

They open through the same helper now. The documents bulk-bar menu keeps its
plain click deliberately: it fires after selecting fifty rows, so hydration is
not in question there and a retry would only hide a real failure.
2026-08-04 01:52:02 +02:00
Marc-André Bombeck 33fc238305 Scan the dashboard once its charts are in the accessibility tree
An unrevealed chart cell is `invisible` and `aria-hidden`, so the chart row
is not in the accessibility tree at all until the reveal gate fires. Waiting
on the tile strip left it to chance whether the row was read: locally the
cells were still hidden when the strip appeared and revealed six seconds
later, while the runner that reported the broken heading order had already
revealed them. The row is the largest surface on the page, so a scan that
sometimes skips it is not a check of the page.

Gating on a revealed cell makes the coverage the same on every run. With the
heading level put back to its old value the case now fails 16 out of 16
repeats across both themes and both viewports, where the same defect
previously surfaced twice in several full runs.
2026-08-02 19:45:05 +02:00
Marc-André Bombeck 47e50ece39 Wait for content the page owns before scanning it
Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.

The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.

Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
2026-08-02 18:50:31 +02:00
Marc-André Bombeck ab13cfb689 Scan the painted page, not the skeleton standing in front of it
The `/insights/workouts` case gated its axe scan on `#main-content
[role="status"]`. That role is not a content marker: the busy skeletons
carry it too, and the one the streamed route shell paints first is a
`chart-skeleton` with `aria-busy="true"`. The sentinel therefore went
green on a document the page client had not touched yet. A frame trace
under a 20x CPU throttle puts that skeleton on screen at 408 ms and the
page's own `<h1>` at 4119 ms, so for most of four seconds the scan could
land on a tree with no level-one heading and report
`page-has-heading-one` against `html`. That is the failure a loaded
runner has been showing, and the page it accused has the heading.

Gate on the two states the page itself renders instead, the list or the
empty state. The overview case above already excludes `aria-busy`
skeletons the same way.

`/coach` had a smaller version of the same gap: `coach-page` is the
sizing wrapper around a `<Suspense fallback={null}>`, on screen 241 ms
before the conversation under it exists. Gate on the conversation.

Neither sentinel waits for a heading. Removing the `<h1>` from
`SubPageShell`, `CoachHero` and the conversation's screen-reader title
still turns the matrix red with four `page-has-heading-one` violations
per theme.
2026-08-02 05:37:03 +02:00
Marc-André Bombeck 0784cbcbb1 Say how many workouts the list holds and let a sport narrow it
The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.

The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
2026-07-30 23:23:37 +02:00
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck 25f41f88fe feat(ux,settings): undo where it was missing and settings that behave one way
Deleting a measurement or mood entry can be undone from the toast via
new restore endpoints, dialogs submit on enter with busy states, silent
mutations gain error toasts, and the settings area finishes its
consolidation: provider and integration cards live in focused
subcomponents, every setting persists server-side at exactly one home,
and the save conventions are written down. The remaining raw fetches in
settings move onto the shared client.
2026-06-11 02:19:28 +02:00
Marc-André Bombeck dbb5979a97 chore(release): v1.4.39.3 — dashboard slim/thick merge robustness and list-page precision
The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across
chromium-desktop and chromium-mobile (chart-overlay-controls,
dashboard, charts-mobile, measurement-flow) that all routed through
two narrow regressions and one durable test-fixture gap.

### Fixed

- Dashboard slim/thick merge no longer blanks the tile strip when the
  slim slice resolves with an empty summaries record. The v1.4.39.2
  inline `slim?.summaries ?? thick?.summaries` short-circuited on
  JavaScript's truthy-empty-object even when thick carried the full
  payload. The merge moved to a pure `mergeSlimAndThickAnalytics`
  helper that uses object emptiness as the discriminator: populated
  slim still wins on overlapping fields (progressive-paint contract
  preserved), empty slim falls through to thick, both empty leaves
  the dashboard's data-floor gates to render the appropriate empty
  state. Eight new behaviour-level unit tests cover the edge cases.
- `MeasurementList` non-grouped rows now preserve decimal precision
  on both the desktop table and the mobile card grid. The v1.4.37 W7c
  collapsed-list view passed every value through `fmt.integer`, which
  kept the per-day step / activity aggregates correct but silently
  truncated single weight / body-fat / temperature readings — 78.4 kg
  rendered as "78 kg". Grouped rows (cumulative HK type day
  aggregates) stay on `fmt.integer` because their readings are
  integer-only; non-grouped rows now use `fmt.number` which honours
  up to three fraction digits without forcing a minimum.

### Changed

- Playwright route mocks for `/api/analytics` migrate from the
  literal `**/api/analytics` string glob to the regex form
  `/\/api\/analytics(\?|$)/` across every authenticated spec. The
  string glob does not match the sliced URL form
  `/api/analytics?slice=summaries` the v1.4.39.2 dashboard split
  fires alongside the thick request, so the slim request fell
  through to the real route on the seeded test user that returns
  empty summaries. Ten specs migrate: dashboard,
  chart-overlay-controls, measurement-flow, charts-mobile, a11y,
  v1427-responsive-sheet, v1427-coach-launch,
  v1427-measurements-add-param, v1427-insights-empty-state,
  onboarding-tour-passthrough. The regex form is the durable
  alignment with the v1.4.37 W-CI lesson that already swept
  `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`.

### Notes

- No schema migration. Runtime + test-fixture-only changes.
- `pnpm test --run` green at 4662 / 4663 (1 long-standing skip).
- Full Playwright suite green at 116 passing / 36 skipped / 152 total
  on both `chromium-desktop` and `chromium-mobile` profiles, against
  the production Next.js build. Closes every failure from CI run
  26213226723.
2026-05-21 10:31:18 +02:00
Marc-André Bombeck 00848b7ed5 fix(e2e): skip desktop-sidebar specs on mobile + stub /api/dashboard/widgets
Three remaining e2e failures after the global-setup tour-completed fix
all came from cross-project sharing — specs that target desktop-only
DOM (the `aside[aria-label="Sidebar"]` logo + Achievements link)
were running in the chromium-mobile profile too, where the sidebar
is `hidden md:flex` by design. Added `test.skip(viewport.width < 768,
...)` guards so each spec only runs in the project that can satisfy
its contract.

The fourth failure was the `/api/dashboard/widgets` endpoint that the
A5 tile-strip work added since `onboarding-flicker.spec.ts` was
written. Without a stub the unauthenticated dashboard 500s on this
query and React Query's error boundary swallows the page render
before `<GettingStartedChecklist>` mounts. Added the stub to both
flicker specs (complete + incomplete-onboarding paths).
2026-05-10 00:50:00 +02:00
Marc-André BombeckandClaude Opus 4.7 786b3951f6 fix(a11y): skip-link no longer blocks logo click outside focus
The "Skip to content" link is `position: fixed; top-2 left-2 z-[100]`
on every authenticated page. Even with `opacity-0` it still occupied
its bounding box, so a mouse click on the desktop logo (also at the
top-left corner) sometimes hit the invisible link instead.

Fix: keep the visual treatment (opacity, focus styles) but combine
`pointer-events-none` + `-translate-y-full` in the unfocused state so
the element neither receives clicks nor occupies any visible space.
On focus, both modifiers reverse — the link slides into view and
becomes interactive again. Existing keyboard flow is unchanged: Tab
focuses the link, the focus ring + translucent slide-in is preserved.

Tests: extend `e2e/a11y.spec.ts` with a Playwright check that the
desktop logo is clickable from its centre on the dashboard and the
URL stays at "/".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:15:21 +02:00
Marc-André BombeckandClaude Opus 4.7 987ce0d5f6 fix(a11y): clear axe-core violations on /admin and key sub-routes
Re-enable the /admin axe-core spec that phase-3 parked while phase-4b
restructured the admin into per-section dynamic routes. The fixme is
gone, /admin/system-status and /admin/users are added as representative
sub-route audits, and the violations the original audit surfaced are
fixed:

- `src/components/admin/_shared.tsx`: drop the duplicate
  `<PasswordInput>` whose toggle button had no accessible name.
  Re-export the canonical `<PasswordInput>` from
  `src/components/settings/password-input.tsx` (added in phase-3) which
  already carries the `aria-label="Show password" / "Hide password"`
  pair. Affects `/admin/integrations` (bug-report token, web-push VAPID
  private key) and `/admin/users` (password-reset dialog).
- `src/components/admin/user-management-section.tsx`: icon-only edit /
  reset-password / force-logout buttons now carry `aria-label` (axe
  `button-name` is `serious`); the leading icon is `aria-hidden` so the
  accessible name is just the action verb.
- `src/components/admin/status-card-grid.tsx`: drop the placeholder
  `{ label: "—", value: "" }` tuple from the audit-log card. An empty
  `<dd>` next to a "—" `<dt>` is not navigable and the `<dl>` was the
  most likely source of an axe violation on `/admin`. The 3-column grid
  still works with two metrics; the empty cell at the end is unrendered
  because there is no `<div>` for it.
- `src/components/admin/__tests__/status-card-grid.test.tsx`: rework
  the 3-metric assertion to allow 1..3 metrics with non-empty,
  non-em-dash labels so the audit-log change doesn't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:11:01 +02:00
Marc-André BombeckandClaude Opus 4.7 ca3c599e7c test(e2e): extend axe-core to authenticated surfaces; fix login regex
Add /, /settings/integrations to the WCAG 2.1 AA serious+critical
sweep under an authenticated test.use(storageState). /admin is
parked as test.fixme() because the overview page surfaces three
real critical violations (button-name × N, select-name × 1,
colour-contrast × 1) all originating in src/components/admin/**,
which Phase 4b is restructuring concurrently — re-enable the audit
once 4b ships.

Repair pre-existing breakage in login.spec.ts: the "Login with
password" copy was renamed to "Sign in with password" months ago,
but the regex never tracked it. Also disambiguate the strict-mode
violation between the passkey CTA and the password submit (both
match /sign in/) by restricting to button[type=submit].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:49:22 +02:00
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck fdf8dd6eb9 test(e2e): add Playwright smoke suite + axe-core a11y gate + CI (#146)
Builds out the foundation for end-to-end tests against the production
build: Playwright with desktop + mobile projects, axe-core for WCAG
2.1 AA gating, and a GitHub Actions workflow that caches the browser
download and uploads the HTML report on failure.

What lands now (public surfaces only — no DB seed required):

  e2e/version.spec.ts          /api/version returns shape, no auth
  e2e/auth-redirect.spec.ts    proxy gate bounces / → /auth/login etc.
  e2e/login.spec.ts            login form renders + autoComplete hints
                               + invalid-credential error surfaces
  e2e/locale-switch.spec.ts    DE/EN cookie switch, no raw i18n keys
                               leak into the DOM
  e2e/a11y.spec.ts             axe-core fails on serious or critical
                               WCAG 2.1 A/AA violations on /auth/login

The four authenticated flow specs from the v1.4.1 plan
(quick-entry, doctor-report, settings-roundtrip, test-buttons,
onboarding) need a seeded test user; they're tracked as a follow-up
because the seeding pipeline + WebAuthn polyfill they require warrant
their own PR. The smoke specs above cover the proxy, the login surface,
the version healthcheck endpoint, and the locale machinery — i.e.
every path a v1.4.X regression could realistically break without
deeper UAT.

CI workflow (.github/workflows/e2e.yml):
  - postgres:16-alpine service container
  - prisma migrate deploy seeds an empty schema
  - playwright install --with-deps chromium (cached)
  - next build, then `playwright test` against `next start`
  - playwright-report uploaded as an artifact on failure

Quality gates:
  - pnpm typecheck — clean
  - pnpm test — 658/658 pass (vitest excludes e2e/**)
  - pnpm lint — 0 errors
2026-05-08 16:08:02 +02:00