mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
chore(release): v1.4.44 — REG-11 dashboard summary fix (iOS Home tile sparkline + value)
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders neither chart nor latest value for BP / Puls / Körperfett when the most recent reading is older than 7 days. Root cause was in `/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both required `measured_at >= sevenDaysAgo`, which returned empty for sparse accounts, leaving the iOS tile with `latestValue: null` + `sparkline: []` to render from. Detail-screen (`/api/measurements/series?kind=…`) escaped because of its broader window. Server-side changes: - `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns latest reading EVER per type, bounded at N_metrics rows). - `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start DESC) so the trailing N daily buckets per type render regardless of age, bounded at SPARK_DAYS × N_metrics. - BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like BodyFat already is — accounts with zero readings ever no longer get empty placeholder tiles. - 4 new regression tests in summary/__tests__/route.test.ts cover sparse accounts, zero-data accounts, within-7d guards. Other develop work bundled in this release: - W4-QoL — copy nits. - W5-WORKOUTS — user source priority threaded through write-time picker + batch route. - W7-WITHINGS-RELIABILITY — response classifier + sync hardening. - W8-OPS — ops phase docs. - B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version cannot drift from CI tag. - env-check workflow — manifest / example lockstep enforcement. Resolves PR #194 (squash-merge produced via local -X theirs strategy because GitHub's UI couldn't auto-resolve develop's post-v1.4.42 contiguous work against the v1.4.42 squash on main; resulting tree is byte-identical to develop's HEAD).
This commit is contained in:
1 parent
9b6e237f5f
commit
b8fd732f3a
61 files changed
+3322
-248
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# =============================================================================
|
||||
# HealthLog -- production .env example
|
||||
# =============================================================================
|
||||
# Mirrors `scripts/env-manifest.json`. The `pnpm check-env --file
|
||||
# .env.production.example` CI step (see `.github/workflows/env-check.yml`)
|
||||
# enforces every variable declared `required: true` in the manifest has a
|
||||
# non-empty placeholder here, so a contributor who introduces a new
|
||||
# required var without updating this file fails the workflow.
|
||||
#
|
||||
# Placeholders are syntactic only -- replace with real secrets before
|
||||
# deploying. The check-env presence rule treats any non-empty,
|
||||
# non-whitespace string as "set", so the `<placeholder>` style below
|
||||
# satisfies the manifest without leaking a usable credential.
|
||||
#
|
||||
# Generate the two crypto secrets locally with:
|
||||
# $ openssl rand -hex 32
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Core -- required for boot
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="postgresql://healthlog:<password>@db:5432/healthlog?schema=public"
|
||||
POSTGRES_PASSWORD="<openssl rand -base64 24>"
|
||||
NEXT_PUBLIC_APP_URL="https://healthlog.example.com"
|
||||
APP_URL="https://healthlog.example.com"
|
||||
ENCRYPTION_KEY="<openssl rand -hex 32>"
|
||||
API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth -- optional integration
|
||||
# -----------------------------------------------------------------------------
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_WEBHOOK_SECRET=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# APNs -- iOS push (optional, all-or-none)
|
||||
# -----------------------------------------------------------------------------
|
||||
# APNS_KEY_ID=""
|
||||
# APNS_TEAM_ID=""
|
||||
# APNS_BUNDLE_ID=""
|
||||
# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest
|
||||
# APNS_KEY_FILE=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
|
||||
# -----------------------------------------------------------------------------
|
||||
# DEPLOY_WEBHOOK_SECRET=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backups -- all-or-none
|
||||
# -----------------------------------------------------------------------------
|
||||
# BACKUP_S3_ENDPOINT=""
|
||||
# BACKUP_S3_BUCKET=""
|
||||
# BACKUP_S3_ACCESS_KEY=""
|
||||
# BACKUP_S3_SECRET_KEY=""
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
@@ -194,6 +194,15 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# v1.4.43 B11 — thread the tag ref into the build as
|
||||
# NEXT_PUBLIC_APP_VERSION. The Dockerfile picks it up as an
|
||||
# ARG/ENV and /api/version reads it at runtime. The build-arg
|
||||
# becomes part of the BuildKit layer cache key, so a release
|
||||
# tag bump invalidates every downstream layer that depends on
|
||||
# it (`pnpm build`) and the prior release's version string
|
||||
# can never survive a cache hit into the new image.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}
|
||||
outputs: |
|
||||
type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: |
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Env-manifest <-> example lockstep
|
||||
|
||||
# v1.4.43 B5 -- enforce that `.env.production.example` stays in lockstep
|
||||
# with `scripts/env-manifest.json`. A contributor adding a new required
|
||||
# variable to the manifest must also add a placeholder to the example
|
||||
# file in the same PR, otherwise the workflow fails with a grep-friendly
|
||||
# list of missing required vars. Optional vars are reported but do not
|
||||
# fail the build -- they exist for operator discovery, not deploy
|
||||
# blocking.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
env-check:
|
||||
name: pnpm check-env against .env.production.example
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Assert example file exists
|
||||
run: |
|
||||
if [ ! -f .env.production.example ]; then
|
||||
echo "::error::.env.production.example is missing -- contributors must keep this file in lockstep with scripts/env-manifest.json. See docs/ops/env-check.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The check-env script exits non-zero when at least one required
|
||||
# variable from the manifest is missing/empty in the example file.
|
||||
# We additionally grep the rendered output for `[MISSING-REQUIRED]`
|
||||
# rows and surface them as GitHub Actions error annotations so the
|
||||
# PR reviewer can see the missing var names without expanding the
|
||||
# log.
|
||||
- name: pnpm check-env --file .env.production.example
|
||||
id: check
|
||||
run: |
|
||||
set +e
|
||||
OUT=$(pnpm --silent check-env --file .env.production.example)
|
||||
EC=$?
|
||||
echo "$OUT"
|
||||
if [ $EC -ne 0 ]; then
|
||||
echo ""
|
||||
echo "::group::Missing required variables"
|
||||
echo "$OUT" | grep -E '^\s*\[MISSING-REQUIRED\]' | while IFS= read -r line; do
|
||||
name=$(echo "$line" | sed -E 's/.*\[MISSING-REQUIRED\] ([A-Z0-9_]+).*/\1/')
|
||||
echo "::error file=.env.production.example::Required env var $name missing from .env.production.example -- add a placeholder so the file stays in lockstep with scripts/env-manifest.json."
|
||||
done
|
||||
echo "::endgroup::"
|
||||
echo ""
|
||||
echo "::error::pnpm check-env reported missing required variables in .env.production.example. Either add a placeholder for each missing var, or downgrade the manifest entry to required:false if the variable is genuinely optional. See docs/ops/env-check.md."
|
||||
exit $EC
|
||||
fi
|
||||
@@ -11,6 +11,9 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# HAR captures from production debugging (may contain auth headers/cookies)
|
||||
*.har
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# W4-QoL-COPY — v1.4.43 phase report
|
||||
|
||||
Branch: `worktree-agent-a2db58cdde5fee530` (pushed).
|
||||
Base: `develop` @ `7daff01a`.
|
||||
Commits: 6 atomic.
|
||||
|
||||
## Six Highs — outcome
|
||||
|
||||
### H1 — DayDrillDown error toast wrong key
|
||||
`src/components/measurements/measurement-list.tsx:1058` now reads
|
||||
`measurements.loadError` in the GET-failure branch. A new key
|
||||
`measurements.loadError` was added inside the existing `measurements.*`
|
||||
namespace of every locale (de/en/es/fr/it/pl). German reads "Fehler beim
|
||||
Laden", English "Could not load measurements".
|
||||
|
||||
Commit `a2bd6fdb`.
|
||||
|
||||
### H2 — German "Provider" / "rate-limited" leak
|
||||
`messages/de.json` only — three locations:
|
||||
- `providerRateLimitTitle` / `providerRateLimitBody` → "Anbieter
|
||||
gedrosselt" / "Anbieter ist temporär überlastet; wieder verfügbar in
|
||||
~5 min."
|
||||
- `:1620 description` → "Anbieter, Modell, API-Schlüssel."
|
||||
- `rawDataOnDescription` / `OffDescription` / `Warning` → "Provider"
|
||||
replaced with "Anbieter" throughout.
|
||||
|
||||
Commit `d8258fc2`.
|
||||
|
||||
### H3 — Persistent failure-kind has no surface
|
||||
Brief allowed only `integration-status-pill.tsx` + locale files. Added a
|
||||
fourth pill state `"warning"` with locale key
|
||||
`settings.integrationPill.warningServerError` ("Verbunden, aber
|
||||
Serverfehler" / "Connected, server error") and an amber
|
||||
`border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow`
|
||||
chip. Variant defaults match `connected` (custom-class instead of
|
||||
shadcn destructive) so the warning visual is distinguishable from the
|
||||
red reconnect chip.
|
||||
|
||||
**Deferred (out of scope per file allow-list):** wiring at
|
||||
`integrations-section.tsx:123-136` — `pillStateFor` still collapses
|
||||
`error_transient` → `"error"`. To enable the warning chip end-to-end,
|
||||
the next phase must:
|
||||
1. Add `lastFailureKind` to `IntegrationStatus` (Prisma migration) and
|
||||
plumb it through `recordSyncFailure` + `getIntegrationStatus`.
|
||||
2. Surface it in the `/api/integrations/status` payload.
|
||||
3. Update `pillStateFor` to return `"warning"` when
|
||||
`failureKind === "persistent"`.
|
||||
|
||||
Commit `d1428650`.
|
||||
|
||||
### H4 — not-found.tsx English-only
|
||||
Switched `src/app/not-found.tsx` to an `async` server component that
|
||||
calls `resolveServerLocale()` + `getServerTranslator()` (already
|
||||
existed under `src/lib/i18n/`). Three new keys per locale under a new
|
||||
top-level `errors.notFound.{title,body,backToDashboard}` namespace.
|
||||
|
||||
Commit `430f5374`.
|
||||
|
||||
### H5 — global-error.tsx bilingual
|
||||
Cannot reach an i18n resolver before root-layout boots, so doubled the
|
||||
four user-facing strings as `Deutsch · English`. Updated heading, body
|
||||
paragraph, both button labels, copy-fallback `window.prompt`, and the
|
||||
unknown-error fallback. No new dependency on the i18n stack — the file
|
||||
remains provider-less by contract.
|
||||
|
||||
Commit `ee0c4e8c`.
|
||||
|
||||
### H6 — relativeMinutes/Hours/DaysAgo singular form
|
||||
Helper `src/lib/i18n/relative-time.ts` now branches on `count === 1`
|
||||
and reads `*AgoOne` vs `*AgoOther`. Each locale's three plural-only
|
||||
keys were split into the two-form pair (matching the existing
|
||||
`dashboard.staleHintWeeksOne` / `Other` pattern). Polish kept a
|
||||
pragmatic singular/plural pair; the helper's two-form contract matches
|
||||
the brief's recipe.
|
||||
|
||||
No other call-site references the old keys (grep confirmed), so the
|
||||
removal is safe.
|
||||
|
||||
Commit `886957a0`.
|
||||
|
||||
## Quality gate
|
||||
|
||||
- `pnpm typecheck` — clean. Required a one-off `pnpm prisma generate`
|
||||
on the worktree because `src/generated/prisma` is git-ignored.
|
||||
- `pnpm lint` — clean.
|
||||
- `pnpm vitest run src/components/settings/__tests__/integration-status-pill.test.tsx src/lib/i18n src/components/measurements` — 28 passed / 1 skipped.
|
||||
- All six `messages/*.json` files parse.
|
||||
|
||||
## File-allow-list discipline
|
||||
|
||||
Touched only: `src/components/measurements/measurement-list.tsx`,
|
||||
`src/components/settings/integration-status-pill.tsx`,
|
||||
`src/app/not-found.tsx`, `src/app/global-error.tsx`,
|
||||
`src/lib/i18n/relative-time.ts`, `messages/{de,en,es,fr,it,pl}.json`.
|
||||
|
||||
Nothing else.
|
||||
|
||||
## Commit-message hygiene
|
||||
|
||||
No `Co-Authored-By: Claude`. No `--no-verify`. No first-name in commit
|
||||
bodies. Marc-voice, English, no AI references.
|
||||
@@ -0,0 +1,122 @@
|
||||
# W7-WITHINGS-RELIABILITY — v1.4.43 phase report
|
||||
|
||||
## Scope shipped (B3 only)
|
||||
|
||||
- **B3 — typed-classification catch-block migration** for the two
|
||||
sibling Withings sync paths (activity + sleep).
|
||||
- Exported `recordWithingsSyncFailure(userId, err)` from
|
||||
`src/lib/withings/sync.ts` so the sibling paths can share the helper.
|
||||
- Migrated `src/lib/withings/sync-activity.ts` to consume the shared
|
||||
helper. Dropped the local `extractWithingsStatus` +
|
||||
`isWithingsRefreshReauthFailure` imports; the helper internally
|
||||
routes through `classifyError` (typed `WithingsApiError` first, regex
|
||||
fallback for un-prototyped errors via pg-boss JSON round-trip).
|
||||
- Migrated `src/lib/withings/sync-sleep.ts` the same way.
|
||||
- BL-P3-2 defence-in-depth — a Withings body-status 403 on either
|
||||
scope-gated endpoint still forces `kind: "reauth_required"`. The
|
||||
response-classifier's body-status taxonomy doesn't include 403 (it
|
||||
isn't a documented Withings response code; only the HTTP layer emits
|
||||
403 for scope-gated resources), so the override stays at the call
|
||||
site rather than being added to the shared `REAUTH_CODES` set — that
|
||||
would have meant a `response-classifier.ts` edit, outside this
|
||||
wave's allow-list.
|
||||
- New regression suite
|
||||
`src/lib/withings/__tests__/sync-typed-classification.test.ts` pins
|
||||
the typed-classification verdict for body-status 100
|
||||
(reauth_required), 102 (reauth_required), 293 (persistent), 601
|
||||
(transient) across both sync paths. The 293 + 601 cases were
|
||||
silently `transient` under the legacy regex chain — the new tests
|
||||
guard against a future refactor reverting to the regex.
|
||||
|
||||
### Quality bar
|
||||
|
||||
- `pnpm lint` — clean.
|
||||
- `pnpm typecheck` — clean.
|
||||
- `pnpm test --run src/lib/withings` — **6 test files, 118 tests,
|
||||
all green** (112 pre-existing + 6 new).
|
||||
|
||||
### File diff surface (strict)
|
||||
|
||||
- `src/lib/withings/sync.ts` — single-keyword `export` added; no
|
||||
behavioural change.
|
||||
- `src/lib/withings/sync-activity.ts` — import set trimmed + catch
|
||||
block refactored.
|
||||
- `src/lib/withings/sync-sleep.ts` — same pattern.
|
||||
- `src/lib/withings/__tests__/sync-typed-classification.test.ts` —
|
||||
new file.
|
||||
- **NO touch** to `src/lib/integrations/status.ts`,
|
||||
`src/lib/withings/response-classifier.ts`, or any other file
|
||||
outside the four listed above.
|
||||
|
||||
## Deferred to v1.4.44
|
||||
|
||||
### B4 — IntegrationStatus surface unification
|
||||
|
||||
**Status:** DEFERRED — scope discipline / context budget.
|
||||
|
||||
**Architecturally-correct path the v1.4.44 wave should pick up:**
|
||||
|
||||
1. The five callers that today hand-roll
|
||||
`recordSyncFailure({ ..., kind, errorCode })` payloads inline
|
||||
(`getValidToken` reauth-park, `syncUserMeasurements` fetch catch,
|
||||
`syncUserActivity` 403 short-circuit, `syncUserSleep` 403
|
||||
short-circuit, plus the route-layer caller in
|
||||
`src/app/api/withings/status/route.ts`) duplicate the
|
||||
classification-to-FailureKind mapping.
|
||||
2. Lift the mapping into a single `withingsFailureKindFor(err)` helper
|
||||
in `src/lib/withings/sync.ts` (or in `response-classifier.ts` if
|
||||
the helper stays pure — preferred). The helper returns
|
||||
`{ kind: FailureKind, errorCode?: string }` directly so each call
|
||||
site collapses to `recordSyncFailure({ userId, integration:
|
||||
"withings", message, ...withingsFailureKindFor(err) })`.
|
||||
3. Migrate `src/app/api/withings/status/route.ts:85` off
|
||||
`isWithingsRefreshReauthFailure(message)` onto the typed helper
|
||||
(the route currently re-parses the message string a second time
|
||||
after the sync layer already classified it).
|
||||
4. Once every call site uses the helper, delete the legacy regex
|
||||
exports `isWithingsRefreshReauthFailure` + `extractWithingsStatus`
|
||||
from `sync.ts`. The route-layer migration in step 3 is the last
|
||||
blocker; once it's gone the regex helpers are unreferenced and can
|
||||
be removed in the same commit.
|
||||
|
||||
### B7 — IntegrationStatus row freshness audit
|
||||
|
||||
**Status:** DEFERRED — scope discipline / context budget.
|
||||
|
||||
**Architecturally-correct path the v1.4.44 wave should pick up:**
|
||||
|
||||
1. Inventory every code path that writes `IntegrationStatus` and
|
||||
compare against the integrations the user can reach from the
|
||||
Settings page. Today the `withings` row is the only one fully
|
||||
wired through the failure ladder; the `apple_health` and `manual`
|
||||
surfaces lean on the same row but never refresh `lastSyncedAt`
|
||||
when ingest succeeds.
|
||||
2. Add a `recordIntegrationSuccess(userId, integration)` helper
|
||||
alongside `recordSyncSuccess` in
|
||||
`src/lib/integrations/status.ts` that ALWAYS bumps
|
||||
`lastSyncedAt` even when the integration doesn't run on a sync
|
||||
cadence (Apple Health is push-only — no `syncUser*` wrapper to
|
||||
hook).
|
||||
3. Wire the helper into the Apple Health batch-ingest endpoint
|
||||
(`src/app/api/healthkit/batch/route.ts` and the per-route
|
||||
counterparts) so the Settings page surfaces a meaningful
|
||||
"last synced" timestamp for the iOS path.
|
||||
4. Add a freshness-staleness probe to the `/api/integrations/status`
|
||||
reader that flags rows where `lastSyncedAt` is older than the
|
||||
integration's expected cadence (Withings: 1h; Apple Health: 24h
|
||||
since iOS only batches on a daily wake). The probe drives an
|
||||
amber-state badge on the Settings card without changing the
|
||||
underlying success / failure / reauth taxonomy.
|
||||
|
||||
### Reason for the split
|
||||
|
||||
The B4 + B7 work touches `src/lib/integrations/status.ts` (B4: helper
|
||||
addition + removal of the regex helpers) and the API surface (B7:
|
||||
freshness probe). Either change is one bucket on its own — bundling
|
||||
all three into v1.4.43 would have meant the wave touched the
|
||||
classifier, the integration-status writer, AND the API reader, well
|
||||
past the "scope discipline" budget the v1.4.43 marathon was
|
||||
calibrated for. B3 was the standalone unit that delivered a
|
||||
measurable de-duplication of the classification logic without
|
||||
expanding the surface, so it shipped on its own and the dependent B4
|
||||
+ B7 work moves to v1.4.44 where it gets the focus it needs.
|
||||
@@ -0,0 +1,45 @@
|
||||
# W8-OPS phase report — v1.4.43
|
||||
|
||||
Branch: `worktree-agent-abf6af166a9d95b13` (pushed).
|
||||
Base: `develop` at `7daff01a`.
|
||||
Commits: 3 atomic, one per B-item.
|
||||
|
||||
## B2 — Widgets audit-ledger rate-limit
|
||||
|
||||
- `src/app/api/dashboard/widgets/route.ts`: added a `Map<key, ts>` dedup memo with a 60 s `(userId, action)` window. The audit-row write is gated through `shouldEmitAuditRow(...)`; the 422 response is unchanged. Opportunistic GC at >512 entries drops expired rows. Exported `__resetAuditDedupMemoForTests` for the unit test.
|
||||
- Test: new case "dedups the audit-ledger write across two sequential 422s for the same user". Two PUTs in succession → exactly one `prisma.auditLog.create` call. Existing W2 tests still green (4 → 5 tests).
|
||||
- Commit: `6301a640 feat(widgets): dedup validation-failed audit rows per user 60s`.
|
||||
|
||||
## B11 — Docker BuildKit cache fix (option 2)
|
||||
|
||||
- `Dockerfile`: `ARG NEXT_PUBLIC_APP_VERSION` + `ENV` in builder and runner stages. Comment notes that the build-arg becomes part of the layer cache key, forcing per-release invalidation.
|
||||
- `.github/workflows/docker-publish.yml`: added `build-args: NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}` to the build-push step.
|
||||
- `src/app/api/version/route.ts`: `process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version`. Whitespace-only env value falls through to the package.json read.
|
||||
- Tests: two new cases — env-var preference + blank-fallthrough. 5 → 7 tests, all green.
|
||||
- Commit: `88e80949 fix(docker): bake CI tag into image, prefer it at runtime`.
|
||||
|
||||
## B5 — pnpm check-env CI integration
|
||||
|
||||
- New `.env.production.example` with placeholder strings for every required manifest var; optional groups commented out. Verified locally: `pnpm check-env --file .env.production.example` exits 0 with 6/6 required `[OK]` rows.
|
||||
- New `.github/workflows/env-check.yml` that runs on push + PR against `main`/`develop`. Runs `pnpm check-env --file .env.production.example`, greps `[MISSING-REQUIRED]` from the rendered output, and surfaces each missing var as a GitHub Actions error annotation with file pinning.
|
||||
- Existing `scripts/__tests__/check-env.test.ts` (19 tests) pin the dedup + anyOf + all-or-none logic; left untouched — no new branches added.
|
||||
- Commit: `1db52d0c ci(env-check): enforce manifest <-> example lockstep`.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck`: green (no output).
|
||||
- `pnpm lint`: green (no output).
|
||||
- Impacted-subset tests: `src/app/api/dashboard/widgets src/app/api/version scripts/__tests__/check-env.test.ts` → 3 files, 31 tests passed.
|
||||
|
||||
## Touched files
|
||||
|
||||
- `src/app/api/dashboard/widgets/route.ts`
|
||||
- `src/app/api/dashboard/widgets/__tests__/route.test.ts`
|
||||
- `src/app/api/version/route.ts`
|
||||
- `src/app/api/version/__tests__/route.test.ts`
|
||||
- `Dockerfile`
|
||||
- `.github/workflows/docker-publish.yml`
|
||||
- `.github/workflows/env-check.yml` (new)
|
||||
- `.env.production.example` (new)
|
||||
|
||||
No unrelated files touched. Marc-voice, no Claude attribution, no `--no-verify`.
|
||||
@@ -0,0 +1,265 @@
|
||||
# v1.4.43 /api/analytics 9s perf investigation
|
||||
|
||||
## Verdict
|
||||
|
||||
**URGENT** — both `/api/analytics` (default + `?slice=summaries`) wait
|
||||
~9 030 ms server-side on every dashboard mount of the live v1.4.42
|
||||
tree. The regression has been latent since v1.4.40 W-WMY-WIRE and was
|
||||
masked through v1.4.41/42 because no other endpoint moved. It is the
|
||||
single largest user-visible regression on the v1.4.42 deploy and must
|
||||
land in v1.4.43.
|
||||
|
||||
## Root cause
|
||||
|
||||
`src/lib/analytics/summaries-slice.ts:731-750`
|
||||
(`computeAvg30LastYearMap`) is an **unbounded `Promise.all` fan-out**
|
||||
over the per-type list of measurement types with data. It was
|
||||
introduced in v1.4.40 W-WMY-WIRE (commit `24568c80`,
|
||||
`perf(summaries-slice): consume monthly rollup buckets for long-window
|
||||
slice`). Both `computeFromRollups`
|
||||
(`src/lib/analytics/summaries-slice.ts:421`) and
|
||||
`computeFromLiveAggregate`
|
||||
(`src/lib/analytics/summaries-slice.ts:584`) call it unconditionally
|
||||
on every slim-slice request.
|
||||
|
||||
On Marc's tenant `typesWithData.length` is ~15 (every measurement type
|
||||
the dashboard tile-strip surfaces). For each type the helper invokes
|
||||
`readBestGranularityRollups(userId, type, 395)`
|
||||
(`src/lib/rollups/measurement-read-wmy.ts:133-155`), which **sequentially**
|
||||
walks `GRANULARITY_FLOORS` from coarsest to finest. With `windowDays=395`
|
||||
the loop tries MONTH (floor 181) first, then WEEK (91), then DAY (0)
|
||||
— up to **three sequential Prisma round-trips per type** when MONTH /
|
||||
WEEK coverage is partial. The only coverage the boot-time inline
|
||||
refresh (`ensureUserRollupsFresh`,
|
||||
`src/lib/rollups/measurement-rollups.ts:632-642`) guarantees is DAY
|
||||
(trailing 90 d); WEEK/MONTH/YEAR depend on the v1.4.35.1 background
|
||||
`rollup-full-backfill` queue and per-write hooks having converged.
|
||||
|
||||
So on a hot dashboard mount with two concurrent `/api/analytics`
|
||||
requests:
|
||||
|
||||
- **Slim** (`computeFromRollups`) holds 3 base slots (narrows /
|
||||
latests / dayBuckets `$queryRaw`) and then **bursts 15 simultaneous
|
||||
`findMany`s** against `measurement_rollups`. Each can serialise into
|
||||
2-3 round-trips on coverage miss.
|
||||
- **Thick** (`buildAnalyticsResponse`) probes coverage, fans out 15-way
|
||||
per-type live walks behind `p-limit(4)` (4 slots), runs two parallel
|
||||
`computeBpInTargetFastPath` calls (4 slots for the rollup pair-read),
|
||||
`correlations-fast-path` (3 slots), `health-score-fast-path` (4
|
||||
parallel slots), plus stand-alone `sleepStages` + `glucoseByContext`
|
||||
findManys (2 slots).
|
||||
- **Background**: each route fires `void
|
||||
ensureUserRollupsFresh(userId)` which itself holds 2 slots for the
|
||||
watermark probe and, when stale, a `recomputeUserRollups` of the
|
||||
DAY window.
|
||||
|
||||
Peak concurrency easily exceeds the `pg.Pool` max of 20 (see
|
||||
`src/lib/db.ts:35`). The W-POOL `p-limit(4)` cap on the per-type
|
||||
live walk inside the thick route (`route.ts:55`,
|
||||
`ANALYTICS_TYPE_FETCH_CONCURRENCY`) was added in v1.4.40 explicitly to
|
||||
prevent this — but the same release added the W-WMY-WIRE fan-out in
|
||||
the slim slice WITHOUT any equivalent cap. The two concurrent routes
|
||||
now drown the pool that v1.4.40 raised from 10→20 specifically to
|
||||
absorb this kind of burst.
|
||||
|
||||
Secondary contributors (audited but NOT the dominant cost):
|
||||
|
||||
- `computeFromRollups`'s three lead `$queryRaw`s are correctly
|
||||
parallelised in one `Promise.all`. They are not the bottleneck.
|
||||
- The thick route's `summarize()` per-type fan-out is correctly capped
|
||||
at 4. Its 425-day cap (`route.ts:264`) is in place.
|
||||
- `probeRollupCoverage` (`src/lib/rollups/measurement-coverage.ts:45`)
|
||||
is one indexed query and lands sub-50 ms in practice.
|
||||
- `glucoseByContext` and `sleepStages` are both bounded to 30 days and
|
||||
narrow projections — fine.
|
||||
- The cache (`caches.analytics`, 60s TTL) is keyed
|
||||
`{userId}|summaries` vs `{userId}|default`. Both miss simultaneously
|
||||
on cold mount; both are single-flighted **per key** but NOT across
|
||||
keys, so the two slices fire concurrently. Correct behaviour, but
|
||||
exposes the pool to the combined fan-out below.
|
||||
|
||||
## Recommended fix
|
||||
|
||||
Two-line minimum-delta change — cap the WMY fan-out with the same
|
||||
`p-limit` discipline v1.4.40 W-POOL applied to the thick route.
|
||||
|
||||
### `src/lib/analytics/summaries-slice.ts`
|
||||
|
||||
Add `import pLimit from "p-limit";` at the top of the file (alongside
|
||||
the existing imports — already a project dependency, used in
|
||||
`src/app/api/analytics/route.ts:1`).
|
||||
|
||||
Replace `computeAvg30LastYearMap`
|
||||
(`src/lib/analytics/summaries-slice.ts:731-750`) with:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* v1.4.43 — cap concurrent per-type WMY reads at 4.
|
||||
*
|
||||
* Pre-fix this helper ran an unbounded Promise.all over every type
|
||||
* the user has data for. On a 15-type tenant the burst held 15+
|
||||
* Prisma slots simultaneously, and with the slim and thick analytics
|
||||
* slices firing in parallel on dashboard mount the combined fan-out
|
||||
* drowned the pg.Pool max=20 even after the v1.4.40 W-POOL raise.
|
||||
*
|
||||
* Capping at 4 keeps the same total work but holds at most 4 slots
|
||||
* for the WMY branch of the slim slice at any moment, leaving
|
||||
* headroom for the concurrent thick slice's own fan-outs.
|
||||
*/
|
||||
const WMY_FANOUT_CONCURRENCY = 4;
|
||||
|
||||
async function computeAvg30LastYearMap(
|
||||
userId: string,
|
||||
types: ReadonlyArray<string>,
|
||||
): Promise<Map<string, number | null>> {
|
||||
const out = new Map<string, number | null>();
|
||||
if (types.length === 0) return out;
|
||||
const limit = pLimit(WMY_FANOUT_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
types.map((type) =>
|
||||
limit(async () => {
|
||||
const value = await computeAvg30LastYearForType(
|
||||
userId,
|
||||
type as MeasurementType,
|
||||
);
|
||||
return [type, value] as const;
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const [type, value] of results) {
|
||||
out.set(type, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
That alone collapses the slim-slice burst back to 4-at-a-time, which
|
||||
matches the thick slice's existing W-POOL cap.
|
||||
|
||||
### Optional follow-up (defer to v1.4.44 if v1.4.43 needs minimum-delta)
|
||||
|
||||
If perf-verify confirms the cap fix is insufficient on its own, two
|
||||
incremental escalations are available:
|
||||
|
||||
1. **Pin the WMY router to the granularity the boot backfill
|
||||
actually mints.** Today `readBestGranularityRollups(395)` falls
|
||||
through MONTH → WEEK → DAY on coverage miss. If the tenant has
|
||||
converged DAY but not MONTH, the helper pays the cost of two
|
||||
miss probes per type before landing on DAY. The cheap fix is to
|
||||
issue a single MONTH-or-DAY read using the per-type coverage map
|
||||
the route already probed (`route.ts:209` /
|
||||
`summaries-slice.ts:221`) instead of a sequential floor walk.
|
||||
See `.planning/round-v1438-perf-analysis.md` §2 for the original
|
||||
"WMY tier sits as write amplification" framing.
|
||||
|
||||
2. **Hoist `computeAvg30LastYearMap` behind a per-(userId,date) cache.**
|
||||
The 395-day window is anchored on `Date.now()` so the result is
|
||||
stable across an entire calendar day. A 24h TTL in
|
||||
`caches.analytics` keyed on a `${userId}|avg30-last-year-${ymd}`
|
||||
shape would collapse the burst to zero on warm days. The current
|
||||
`caches.analytics` envelope already carries the full slice; the
|
||||
per-call sub-helper just doesn't see the cache because it lives
|
||||
inside `computeFromRollups`.
|
||||
|
||||
Neither follow-up is required to close the 9 s regression; the
|
||||
`p-limit(4)` cap is the single minimum-delta land.
|
||||
|
||||
## Expected delta
|
||||
|
||||
Empirical reference points from the v1.4.39 cold-mount trace
|
||||
(`.planning/round-v1439-empirical-trace.md` § B1-B3) and the v1.4.40
|
||||
W-POOL closure show:
|
||||
|
||||
- W-POOL `p-limit(4)` on the thick route's 15-way per-type walk took
|
||||
the pool-wait from 6.5 s → ~1.5 s under a similar burst.
|
||||
- The slim slice's pre-W-WMY-WIRE timing (v1.4.37) was 1.5-3 s cold,
|
||||
<100 ms warm. Adding the 15-way WMY fan-out in v1.4.40 widened the
|
||||
cold-mount window proportionally.
|
||||
|
||||
Projecting the same `p-limit(4)` shape onto the slim slice:
|
||||
|
||||
| state | before | after (est) |
|
||||
|---|---|---|
|
||||
| dashboard mount, both slices cold | 9.0 s + 9.0 s in parallel | ~2.0-3.0 s + ~2.0-3.0 s in parallel |
|
||||
| dashboard mount, slim warm + thick cold | ~9.0 s thick | ~2.0-3.0 s thick |
|
||||
| dashboard mount, both warm (60s TTL hit) | ~50 ms | ~50 ms |
|
||||
| WMY tier converged + cap applied | n/a | <500 ms cold (best case) |
|
||||
|
||||
The cap restores the v1.4.37 target band (1.5-3 s) under combined
|
||||
slim+thick concurrent load. Warm-cache behaviour is unchanged.
|
||||
|
||||
## Test plan
|
||||
|
||||
1. **Unit** — extend
|
||||
`src/lib/analytics/__tests__/summaries-slice.test.ts` with a test
|
||||
that mocks `computeAvg30LastYearForType` and asserts the helper
|
||||
never holds more than 4 in-flight calls simultaneously. Pattern
|
||||
mirrors the v1.4.40 `route.test.ts` test pinning
|
||||
`ANALYTICS_TYPE_FETCH_CONCURRENCY=4`
|
||||
(`src/app/api/analytics/__tests__/route.test.ts` —
|
||||
`pin concurrency cap and pool ceiling`, commit `433874e3`).
|
||||
|
||||
2. **Integration** — add a smoke test that issues a concurrent
|
||||
`?slice=summaries` + default request against a seeded
|
||||
15-type tenant and asserts both resolve under 5 s. Place
|
||||
alongside the existing `route.test.ts` integration suite.
|
||||
|
||||
3. **Wide-event probe** — in production, after deploy, confirm both
|
||||
routes emit `meta.analytics.slim_summaries.path:"rollup"` AND a new
|
||||
`wmy_fanout.concurrency: 4` annotate (add to the helper's annotate
|
||||
call alongside `slim_summaries` block so ops can prove the cap
|
||||
fired). Compare wall-clock to the HAR baseline.
|
||||
|
||||
4. **HAR re-capture** — Marc to re-record the live tree post-deploy
|
||||
and replace `.planning/v1442-postdeploy-new1.har` with the
|
||||
post-fix capture. Both `/api/analytics*` rows should land in the
|
||||
2-3 s band.
|
||||
|
||||
## Related findings
|
||||
|
||||
- **WMY rollup tier dead-write-amplification risk**: the v1.4.38 perf
|
||||
audit (`round-v1438-perf-analysis.md` § 2, P6) flagged that
|
||||
WEEK/MONTH/YEAR rollups are written on every measurement but never
|
||||
read. v1.4.39 W-WMY readers + v1.4.40 W-WMY-WIRE wiring closed
|
||||
that — but the wiring landed without a concurrency cap, which is
|
||||
exactly the regression this report documents. The pattern
|
||||
recurrence (Read-swap should replace, not parallel-run — see the
|
||||
memo `feedback_read_swap_replace_not_parallel.md`) is worth
|
||||
surfacing for the v1.4.43 marathon retrospective.
|
||||
|
||||
- **Boot backfill MONTH/WEEK coverage**: `ensureUserRollupsFresh`
|
||||
(`src/lib/rollups/measurement-rollups.ts:632`) inlines only the
|
||||
DAY refresh. If the per-write hooks or boot backfill have not
|
||||
drained the MONTH partition for a user, every WMY call pays a
|
||||
miss-probe penalty before landing on DAY. The v1.4.35.1
|
||||
`rollup-full-backfill` queue (`enqueueBootTimeRollupBackfill`,
|
||||
`measurement-rollups.ts:688`) should be doing this on each worker
|
||||
boot — operator verification via `pg-boss` queue depth on
|
||||
`ROLLUP_FULL_BACKFILL_QUEUE` would confirm. Not on the v1.4.43
|
||||
critical path; deferrable to a v1.4.44 ops doc.
|
||||
|
||||
- **Cache key splitting**: the analytics cache uses
|
||||
`${userId}|summaries` and `${userId}|default` as separate keys
|
||||
(`route.ts:96` and `route.ts:115`), so the two concurrent dashboard
|
||||
fetches do NOT share a single-flight. Each must compute
|
||||
independently. Acceptable today (different response shapes), but
|
||||
if v1.5 unifies the analytics envelope a single-flight collapse
|
||||
would halve the cold-mount cost outright.
|
||||
|
||||
- **`mood/analytics` at 200 ms (HAR row 3)** is healthy — it does
|
||||
not share the WMY fan-out and serves directly from
|
||||
`mood_entry_rollups` (`src/lib/rollups/mood-rollups.ts`). No
|
||||
action.
|
||||
|
||||
- **`ensureUserRollupsFresh` void-fire**: the v1.4.37.1 hotfix
|
||||
decoupling was correct and remains. The in-flight dedup map
|
||||
(`measurement-rollups.ts:582-588`) handles the concurrent slim+thick
|
||||
case correctly — both join the same promise. No action.
|
||||
|
||||
- **Charts intermittent empty-state ("Erfasse mindestens 3
|
||||
Einträge…")**: traceable to the slim slice's 9 s tail letting the
|
||||
chart's `count >= 3` predicate evaluate against an undefined
|
||||
summary before the response lands. Capping the fan-out closes this
|
||||
symptom as a side effect — the chart will see the populated
|
||||
`summary` within the 2-3 s window TanStack Query's `useQuery`
|
||||
loading skeleton already accommodates. No separate fix needed.
|
||||
@@ -0,0 +1,148 @@
|
||||
# v1.4.43 Mobile UI / responsive audit findings
|
||||
|
||||
## Verdict
|
||||
APPROVE_WITH_FIXES
|
||||
|
||||
Mobile-UI hygiene is in good shape overall — v1.4.27/33/34 mobile passes have closed the major touch-target / iOS-zoom / safe-area gaps. The user-reported "Erfasse mindestens 3 Einträge" finding is a real semantic bug (not a slow-data artifact), and there are a few residual sub-44 px tap targets and motion-handling inconsistencies worth addressing before the tag. None block the release on their own.
|
||||
|
||||
---
|
||||
|
||||
## Critical (must fix before tag)
|
||||
|
||||
### C1 — Chart empty-state fires on the 7-day tab even when the user has lots of data
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/health-chart.tsx:1251` (gate) + `:672-770` (chartData derivation) + `:675` (slice)
|
||||
- **What's wrong**: The empty state `t("charts.emptyStateTitle")` = "Erfasse mehr Messungen, um Trends zu sehen" + `t("charts.emptyStateDescription")` = "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten." paints when `chartData.length >= 1 && chartData.length < 3`. `chartData` is the *daily-aggregated* slice over the active range tab. A user with 50 BP readings in the last 30 days but only 2 distinct days inside the 7-day tab still sees this message even though raw count is ≥ 50. The message is factually wrong in that case (they have many entries; what they don't have is points across distinct days). The same condition holds in `mood-chart.tsx:679` and `medication-compliance-chart.tsx:391`.
|
||||
- **Recommended fix**: Gate the message on raw measurement count (or `summary.count`), not aggregated `chartData.length`. Two options:
|
||||
1. When `chartData.length < 3 && rawCount >= 3`, render a different copy: "Mehr Messtage erforderlich" / "Erfasse an mehreren Tagen für eine Trendlinie" (the underlying constraint is *day diversity*, not entry count).
|
||||
2. Render the chart line anyway with the 1-2 points and skip only the regression overlay — the empty-state was added in v1.4.16 B1a for genuinely brand-new accounts; an established user just doesn't need it.
|
||||
- **Severity rationale**: Marc explicitly flagged this as confusing under "even when data exists". It misleads experienced users into thinking the app forgot their data.
|
||||
|
||||
---
|
||||
|
||||
## High (should fix before tag)
|
||||
|
||||
### H1 — Switch tap area is 18×32 px, well below WCAG 2.5.5
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/ui/switch.tsx:20`
|
||||
- **What's wrong**: `data-[size=default]:h-[1.15rem] data-[size=default]:w-8` = 18.4×32 px. 24 instances across the app (settings sections, sources-section, dashboard-layout-section, coach-settings-sheet, notification toggles). On a touch device a 18 px tall target is one finger-pad's height — the Radix-Switch root *is* the hit area; the label next to it is not part of the tappable region. v1.4.34 IW-G floored Inputs at 44 px but Switches were missed.
|
||||
- **Recommended fix**: Wrap the Switch root in a 44×44 padded `<label>` or extend the Switch component itself with `before:absolute before:inset-[-12px] before:content-['']` to enlarge the hit zone without changing the visual. Alternatively expose a `tapSize="lg"` variant that pads via `relative` + `::after` pseudo-element.
|
||||
- **Severity rationale**: WCAG 2.5.5 violation across the most-used setting toggles. Discovered now while everyone walks Settings on phones.
|
||||
|
||||
### H2 — Comparison-baseline buttons in chart overlay popover are 36 px
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/chart-overlay-controls.tsx:251`
|
||||
- **What's wrong**: `className="h-9 px-1 text-[11px]"` = 36×~36 px buttons inside the per-chart cog popover (3-column grid: `none` / `lastMonth` / `lastYear`). On a mobile chart the popover already lives inside a flyout the user tapped a 44 px cog to open, so undersized targets compound a precision problem.
|
||||
- **Recommended fix**: Bump to `min-h-11 sm:min-h-9 px-2 text-[11px]` so mobile gets 44 px and desktop keeps the dense 36 px row.
|
||||
- **Severity rationale**: Affects every dashboard chart's comparison-toggle, which Marc surfaced for v1.4.21 + v1.4.22 specifically as a recurring mobile irritation.
|
||||
|
||||
### H3 — Mood-form "more options" dropdown trigger is 36 px on mobile
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/mood/mood-form.tsx:143`
|
||||
- **What's wrong**: `<Button size="icon" className="h-9 w-9">` = 36×36 px. Lives inside the bottom-sheet footer where the user has to thumb between Cancel / Save / kebab — same mobile sheet shipped to every quick-add. The medication-form analogue at `medication-form.tsx:781,994` uses `h-11 w-11` correctly; the mood-form lagged behind, and the same legacy size sits on `mood-list.tsx:616` (the *edit*-mode footer mounted on mobile).
|
||||
- **Recommended fix**: Swap both to `className="h-11 w-11"` to match the medication-form pattern.
|
||||
- **Severity rationale**: Bottom-sheet finger ergonomics — exactly the surface mobile users hit most frequently.
|
||||
|
||||
### H4 — Bottom-sheet close-X is 36 px (intentional but inconsistent with other tap-target hardening)
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/ui/sheet.tsx:88` + `dialog.tsx:86`
|
||||
- **What's wrong**: `min-h-9 min-w-9` = 36×36 px. The inline comment notes "intentional exception per dialog primitive's rationale: 44 px would crowd the sheet header out of proportion". Acceptable in isolation, but Marc reported this collision with the settings cog in v1.4.20 post-deploy ("settings cog vs Sheet close-X collision"), and the surrounding chrome (form submit / cancel buttons) is already 44+ px. The argument that 44 px would crowd the header doesn't hold when the header is `p-4 pr-12` — there's room.
|
||||
- **Recommended fix**: Bump to `min-h-11 min-w-11`. The icon stays `size-4`; only the hit zone grows. If the visual crowding really is a problem on a 320 px viewport, gate on `min-h-9 sm:min-h-11`-style breakpoint so phones get the safer target.
|
||||
- **Severity rationale**: 36 px is below WCAG 2.5.5; comment acknowledges the tension but lands on the wrong side for mobile users.
|
||||
|
||||
### H5 — Smooth-scroll calls ignore prefers-reduced-motion (3 sites)
|
||||
- **Files**:
|
||||
- `/Users/marc/Projects/HealthLog/src/components/settings/settings-shell.tsx:172`
|
||||
- `/Users/marc/Projects/HealthLog/src/components/admin/admin-shell.tsx:186`
|
||||
- `/Users/marc/Projects/HealthLog/src/components/insights/coach-panel/message-thread.tsx:197,216`
|
||||
- **What's wrong**: `el.scrollIntoView({ behavior: "smooth" })` / `el.scrollTo({ behavior: "smooth" })` fires unconditionally. `WelcomeCarousel.tsx:126` already shows the right pattern: `behavior: reduceMotion ? "auto" : "smooth"` driven by a media-query hook.
|
||||
- **Recommended fix**: Add a shared helper (`scrollBehaviorForUser()` in `src/lib/motion.ts` or similar) that reads `window.matchMedia('(prefers-reduced-motion: reduce)').matches` and returns `"auto"` when true. Wire all four sites to it.
|
||||
- **Severity rationale**: Motion sensitivity is an accessibility class HealthLog already supports elsewhere (CSS keyframes are gated; Lottie/Framer is `motion-reduce`-aware) — JS-driven scrolling is the lone gap.
|
||||
|
||||
---
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
### M1 — `Loader2 animate-spin` missing `motion-reduce:animate-none` (21 sites)
|
||||
- **Files**: `health-chart.tsx:1249` (chart loading spinner), `settings/account-section.tsx:362,620,711`, `settings/about-section.tsx:214`, `settings/sources-section.tsx:595`, `settings/ai-section.tsx:642,685,706`, `medications/DrugLevelChart.tsx:235`, `admin/backups-section.tsx:365,421,432,462,521`, `admin/api-token-overview-section.tsx:169`, `admin/system-status-section.tsx:231,240`, `doctor-report/doctor-report-dialog.tsx:486,575`.
|
||||
- **What's wrong**: ~16 % of `animate-spin` call sites lack the motion-reduce modifier (107 honor it, 21 don't). The chart's own loading spinner inside the dashboard's tile-strip area is the most visible offender (`health-chart.tsx:1249`).
|
||||
- **Recommended fix**: Sweep all `animate-spin` occurrences to append `motion-reduce:animate-none`. A `<Spinner />` primitive that bakes the class in (similar to ChartSkeleton) would close the gap permanently.
|
||||
- **Severity rationale**: Continuous rotation is the canonical "trigger" animation for motion-sensitive users; missing the modifier on a chart loading-state defeats the rest of the motion-reduce defence.
|
||||
|
||||
### M2 — `phase-config-dialog.tsx` Input + Button are 32 px tall with `text-sm`
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/medications/phase-config-dialog.tsx:237,243`
|
||||
- **What's wrong**: `<Input className="h-8 w-20 text-sm" />` and `<Button className="h-8 w-12 px-0 text-xs" />`. The Input's `text-sm` override defeats the default `text-base md:text-sm` iOS-zoom defence — 14 px input will zoom the viewport on iOS Safari focus. The 32 px height for both is well below 44 px.
|
||||
- **Recommended fix**: Drop the `text-sm` from the Input (let the default win on mobile) and bump heights: `h-11 sm:h-9`.
|
||||
- **Severity rationale**: Phase-config-dialog is medication-tracker P0 surface; iOS zoom-on-focus + tiny buttons compound.
|
||||
|
||||
### M3 — Insights dynamic skeletons don't match loaded content height exactly
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/app/insights/page.tsx:43,55,67`
|
||||
- **What's wrong**:
|
||||
- `DailyBriefing` skeleton `h-48` (192 px) vs loaded content varies (~220-360 px depending on briefing content).
|
||||
- `CorrelationRow` skeleton `h-32` (128 px); loaded grid is 1-col (mobile) or 2-col (md+) of cards each ~250-300 px including disclaimer.
|
||||
- `TrendsRow` skeleton `h-64` (256 px); loaded row is a 3-up grid each ~300+ px with annotation.
|
||||
- **Recommended fix**: Either reserve the larger height (`h-[24rem]` for DailyBriefing, `h-[20rem]` for the others) or use a content-mirror approach like `<ChartSkeleton>` does. The shift is small (skeleton is short, paint is taller — content pushes down, not up) but it does CLS the page on slow networks.
|
||||
- **Severity rationale**: User-facing only on cold mounts with slow networks; ranks as Medium because the v1.4.42 W5 placeholder polish already nailed the chart-tile case.
|
||||
|
||||
### M4 — `correlation-card.tsx` scatter skeleton mismatches loaded aspect ratio on `sm+`
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/insights/correlation-card.tsx:34` + `scatter-correlation-chart.tsx:100`
|
||||
- **What's wrong**: Skeleton is fixed `h-[180px]`; the loaded chart is `aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto`. On `sm+` the loaded chart becomes a 3:2 aspect rectangle — typically 240+ px tall on a 360 px-wide card — so the skeleton is ~60 px shorter than the painted chart. CLS on insights cold mount.
|
||||
- **Recommended fix**: Mirror the chart's class on the skeleton: `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`.
|
||||
- **Severity rationale**: Single below-the-fold spec; corrects a known CLS hot spot.
|
||||
|
||||
### M5 — Dashboard "blocked-then-burst" tile-strip when slim-analytics also lags
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/app/page.tsx:1360-1424` (tile-strip render gate)
|
||||
- **What's wrong**: The grid wrapper only renders when `trendCards.length > 0`. Every tile is gated on `has*` flags read from `data?.summaries?.*?.count`. If both `slim` and `thick` `/api/analytics` are slow (the HAR shows 9 s; slim is normally <1 s but during cache eviction it can be slower too), the user sees the page header + 0 tiles + then 9 s later 7 tiles appear at once. The per-tile `<Suspense>` placeholder at `:1412` only paints if the tile body itself suspends (which the synchronous tile bodies never do today). v1.4.39.2 split the analytics query specifically to fix this; the fix works only when slim is fast.
|
||||
- **Recommended fix**: Two options:
|
||||
1. Render a "tile-strip skeleton" (N greyed-out cards keyed off the layout's visible-tile count) when `trendCards.length === 0 && analyticsSlimQuery.isLoading && layout.widgets.some(w => w.tileVisible)`. The user sees the strip silhouette during the slow window.
|
||||
2. Treat the 9 s thick fetch as a perf bug to chase separately (v1.4.43 audit-data scope) and rely on slim being fast.
|
||||
Either way: when only thick is slow, partial-paint the strip from slim and avoid waiting on thick.
|
||||
- **Severity rationale**: Marc reported the "etwas nervig" feel in v1.4.39 already; the slim/thick split was supposed to close it, but a thick-slow scenario still re-creates the impression on cold mounts.
|
||||
|
||||
### M6 — Chart's `aria-busy` reuses `isLoading` flag but the empty-state branch silently swallows announcements
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/health-chart.tsx:1095,1247`
|
||||
- **What's wrong**: `if (!isLoading && !data?.length) return null;` (line 1095) returns NO DOM when data is empty after load. The chart's containing widget shows nothing at all — no empty card, no skeleton, no message. Combined with the empty-state-on-1-2-points branch at line 1251, the chart's user-visible states are: skeleton (loading), 1-2 points → "Erfasse 3 Einträge", 3+ points → chart line, 0 points → nothing-at-all. The 0-points branch is unexpected ergonomically (other Recharts wrappers paint a "Keine Daten" message).
|
||||
- **Recommended fix**: Replace `return null` with a "no data in this window" state that mirrors `<ChartEmptyState>` styling. Different copy than the < 3-points branch to avoid the C1 confusion.
|
||||
- **Severity rationale**: User reads "the dashboard is broken" instead of "this range has no data for me".
|
||||
|
||||
---
|
||||
|
||||
## Low (defer to v1.4.44)
|
||||
|
||||
### L1 — Insights tab-strip group-popover items at 40 px instead of 44 px
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/insights/insights-tab-strip.tsx:380`
|
||||
- **What's wrong**: `className="flex min-h-10 items-center …"` = 40 px sub-page links inside the group popover. Outer tab pills meet the 44 px floor; inner popover items are 4 px short.
|
||||
- **Fix**: Bump to `min-h-11`.
|
||||
|
||||
### L2 — `chart-overlay-controls.tsx` dropdown trigger uses `size="sm"` + `min-h-11 min-w-11 px-0` mix
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/charts/chart-overlay-controls.tsx:101-102`
|
||||
- **What's wrong**: `size="sm"` (32 px default) stretched to 44 px via `min-h-11 min-w-11 px-0`. Same v1.4.27 fix pattern Marc later called klobig in dashboard-layout-section. Visually correct but the height stretch is the wrong fix shape now; v1.4.33 maintainer-item-7 settled on `size="default" min-h-11 sm:min-h-9`.
|
||||
- **Fix**: Adopt the responsive `min-h-11 sm:min-h-9` pattern consistently.
|
||||
|
||||
### L3 — `bugreport/page.tsx` and `medication-form` JSON import textarea inline-styled
|
||||
- **Files**: `/Users/marc/Projects/HealthLog/src/app/bugreport/page.tsx:212`, `medications/page.tsx:475`, etc.
|
||||
- **What's wrong**: Each textarea has its own copy-pasted className with the `text-base sm:text-sm` iOS-zoom defence. Maintained six places (audit grep shows three more inside form components). Drift risk.
|
||||
- **Fix**: Introduce a `<Textarea>` primitive in `src/components/ui/textarea.tsx` (parallel to `<Input>`) that bakes in the defence + autocomplete defaults.
|
||||
|
||||
### L4 — `next/dynamic` loading skeletons miss `motion-reduce:animate-none` on insights page
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/app/insights/page.tsx:43,55,67`
|
||||
- **What's wrong**: `animate-pulse` without `motion-reduce:animate-none`. The chart skeleton primitives have it; the insights mother-page skeletons skipped it.
|
||||
- **Fix**: Append `motion-reduce:animate-none` to all three.
|
||||
|
||||
### L5 — `tabIndex={0}` on `injection-site-picker.tsx:178`
|
||||
- **File**: `/Users/marc/Projects/HealthLog/src/components/medications/injection-site-picker.tsx:178`
|
||||
- **What's wrong**: Explicit `tabIndex={0}` on a `<div role="button">` is fine, but the element should be a `<button>` to inherit native semantics — easier than maintaining the keyboard handler manually.
|
||||
- **Fix**: Use `<button type="button">` instead of `<div tabIndex={0} role="button">`.
|
||||
|
||||
### L6 — Recent-workouts tile placeholder + drug-level loading don't pre-reserve height
|
||||
- **Files**: `RecentWorkoutsTile` (no reserved height during initial fetch), `medications/DrugLevelChart.tsx:235`
|
||||
- **What's wrong**: Both render `<Loader2 spinner />` centered inside a `Card` without a reserved minimum height — content pops into place once data lands.
|
||||
- **Fix**: Add `min-h-[X]` to the loading branch matching the loaded card's typical height.
|
||||
|
||||
---
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Touch-target hygiene mostly green** — `Input.tsx` floors at 44 px on mobile (`h-11 sm:h-10`); `Button` icon variants documented and most call sites override correctly (settings drag-handles `size-11 sm:size-9`, medication-card `min-h-11 min-w-11`, coach-drawer header buttons `size-11`); bottom-nav primary + overflow pills correctly at 44 px each with safe-area-inset awareness.
|
||||
- **iOS zoom defence** — `Input.tsx` defaults to `text-base md:text-sm`; coach-input textarea + bugreport textarea + side-effects textarea + admin-feedback textarea all explicitly pin `text-base` on mobile.
|
||||
- **Safe area + viewport-fit** — `layout.tsx` sets `viewportFit: "cover"`; bottom-nav uses `pb-[env(safe-area-inset-bottom)]`; FAB sits at `bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]`.
|
||||
- **prefers-reduced-motion** — globals.css guards `.animate-insight-in` + `.pulse-dot`; ChartSkeleton notes the `motion-reduce` modifier; the `WelcomeCarousel` pattern is the gold standard for JS-driven scroll.
|
||||
- **Per-tile Suspense fallbacks** — v1.4.41 W-FRONTEND-FACTORY layout-stable `min-h-[6rem]` placeholders are correct; ChartSkeleton mirrors loaded `<HealthChart>` height to within a pixel (`h-[var(--chart-height,…)]`).
|
||||
- **Responsive grid track** — dashboard tile strip's `repeat(auto-fit, minmax(min(100%,11rem),1fr))` scales cleanly from 280 px (Galaxy Fold, 1 col) up to 1920 px (8 cols) without breakpoints.
|
||||
- **Bottom-nav 5+More pattern** — well-thought-out compromise (4 core + Insights pill + overflow sheet); WCAG 2.5.5 floor honoured per-pill.
|
||||
- **iOS-specific footgun coverage** — `inputMode` derivation in `Input.tsx`, `enterKeyHint` on coach-input, `autoCapitalize="sentences"` on free-text textareas, status-bar styling for PWA standalone.
|
||||
@@ -0,0 +1,152 @@
|
||||
# v1.4.43 Quality-of-life audit findings
|
||||
|
||||
## Verdict
|
||||
APPROVE_WITH_FIXES
|
||||
|
||||
Net assessment: the product has a strong empty-state + onboarding spine, layout-stable Suspense boundaries, and consistent shadcn primitives. The paper cuts that compound — Provider-jargon leaks into German UI, ungrammatical singular/plural strings, an unstyled English-only 404, the new v1.4.42 `persistent` integration state has no user surface, and a global-error chunk-load shows a sub-card error rather than the user-friendly "we'll reload" copy — are 1–4-line edits each. No regressions block v1.4.43, but six items would significantly cheapen the product's "feels rough" tax.
|
||||
|
||||
## Critical (must fix before tag)
|
||||
|
||||
None. No findings rise to release-blocker severity.
|
||||
|
||||
## High (should fix before tag)
|
||||
|
||||
### H1 — DayDrillDown error renders `measurements.saveError`, copy mismatch
|
||||
- `src/components/measurements/measurement-list.tsx:1056-1060` — the per-day drill-down query's error branch surfaces `t("measurements.saveError")` ("Fehler beim Speichern" / "Save error") but the failing operation is a `GET`, not a save. Users see "Save error" when their step-history drill-down fails.
|
||||
- Recommended: introduce `measurements.loadError` ("Fehler beim Laden" / "Could not load measurements") and route the catch-block through it. Affects EN + DE.
|
||||
- Severity rationale: visible on every Apple-Health-heavy account that touches the cumulative-type chevron when the network falters. Cheap fix; copy bug, not architecture.
|
||||
|
||||
### H2 — "Provider" + "rate-limited" leak through German UI
|
||||
- `messages/de.json:1136-1137`: `providerRateLimitTitle: "Provider rate-limited"`, `providerRateLimitBody: "Provider ist temporär überlastet; Reset in ~5 min."`. Both terms are English jargon in a German string.
|
||||
- `messages/de.json:1062`: `errorProvider: "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen…"` — already migrated to "Anbieter"; the v1.4.25 rate-limit copy missed the same migration.
|
||||
- `messages/de.json:1619`: settings AI section description `"Provider, Modell, API-Key."` — same.
|
||||
- `messages/de.json:1757-1759`: `rawDataOnDescription`/`rawDataOffDescription`/`rawDataWarning` reference "Provider".
|
||||
- Recommended: rename "Provider" → "Anbieter", "rate-limited" → "gedrosselt" or "temporär überlastet", "Reset" → "wieder verfügbar". Per the [Marc voice memo](feedback_marc_voice_english.md), no English jargon in German UI.
|
||||
- Severity rationale: violates the Umlaute-required / German-UI-purity rule already pinned in user memory. Six-line copy edit.
|
||||
|
||||
### H3 — v1.4.42 `persistent` state has no user surface
|
||||
- `src/lib/withings/response-classifier.ts:59` introduces a fourth `FailureKind`: `persistent` (for Withings rate-limit `601` / contract-mismatch `293/294`).
|
||||
- `src/lib/integrations/status.ts:218-222` maps it to `state="error_transient"` in the DB — the user-facing column.
|
||||
- `src/components/settings/integration-status-pill.tsx:117-136` collapses both `error_transient` and `error_reauth` to the same `"error"` pill label "Fehler — neu verbinden". A `persistent` failure (operator misconfig, contract mismatch) reads identically to a transient one.
|
||||
- Recommended: either (a) plumb a third pill state `"warning"` for `persistent` so the user can see "Verbunden, aber Server liefert ungültige Daten" without telling them to reconnect — that's misleading because the access token still works, or (b) extend `IntegrationErrorMessage` to display `viewModel.failureKind` so a user can read "Withings antwortet mit Fehlercode 293 — bitte den Hosting-Operator kontaktieren". (a) is the v1.4.42-promised behaviour; the wave landed the classifier without the surface.
|
||||
- Severity rationale: the whole point of the v1.4.42 classifier change was operator visibility; right now the operator gets the audit-log trail but the user still sees "reconnect" copy that won't fix the problem. A user clicking the reconnect link 10 times learns nothing.
|
||||
|
||||
### H4 — `not-found.tsx` is English-only
|
||||
- `src/app/not-found.tsx:32-44`: hard-coded English strings ("Page not found", "The page you were looking for doesn't exist…", "Back to dashboard"). No `useTranslations()` call.
|
||||
- Recommended: lift the four strings into `messages/{de,en,…}.json` under `errors.notFound.{title,body,backToDashboard}` and import `useTranslations` in the page. Page is now a client component anyway? — it's currently a server component, so the translation has to come via `getServerTranslations()` (the helper exists for the doctor-report PDF path; reuse it).
|
||||
- Severity rationale: a logged-in German user landing on a typo'd URL sees only English. Self-evidently inconsistent.
|
||||
|
||||
### H5 — `global-error.tsx` is English-only
|
||||
- `src/app/global-error.tsx:36-117`: by contract this fires when even the root layout fails (no i18n provider yet). The English-only design is intentional, but the copy "Something went wrong" / "A critical error occurred" reads cold to a German user already in a bad state.
|
||||
- Recommended: bilingual lockup ("Etwas ist schiefgegangen / Something went wrong", "Ein kritischer Fehler ist aufgetreten / A critical error occurred"). 6 lines of static text, no runtime cost.
|
||||
- Severity rationale: matches the same fix landed for the legacy 404 in v1.4.27 MB6. The boundary itself can't read context; the static fallback should be bilingual.
|
||||
|
||||
### H6 — `relativeMinutesAgo` / `relativeHoursAgo` / `relativeDaysAgo` have no singular form
|
||||
- `messages/de.json:1140-1142` + `messages/en.json:1140-1142`: each "ago" key carries only the plural form. `formatRelativeTime` (`src/lib/i18n/relative-time.ts:20-24`) passes `count: 1` and the user reads "vor 1 Minuten" / "1 minutes ago" / "1 days ago".
|
||||
- The same project did get this right for the stale-hint where the code threads `count === 1 ? "...One" : "...Other"` (`dashboard.staleHintWeeksOne` vs `staleHintWeeksOther`). The pattern needs to extend to the three remaining keys.
|
||||
- The integration-status pill (`integrationPill.{minutes,hours,days}Ago`) lives separately and uses abbreviated units ("vor 1 min" / "vor 1 Std.") — abbreviated forms read OK at count=1, so the pill itself is fine.
|
||||
- Recommended: split each into `…One` / `…Other`, update `formatRelativeTime` to branch on `count === 1`. Mirror across all six locales.
|
||||
- Severity rationale: visible everywhere — hero strip "last update", daily briefing time-since-load, conversation history rail timestamps. Tiny grammar paper-cut, but pervasive.
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
### M1 — Dashboard widget reorder via arrow buttons, not drag-and-drop
|
||||
- `src/components/settings/dashboard-layout-section.tsx:320-343`: each widget row exposes ↑ / ↓ buttons to reorder. Modern UX expectation (Apple Health, Withings web) is drag-to-reorder.
|
||||
- The current implementation is keyboard-accessible — which drag-and-drop libraries famously aren't without extra work — so the arrow-button approach has a legitimate a11y story.
|
||||
- Recommended (defer-worthy but worth noting): if drag-to-reorder lands later, keep the arrow buttons as the accessible fallback. For v1.4.43 the only edit is a tooltip on each button explaining "Verschieben Sie diese Kachel nach oben/unten" instead of just the aria-label. The current aria-label "Nach oben" is short to the point of cryptic for first-time users.
|
||||
- Severity rationale: not broken, just non-modern. Defer to v1.4.44 with intent.
|
||||
|
||||
### M2 — No user-facing AI Coach disable toggle
|
||||
- `src/lib/feature-flags/index.ts:25,115`: `flags.coach` is server/admin-only via `getAssistantFlags()`. A user cannot disable the Coach for themselves; if the operator has enabled it globally, the FAB and drawer are everywhere.
|
||||
- `messages/de.json:1539`: settings copy mentions a research-mode disable but no AI-coach disable.
|
||||
- Recommended: add a per-user `disableCoach` toggle to `Settings → AI` with copy "Coach ausblenden". Defaults to off. The CoachFab + drawer mount guards on `flags.coach && !user.disableCoach`.
|
||||
- Severity rationale: privacy-leaning users want this; it's also the answer to "I never use the Coach, why is it taking space on every screen?" The audit-scope brief explicitly listed this as a Settings-findability item.
|
||||
|
||||
### M3 — Account-section danger zone has no separate "Delete account" CTA
|
||||
- `src/components/settings/advanced-section.tsx:240-321`: the danger zone "DELETE" button deletes all data (`/api/settings/data` DELETE) but leaves the account row alive (passkeys, profile, audit log). A user reading "Gefahrenzone" / "Danger Zone" expects "delete my account entirely" — what the button does is half that.
|
||||
- Recommended: split into two destructive actions — "Alle Gesundheitsdaten löschen" (current behaviour) and "Konto vollständig löschen" (cascade-delete user). The copy currently says "deine Daten" without disambiguating; users have asked for the full-account-delete via the bug-report channel.
|
||||
- Severity rationale: ambiguous destructive UX is a known compliance flag (GDPR Article 17 right to erasure). The current button is GDPR-compliant only if you read the copy carefully.
|
||||
|
||||
### M4 — Doctor-report unavailable sections vanish rather than show "no data"
|
||||
- `src/components/doctor-report/doctor-report-dialog.tsx:305-307,549-563`: when a section has no data in the selected range, the toggle row is filtered out entirely. The user has no signal that "Compliance" was an option but is empty for this date range.
|
||||
- Recommended: render the missing sections as a disabled toggle with strike-through copy + tooltip "Keine Daten in diesem Zeitraum". The user immediately understands why their report has 5 sections instead of 7 and what range to pick to get the 7th.
|
||||
- Severity rationale: the doctor-report PDF is the flagship export. Silent omission is worse than explicit absence.
|
||||
|
||||
### M5 — No offline / network-error banner
|
||||
- The PWA service worker (`public/sw.js:1-50`) caches the shell but there's no `navigator.onLine`-based "you're offline" banner. A user toggling airplane mode mid-form-fill sees blank tiles and no explanation.
|
||||
- Recommended: a slim `<OfflineBanner>` mounted in `auth-shell.tsx` that listens for `online`/`offline` window events and renders "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist". The service worker can already serve the read paths; the banner closes the explanation gap.
|
||||
- Severity rationale: progressive enhancement deferred; the PWA promises offline-friendly but the UX doesn't acknowledge offline. Low-cost to ship, high-comfort signal.
|
||||
|
||||
### M6 — Coach error copy "Provider rate-limited" not user-friendly
|
||||
- `errorCodeToI18nKey` in `src/components/insights/coach-panel/message-thread.tsx:78-95` maps multiple distinct provider failures (`coach.provider.unavailable`, `coach.network`, `coach.stream`, `coach.provider.empty`) to one bucket (`insights.coach.errorProvider`). A user sees "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen" whether the network is dead, the provider is down, or their own connection is flaky.
|
||||
- Recommended: split into `coach.network` → "Keine Internetverbindung — versuche es erneut, sobald du online bist" vs the others. Network errors are the user's problem and they need a different next action.
|
||||
- Severity rationale: paper cut; a sophisticated user already infers from context, but a new user sees the same copy in three different failure modes.
|
||||
|
||||
### M7 — German integration pill uses "T." for "Tage" — confusing
|
||||
- `messages/de.json:1896`: `daysAgo: "vor {count} T."`. The "T." abbreviation is non-standard and reads as a typo. Compare with the existing "Std." (Stunden) which is at least recognised shorthand.
|
||||
- Recommended: either spell out "Tage" / "Tag" (with the singular fix from H6) or use the more conventional `d` (matches the rest of the app's `staleHint` `vor Xd` pattern).
|
||||
- Severity rationale: minor copy inconsistency; nobody is confused for long but it reads cheap.
|
||||
|
||||
### M8 — Sub-locale users (fr/es/it/pl) get English number formats
|
||||
- `src/lib/format.ts:21-32`: `activeLocale()` returns `'de' | 'en'` only. A French user reading the dashboard sees French strings but `formatDateTime` produces "12/24/2025, 2:30 PM" via the `en` fallback.
|
||||
- The codebase has a parallel `useFormatters()` hook in `src/lib/i18n/context` that handles all locales correctly — `src/lib/format.ts` is the legacy path used by ~25 call sites (server-rendered SSR strings, audit-log helpers).
|
||||
- Recommended: migrate `format.ts`'s `activeLocale` to read the full `Locale` union, with `Intl.DateTimeFormat` doing the heavy lifting. Fallback to `en` only when the cookie is unrecognised.
|
||||
- Severity rationale: known fallback per the [Marathon docs](v1.4.38 multi-locale ship); v1.4.43 should close this since the non-maintained locales are growing.
|
||||
|
||||
## Low (defer to v1.4.44)
|
||||
|
||||
### L1 — `not-found.tsx` and `global-error.tsx` use static raw text instead of i18n keys
|
||||
- Already covered as H4/H5 but parallel issue: even when localised, the messages are wordy ("The page you were looking for doesn't exist or has been moved.") Marc's voice memo says copy should be tight + professional; the current shape is consumer-app-marketing. Recommended: "Diese Seite existiert nicht." / "This page doesn't exist." period.
|
||||
|
||||
### L2 — Getting-started checklist measurement threshold = 1
|
||||
- `src/lib/onboarding/checklist.ts` (referenced from `getting-started-checklist.tsx`) defines the measurement-completion threshold as `count >= 1`. The trend tile needs ≥5 measurements to draw, so a user clears the checklist's "first measurement" before they get visual reward on the dashboard. The "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen" copy in `messages/de.json:1510` is technically false — a single point can't draw a trend slope.
|
||||
- Recommended: raise the bar to 3 or rewrite the description to "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag." Pick truth.
|
||||
|
||||
### L3 — `CACHE_VERSION = "v1.4.38.4"` literal stale in `public/sw.js:16`
|
||||
- The literal hasn't bumped for the v1.4.39 → v1.4.42 chain. The `<VersionPoller>` self-heal still fires correctly because it reads `NEXT_PUBLIC_APP_VERSION` from the build env (line 38), but the SW cache key is now 4 releases stale, meaning the activate-step's old-cache eviction has been a no-op for four deploys.
|
||||
- Recommended: replace with `const CACHE_VERSION = (self as any).__APP_VERSION__ || "v1.4.42";` and inject `__APP_VERSION__` via the SW build step. Documented self-heal pattern from v1.4.38.4 implies this was the intent.
|
||||
|
||||
### L4 — `/api/analytics` 9-second perf — user-visible UX impact
|
||||
- Per the W-AUDIT-analytics-9s sibling finding: the thick `/api/analytics` query blocks the dashboard for ~9 s when the rollup-coverage probe misses. The dashboard does paint slim-slice tiles first (good!) but the BD-Zielbereich tile, glucose-by-context tile, and the correlations panel under `/insights` sit on a `ChartSkeleton` for 9 s with no progress signal.
|
||||
- Recommended (UX-only, defer perf to W-AUDIT-analytics-9s): drop a "Auswertungen werden berechnet — das kann einen Moment dauern" caption inside the `ChartSkeleton` after 3 s. Today the skeleton just shimmers indefinitely. Sets user expectation.
|
||||
|
||||
### L5 — "Gefahrenzone" / "Danger Zone" branding is louder than the action warrants
|
||||
- Current copy: red `AlertTriangle` + red title + red button. Twice as scary as the actual destructive action (which is already double-gated by a confirmation dialog). Compare with GitHub's danger-zone shaping, which uses neutral icon + red CTA only.
|
||||
- Recommended: drop the AlertTriangle icon, render the title in neutral grey, keep the button red. Same protective gate, less visual hostility.
|
||||
|
||||
### L6 — Onboarding wizard "Welcome" carousel and tour both auto-launch
|
||||
- `src/components/onboarding/WelcomeCarousel.tsx` + `src/components/onboarding/tour-launcher.tsx` both gate on different fields (`onboardingCompletedAt` for the carousel, `onboardingTourCompleted` for the tour). A user can finish the carousel and immediately get the tour. Both are valuable, but the chained flow takes ~90 seconds before the user gets to actually use the app.
|
||||
- Recommended: gate the tour on `onboardingCompletedAt + 24h` so a new user gets the dashboard between the two onboarding flows. The tour becomes "the second-visit thing."
|
||||
|
||||
### L7 — `cancel`/`save` button order in `responsive-sheet` footer is not platform-conventional
|
||||
- `src/components/measurements/measurement-list.tsx:940-965`: in the edit-measurement sheet, the visual order is `[…] Cancel Save`. On iOS that's correct (Save is the rightmost confirmation); on Android the convention is the inverse. Since iOS is the primary mobile target, the current ordering is correct. Worth pinning in a comment so a future refactor doesn't "fix" it.
|
||||
|
||||
### L8 — `formatDateTime` everywhere; `formatRelativeTime` only inside Insights/Coach
|
||||
- The measurement list (line 572, 717) renders absolute "21.05.2026, 14:32" timestamps; the daily-briefing renders "vor 12 min". Two formats for "when". The list-view absolute style is appropriate for accurate referencing, but the heading "Erfasst am 21.05.2026, 14:32" sits awkwardly next to "vor 3 min" on the same screen.
|
||||
- Recommended: introduce a `formatDateOrRelative(iso)` helper — within 24h → relative, older → absolute. Defer to v1.4.44; not broken, just inconsistent.
|
||||
|
||||
## Strengths
|
||||
|
||||
S1. **Empty-state primitive (`empty-state.tsx`) is excellent.** Variants (`card` / `plain`), sizes (`default` / `compact`), CTA-floor enforcement at 44 px on mobile, `aria-live="polite"`, dashed-border affordance. The pattern is used consistently across 25+ call sites.
|
||||
|
||||
S2. **Empty-state copy is filter-aware.** `measurement-list.tsx:447-480` distinguishes "no measurements yet" from "no measurements match this filter" with a separate CTA to clear the filter. Few apps do this; HealthLog does.
|
||||
|
||||
S3. **Doctor-report dialog availability probe.** `doctor-report-dialog.tsx:165-250` re-probes `/api/doctor-report/availability` on every range change, paints a quiet skeleton-row loading state first, then either the toggle list or a "no data in range" empty-state. Genuinely well-designed.
|
||||
|
||||
S4. **Chart empty-state matches chart card height** (`chart-empty-state.tsx:46-49`) so the dashboard layout doesn't reflow when a chart enters/leaves the empty branch. This is the layout-stability paper-cut other apps trip over.
|
||||
|
||||
S5. **TrendCard's headline-arrow + 7-day-delta + comparison-caption all share one `getTrendSentiment` helper** (`trend-card.tsx:67-78`). Three signals route through one source of truth — no risk of green-arrow-next-to-orange-value desyncs.
|
||||
|
||||
S6. **Getting-started checklist is opt-out, collapsed by default, progress-meter-visible even when collapsed** (`getting-started-checklist.tsx:114-128, 380-408`). Marc's brief in v1.4.15 phase-A3 fix #3 explicitly noted the previous always-expanded layout as the dominant complaint; the current shape addresses it.
|
||||
|
||||
S7. **Integration status pill collapses meaningfully** — one chip with state-conditional colour + relative timestamp, mobile-safe with `whitespace-nowrap` and abbreviated time units. Replaces the v1.4.18 four-fold redundant status surface cleanly.
|
||||
|
||||
S8. **`<ResponsiveSheet>` primitive** (`responsive-sheet.tsx:64-90`) flips between bottom-Sheet on mobile and centred Dialog on desktop via the existing `useIsMobile()` hook. Footer sticky-pinning solves the "Save button hidden under iOS keyboard" paper-cut directly.
|
||||
|
||||
S9. **Stale-hint bucketing** (`trend-card.tsx:338-365`) — days under a week stay silent, 8–30 → "vor Xd", 31–60 → "vor X Wochen", >60 → "vor X Monaten", with proper one/other plural pairs. The pattern this audit wishes `relativeMinutesAgo` would adopt.
|
||||
|
||||
S10. **Coach `errorCodeToI18nKey`** centralises the server-emitted error-code → user-copy mapping in one helper (`message-thread.tsx:78-95`), exported for unit-test pinning. Distinct keys for daily-budget vs provider-rate-limit so users know whether resetting is on their side or the provider's.
|
||||
|
||||
S11. **AppError chunk-load auto-reload** (`src/app/error.tsx:35-52`) — sessionStorage-gated so a recoverable stale-shell error self-heals with one hidden reload instead of showing the user a scary error page. Marc's v1.4.38.3 paper-cut closed cleanly.
|
||||
|
||||
S12. **Skip-to-main-content link + ARIA-live error regions in forms** (e.g. `measurement-list.tsx:899-908`). The form's error banner is bound to the inputs via `aria-describedby` so screen readers announce the validation failure paired with the field. Most apps half-implement this; HealthLog gets it right.
|
||||
@@ -0,0 +1,359 @@
|
||||
# v1.4.43 Security audit findings
|
||||
|
||||
Scope: sweep of every security surface for the v1.4.43 release. `develop`
|
||||
is at `7daff01a` (post v1.4.42 merge). Reviewer: W-AUDIT-SECURITY
|
||||
(read-only, static review).
|
||||
|
||||
## Verdict
|
||||
APPROVE_WITH_FIXES
|
||||
|
||||
The crypto, OAuth, encryption-at-rest, webhook-auth, rate-limit and
|
||||
admin-gating surfaces are solid. No Critical / data-leak / auth-bypass
|
||||
issues. Two High findings (PII in `auth.login.failed` audit row, audit
|
||||
ledger writes carrying un-capped strings) should land before tag — they
|
||||
are small, isolated fixes that close PII / log-growth gaps the
|
||||
v1.4.20 retroactive directive explicitly forbids. Several Mediums are
|
||||
defence-in-depth strengtheners; the Lows are deferable to v1.4.44.
|
||||
|
||||
## Critical (must fix before tag — vuln / data leak / auth bypass)
|
||||
|
||||
None.
|
||||
|
||||
## High (should fix before tag — defence-in-depth gap, log leak, error swallow)
|
||||
|
||||
### H-1 `auth.login.failed` persists the raw typed identifier in `AuditLog.details`
|
||||
- **File**: `src/app/api/auth/login/route.ts:60-65`.
|
||||
- **Attack vector**: When the user-lookup branch of `/api/auth/login`
|
||||
fails (no matching user OR no `passwordHash`), the handler writes
|
||||
`auditLog("auth.login.failed", { ipAddress: ip, details: { identifier, reason: "user_not_found_or_no_password" } })`.
|
||||
`identifier` is `parsed.data.email.trim()` — i.e. exactly what the
|
||||
caller typed. This persists the raw email or username string into
|
||||
`AuditLog.details` JSON forever (audit rows never expire by design).
|
||||
Three concerns:
|
||||
1. The v1.4.20 retroactive-cleanup directive forbids ALL user-facing
|
||||
PII in any operator-readable artefact, and `/api/admin/audit-log`
|
||||
surfaces this row to the admin. An admin scrolling the audit log
|
||||
sees every wrong-email a user typo'd at the login screen.
|
||||
2. A failed brute-force probe of a guessed email lands in the audit
|
||||
ledger with the guessed string, so an attacker who later compromises
|
||||
the audit table walks away with a list of valid + invalid email
|
||||
candidates each user-not-found row was probed against.
|
||||
3. The successful-login + invalid-password branches (lines 70-75) do
|
||||
NOT carry the identifier — they hash to a `userId` instead. The
|
||||
user-not-found branch is the only one that leaks because no
|
||||
`userId` exists at that point. Replace with a one-way hash of the
|
||||
identifier (the same `hashToken(identifier)` shape v1.4.41 L-2
|
||||
proposed for `check-user`) so the audit row still says "this IP
|
||||
tried email-X N times" without persisting the literal string.
|
||||
- **Severity rationale**: H rather than Critical — the data is operator-
|
||||
read-only, not internet-reachable, and the brute-force surface is
|
||||
already throttled by `auth:login:<ip>` at `5/15min`. But the v1.4.20
|
||||
directive is a hard rule, the fix is one line, and shipping a release
|
||||
that breaks the rule retroactively requires another scrub pass.
|
||||
- **Recommended fix**: drop the raw `identifier` from the details and
|
||||
replace with `identifier_hash: hashToken(identifier)` (or simply
|
||||
drop the field entirely — `reason: "user_not_found_or_no_password"`
|
||||
is the operationally interesting bit, the literal string is not).
|
||||
|
||||
### H-2 v1.4.42 L-1 still open — `WithingsApiError.message` lands un-capped in `AuditLog.details`
|
||||
- **File**: `src/lib/integrations/status.ts:250-260` (audit-log call site),
|
||||
consumed by every `WithingsApiError` thrown from `src/lib/withings/client.ts:127-135, 170-178, 290-298, 374-382`.
|
||||
- **Attack vector**: The classifier shipped in v1.4.42 builds
|
||||
`WithingsApiError.message` as `` `Withings <verb> error: <status> - <json.error>` `` where `json.error` is whatever Withings put in the
|
||||
response body. That message flows verbatim into the
|
||||
`auditLog("integrations.sync.failed", { details: { …, message, … } })`
|
||||
call. The encrypted `IntegrationStatus.lastError` column is sliced to
|
||||
1024 chars via `safeEncryptError`, but the audit-row JSON is NOT —
|
||||
Postgres TEXT can hold arbitrary length. A compromised or buggy
|
||||
Withings response could pump a large `error` string into every
|
||||
connected user's audit ledger.
|
||||
- **Severity rationale**: H, not Critical — Withings is the trusted
|
||||
upstream and the failure path runs at most 3 times per user before
|
||||
the 3-strike alert ladder fires. But the directive from v1.4.42 was
|
||||
explicit (L-1) that audit-row payload sizes must match the encrypted-
|
||||
error column behaviour and the gap is still open in v1.4.43. Closing
|
||||
it costs one `.slice(0, 1024)` in either the constructor or the call
|
||||
site.
|
||||
- **Recommended fix**: apply `slice(0, 1024)` to `message` inside the
|
||||
`WithingsApiError` constructor (single owner) so every audit /
|
||||
notification path inherits the cap.
|
||||
|
||||
## Medium (recommended for tag)
|
||||
|
||||
### M-1 `auth.check-user` enumeration probe still un-audited
|
||||
- **File**: `src/app/api/auth/check-user/route.ts:95-113`.
|
||||
- **Attack vector**: v1.4.41 L-2 noted that `/api/auth/check-user`
|
||||
emits `annotate()` for the wide-event log but never calls
|
||||
`auditLog(...)`. The v1.4.41 H-1 fix landed the per-IP rate-limit
|
||||
but did NOT land the audit row. The route now correctly throttles a
|
||||
burst to 30/15min, but a throttled burst leaves no `auditLog`
|
||||
evidence; wide-events are sampled and retained shorter than audit
|
||||
rows. An admin investigating "did anyone enumerate accounts last
|
||||
week" has no row to grep.
|
||||
- **Severity rationale**: M — the throttle closes the operationally
|
||||
important attack surface; the absent audit row is defence-in-depth.
|
||||
Adding it is one line; matches the depth pattern of every other
|
||||
/api/auth/* route.
|
||||
- **Recommended fix**: emit
|
||||
`auditLog("auth.check-user", { ipAddress: ip, details: { branch, identifier_hash: hashToken(identifier) } })`
|
||||
on every branch. Hash the identifier rather than storing it raw —
|
||||
same reasoning as H-1.
|
||||
|
||||
### M-2 `dispatchNotification` formatting may carry an arbitrary upstream
|
||||
string to Telegram (the slice trims to 280, but the field is still
|
||||
read from user-influenced data)
|
||||
- **File**: `src/lib/integrations/status.ts:505-547` (the
|
||||
`formatAdminAlertPayload` path is the unchanged Telegram alert body).
|
||||
- **Attack vector**: Same pipeline as H-2. The `message` argument
|
||||
reaches `formatAdminAlertPayload`, which trims to 280 chars before
|
||||
pushing to Telegram. Bounded, so not a payload-size DoS. The concern
|
||||
is that an HTML / Markdown injection in `json.error` would land in
|
||||
the Telegram body unescaped — the existing alert body uses no
|
||||
parse_mode (verified by reading `sendTelegramMessage` callers from
|
||||
this file), so Telegram renders plain text and the injection is
|
||||
inert. Note for the next reader: if anyone flips the alert body to
|
||||
`parseMode: "HTML"` (which the medication-reminder paths use) the
|
||||
upstream string would suddenly become an HTML injection vector.
|
||||
- **Severity rationale**: M — preventive, not exploitable today.
|
||||
- **Recommended fix**: keep alerts on plain text (no `parseMode`).
|
||||
Document the constraint with a comment on `formatAdminAlertPayload`
|
||||
so a future contributor doesn't silently flip it.
|
||||
|
||||
### M-3 Coach SSE transcript replays prior assistant + user messages
|
||||
verbatim from DB without re-running `detectRefusal`
|
||||
- **File**: `src/app/api/insights/chat/route.ts:193-244`.
|
||||
- **Attack vector**: `detectRefusal` runs only on the inbound `message`
|
||||
per turn (line 164-177). Prior turns (`priorTurns`) are loaded
|
||||
straight from `coach_messages.encryptedContent` and concatenated
|
||||
into the `userPrompt` transcript at line 235-244. If an injection
|
||||
pattern slipped past the refusal once (false-negative on the regex
|
||||
bank), it is replayed verbatim on every subsequent turn of the same
|
||||
conversation. Today the same regex bank gates every new turn so the
|
||||
user cannot send a follow-up that adds new payload; but the
|
||||
ALREADY-PERSISTED payload keeps re-entering the prompt every turn,
|
||||
so the model sees it on every reply.
|
||||
- **Severity rationale**: M — the refusal pass is the existing defence
|
||||
layer and the regex bank is conservative. The risk is "false-negative
|
||||
amplification" rather than a fresh bypass. The fix is to re-run
|
||||
`detectRefusal` against every turn re-loaded from DB and short-
|
||||
circuit the conversation when an injection re-surfaces (or to clamp
|
||||
per-turn content to a length that the provider's system-prompt
|
||||
precedence overrides).
|
||||
- **Recommended fix**: defer — pin the pattern in v1.4.44 with a
|
||||
dedicated `replay_injection` audit annotation so the failure case is
|
||||
observable before adding the kill-switch.
|
||||
|
||||
### M-4 `getClientIp` falls back to a literal `"unknown"` string in
|
||||
every rate-limit caller — a misconfigured trust chain collapses
|
||||
every anonymous request into one shared bucket
|
||||
- **File**: every `checkRateLimit(\`...:\${ip ?? "unknown"}\`, ...)` call
|
||||
site (e.g. `src/app/api/auth/login/route.ts:25-26`,
|
||||
`src/app/api/auth/register/route.ts:24-25`).
|
||||
- **Attack vector**: The v1.4.37 trust-violation warning fires once
|
||||
per process and degrades the rate-limit precision silently
|
||||
afterwards (`api-response.ts:217-237` documents this exact concern).
|
||||
Anonymous traffic shares the `"unknown"` bucket → one attacker can
|
||||
exhaust the bucket for every legitimate caller. The
|
||||
`getClientIpOrTrustWarning` helper already returns a `trustViolation`
|
||||
flag (line 254-300) but no call site routes the request to a tighter
|
||||
global bucket when the flag is set; every caller still falls back to
|
||||
`?? "unknown"`.
|
||||
- **Severity rationale**: M — requires misconfigured `TRUST_PROXY_HOPS`
|
||||
to trigger, but is exploitable in that misconfigured-but-still-running
|
||||
state. The warning fires once but the precision stays degraded for
|
||||
the rest of the process lifetime.
|
||||
- **Recommended fix**: extend `checkRateLimit` (or wrap every auth-
|
||||
surface call site) so that `trustViolation === true` routes the
|
||||
request to a single global bucket with a much tighter limit (e.g.
|
||||
100/15min across the whole "unknown" pool). Defer to v1.4.44 if the
|
||||
v1.4.43 release window is tight.
|
||||
|
||||
## Low (defer to v1.4.44)
|
||||
|
||||
### L-1 `withings_state` cookie carries `userId:nonce` plaintext
|
||||
- **File**: `src/app/api/withings/connect/route.ts:25-37`.
|
||||
- **Attack vector**: The Withings OAuth `state` parameter is built as
|
||||
`${user.id}:${stateNonce}` and persisted both as the cookie value AND
|
||||
the URL state. The cookie is `httpOnly`, `Secure` (prod) and
|
||||
`sameSite: "lax"` so the XSS exfiltration surface is closed. But the
|
||||
user id is recoverable from a request log entry (where the cookie is
|
||||
not redacted) and from a network-traffic capture of the redirect.
|
||||
Cosmetic — knowing your own user id leaks nothing — but a future
|
||||
refactor that switches the cookie to a non-httpOnly variant would
|
||||
silently expose it. The state is also compared via constant-time
|
||||
string-equal (line 31), good.
|
||||
- **Recommended fix** (defer): switch the state to a fully-random
|
||||
16-byte nonce and persist `(nonce → userId)` in a short-lived (10
|
||||
min) row; CSRF check then becomes "this nonce is in the table" rather
|
||||
than "this nonce includes the right userId".
|
||||
|
||||
### L-2 The Coach `userPrompt` is built via raw string concatenation
|
||||
- **File**: `src/app/api/insights/chat/route.ts:235-244`.
|
||||
- **Attack vector**: None today — the snapshot is server-built JSON
|
||||
(already sanitised at the source via `sanitizeForPrompt` for every
|
||||
free-text field — verified across `glp1-snapshot.ts:318-332`,
|
||||
`blood-pressure-status.ts:323-324`, `medication-compliance-status.ts:234-235`,
|
||||
`glp1-plateau.ts:128-129`). User messages are the only free-text;
|
||||
`detectRefusal` is the gate. Worth a note for the next reader so
|
||||
the boundary stays explicit: any new free-text field added to the
|
||||
snapshot MUST run through `sanitizeForPrompt` before landing in
|
||||
`snapshot.snapshotJson`.
|
||||
- **Recommended fix** (defer): add a lint-style test in
|
||||
`src/lib/ai/coach/__tests__/snapshot.test.ts` that fails when a
|
||||
free-text field is added to the snapshot blob without the
|
||||
`sanitizeForPrompt` wrap. Cheap regression guard.
|
||||
|
||||
### L-3 `passkey/login-verify` uses raw `as AuthenticationResponseJSON` cast
|
||||
- **File**: `src/lib/auth/passkey.ts:189`.
|
||||
- **Attack vector**: The SimpleWebAuthn verifier owns shape validation
|
||||
immediately downstream (line 202-213) so an arbitrary-shaped body
|
||||
fails fast. But the cast bypasses TS's type-narrowing — a future
|
||||
refactor that reads `typedResponse.id` or another field BEFORE
|
||||
calling the verifier would crash on `undefined.id`. Note from
|
||||
v1.4.41 L-1's twin direction: explicit Zod parsing in front of the
|
||||
verifier closes this comprehensively.
|
||||
- **Recommended fix** (defer): add a thin Zod schema upstream of
|
||||
`verifyAuthentication` so the cast becomes an explicit narrowing
|
||||
rather than an implicit one.
|
||||
|
||||
### L-4 `legacy_form_total` counter is in-memory only — useless in a
|
||||
multi-container deploy
|
||||
- **File**: `src/app/api/withings/webhook/route.ts:45-64`.
|
||||
- **Attack vector**: None — the counter is observability, not a
|
||||
security control. Note for the v1.4.27 removal cut: if the gate is
|
||||
"legacy form usage trending toward zero", a single-container reading
|
||||
of the counter undersells the actual traffic. Two containers each
|
||||
serving 30 % of legacy traffic each report ~30 % of total volume.
|
||||
- **Recommended fix** (defer): move to Postgres if the v1.4.27 cut
|
||||
matters for the gate; otherwise drop the counter when the legacy
|
||||
form is removed.
|
||||
|
||||
## Strengths
|
||||
|
||||
1. **Encryption-at-rest is comprehensive + versioned.**
|
||||
- Withings access + refresh tokens (`WithingsConnection.{accessToken, refreshToken}`),
|
||||
Codex access + refresh tokens (`User.codex{Access,Refresh}TokenEncrypted`),
|
||||
Telegram bot token (`User.telegramBotToken`), Withings client secret
|
||||
(`User.withingsClientSecretEncrypted`), Anthropic API key
|
||||
(`User.aiAnthropicKeyEncrypted`), and Coach messages
|
||||
(`CoachMessage.encryptedContent`) all flow through `src/lib/crypto.ts`'s
|
||||
versioned AES-256-GCM envelope with a documented rotation CLI.
|
||||
- The legacy bare-base64 format refuses to decrypt under the active
|
||||
key without an explicit `v1` entry, preventing silent corruption
|
||||
during rotation.
|
||||
- APNs key, password hashes (argon2id), and API token hashes
|
||||
(HMAC-SHA256) cover the remaining sensitive columns.
|
||||
|
||||
2. **Webhook authentication is uniform + timing-safe.**
|
||||
- Withings webhook (path-segment + legacy query): `timingSafeStringEqual`
|
||||
wrapper around `node:crypto.timingSafeEqual`, length-pre-check
|
||||
guards leak-via-length-mismatch (`src/lib/withings/webhook-handler.ts:50-59`).
|
||||
- Telegram webhook: same shape with the `x-telegram-bot-api-secret-token`
|
||||
header (`src/app/api/telegram/webhook/route.ts:36-48`).
|
||||
- Withings OAuth callback `state` cookie: `timingSafeEqual` on
|
||||
length-matched buffers (`src/app/api/withings/callback/route.ts:27-31`).
|
||||
- Per-IP rate-limit applied BEFORE the secret check on every webhook
|
||||
so a brute-force probe of the secret is bounded.
|
||||
|
||||
3. **Per-IP rate-limit coverage on every public auth surface.**
|
||||
- `/api/auth/login` 5/15min, `/api/auth/register` 5/15min,
|
||||
`/api/auth/passkey/login-options` 10/15min,
|
||||
`/api/auth/passkey/login-verify` 10/15min,
|
||||
`/api/auth/refresh` 60/15min,
|
||||
`/api/auth/check-user` 30/15min (v1.4.41 H-1 close),
|
||||
`/api/auth/password` 5/15min (per-user, not per-IP),
|
||||
Withings webhook 30/min, Telegram webhook 120/min.
|
||||
- Routes that lack `checkRateLimit` were swept: the omissions are
|
||||
authenticated routes where the session cookie / Bearer token IS
|
||||
the rate gate (revoke + audit on every miss is sufficient — the
|
||||
attacker would have to compromise a valid token first).
|
||||
|
||||
4. **Encryption-key versioning + rotation refuses to fail silently.**
|
||||
- `decrypt()` on a legacy-format ciphertext without a `v1` key
|
||||
entry throws an explicit "restore the original ENCRYPTION_KEY or
|
||||
add a 'v1' entry" error (`src/lib/crypto.ts:230-241`) instead of
|
||||
trying the active key (which would either fail with an opaque GCM
|
||||
tag error or succeed silently with junk on a key collision).
|
||||
- The `envSignature()` cache invalidator (line 40-46) ensures a
|
||||
mid-process key reload reflects in the next decrypt; the test
|
||||
helper `_resetCryptoCacheForTests` is the only opening.
|
||||
- Production rejects short-padded dev keys at startup
|
||||
(`decodeKey:62-67`).
|
||||
|
||||
5. **Cookie security posture is correct.**
|
||||
- `healthlog_session`: `httpOnly`, `secure` (prod), `sameSite: "lax"`,
|
||||
`path: "/"`, 30-day max-age with 1-day sliding refresh
|
||||
(`src/lib/auth/session.ts:71-79, 130-141`).
|
||||
- `hl_onboarding`: `httpOnly: false` (UX hint), `secure` (prod),
|
||||
`sameSite: "strict"` (no cross-site flow depends on it), explicit
|
||||
documentation that the real gate is server-side.
|
||||
- `withings_state`: `httpOnly`, `secure` (prod), `sameSite: "lax"`,
|
||||
`maxAge: 600` (10 min — matches the OAuth state lifetime).
|
||||
- `codex_device`: `httpOnly`, `secure` (prod), `sameSite: "lax"`,
|
||||
`maxAge: 900` (15 min — matches Hydra's device-code lifetime).
|
||||
Payload is `encrypt(JSON.stringify({deviceAuthId, userCode}))` — even
|
||||
a stolen cookie cannot be decrypted without the active key.
|
||||
|
||||
6. **CSP + HSTS + frame-ancestors are baked into `src/proxy.ts`.**
|
||||
- The Next 16 file-convention rename `middleware.ts` → `proxy.ts`
|
||||
is correctly applied. The `Content-Security-Policy`,
|
||||
`Strict-Transport-Security`, `X-Frame-Options: DENY`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy`,
|
||||
`Permissions-Policy` headers all ship from `src/proxy.ts:226-275`.
|
||||
- CSP `connect-src` is route-gated: `https://api.openai.com` and
|
||||
`https://chatgpt.com` are only added on `/settings/ai*`;
|
||||
`https://wbsapi.withings.net` only on `/settings/integrations/withings*`
|
||||
and `/api/withings/*`. A DOM-XSS on an unrelated page cannot
|
||||
exfiltrate to AI or Withings origins.
|
||||
- HSTS in prod carries `preload` so the first-visit MITM window on
|
||||
hostile networks is closed.
|
||||
- Nonce-based `script-src` with per-request 128-bit `crypto.getRandomValues`
|
||||
nonce.
|
||||
|
||||
7. **CORS is correctly absent.**
|
||||
- No `Access-Control-Allow-Origin: *` anywhere in `src/` (grep
|
||||
clean). iOS native uses the same-origin URL `https://healthlog.bombeck.io`
|
||||
so no preflight is required. A future cross-origin call would
|
||||
have to opt in explicitly.
|
||||
|
||||
8. **`check-env` CLI is operator-only + secret-clean.**
|
||||
- v1.4.42 W6 verdict still holds: `renderResults` (`scripts/check-env.ts:188-220`)
|
||||
emits only `[OK]` / `[MISSING-REQUIRED]` / `[missing-optional]`
|
||||
plus the variable NAME — no value substitution. `parseEnvFile`
|
||||
never echoes the value back to stdout.
|
||||
- `MANIFEST_PATH = resolve(__dirname, "env-manifest.json")` is
|
||||
module-frozen; `--file` controls only the env-source.
|
||||
- Run as `pnpm check-env` — operator-only, no network-attacker
|
||||
surface.
|
||||
|
||||
9. **Admin gating is uniform — every `/api/admin/**/route.ts` calls
|
||||
`requireAdmin()`.** Grep `find … -L "requireAdmin"` returned empty
|
||||
for the admin tree. Bearer-token authentication never elevates to
|
||||
admin (`src/lib/api-handler.ts:412-431` — `requireAdmin` is
|
||||
session-cookie-only by design).
|
||||
|
||||
10. **User-scope filtering is consistent on every owned-resource read.**
|
||||
Every route under `/api/measurements`, `/api/mood-entries`,
|
||||
`/api/medications`, `/api/workouts`, `/api/insights`, `/api/withings`,
|
||||
`/api/dashboard` filters by `userId: user.id` from the auth context
|
||||
in the WHERE clause. Forged IDs in JSON bodies are server-overridden
|
||||
(`src/app/api/workouts/batch/route.ts:228`). Soft-delete (`deletedAt: null`)
|
||||
is applied to every read tier consistently after the v1.4.40 W-DELETED
|
||||
sweep.
|
||||
|
||||
11. **Dashboard-widgets 422 audit-ledger writes are user-scoped + best-effort.**
|
||||
`prisma.auditLog.create({ data: { userId: user.id, action, details: JSON.stringify({ issues }) } })`
|
||||
uses the sanitised projection from `sanitiseZodIssues`, never raw
|
||||
`issue.params`. `.catch(() => {})` ensures a DB miss never 5xx's
|
||||
the 422 contract. Verified in v1.4.42 W2 review.
|
||||
|
||||
12. **`dedupeWorkoutBatch` is user-scoped + tamper-proof.** The
|
||||
group key includes `userId` so cross-user collapse is impossible;
|
||||
the batch route hard-overrides `userId: user.id` from the auth
|
||||
context (line 228) so a forged JSON `userId` is silently dropped.
|
||||
The duplicate envelope to iOS carries only `index` and `status` —
|
||||
no PII echo.
|
||||
|
||||
---
|
||||
|
||||
(56 words follow for the stdout summary)
|
||||
@@ -0,0 +1,76 @@
|
||||
# v1.4.43 Marathon Handoff — fresh session needed
|
||||
|
||||
**Orchestrator hit context limit (78%) before completing the marathon.** All 9 implementation waves dispatched in worktrees and pushed to origin. Fresh session picks up at cherry-pick + QA round + reconcile + release endgame. Estimated 2-3h fresh.
|
||||
|
||||
## Status of dispatched waves
|
||||
|
||||
| Wave | Agent | Scope | Status as of handoff |
|
||||
|---|---|---|---|
|
||||
| W1-ANALYTICS-PERF | `a5046322` | `p-limit(4)` cap on `computeAvg30LastYearMap` (9s → 2-3s) | 🟡 running in worktree |
|
||||
| W2-CHART-GATE | `a3243fc2` | C1 — fix "Erfasse 3 Einträge" gate to count raw entries | 🟡 running |
|
||||
| W3-SECURITY | `a77a15c3` | H-1 auth.login.failed PII + H-2 WithingsApiError slice | 🟡 running |
|
||||
| W4-QoL-COPY | `a2db58cd` | 6 QoL Highs (load error, Anbieter, persistent pill, not-found, global-error, plural forms) | 🟡 running |
|
||||
| W5-MOBILE-UI | `ac8ade2c` | Switch tap, comparison pills, mood kebab, Sheet close, reduced-motion helper | 🟡 running |
|
||||
| W6-ZOD-ROLLOUT | `a3a642a8` | 41 routes `returnAllZodIssues` migration | 🟡 running |
|
||||
| W7-WITHINGS (B3 only) | `a33c0578` (continuation) | B3 only — B4 + B7 deferred to v1.4.44 | 🟡 running |
|
||||
| W8-OPS | `abf6af16` | B2 widgets audit RL + B5 check-env CI + B11 docker BuildKit cache fix | 🟡 running |
|
||||
| W9-WORKOUTS-PRIORITY | `aeddfce5` | B6 — `dedupeWorkoutBatch` user-priority resolution | 🟡 running |
|
||||
|
||||
## What fresh session needs to do
|
||||
|
||||
1. **Wait for all 9 waves to complete** — they'll show up as `worktree-agent-*` branches on origin
|
||||
2. **For each wave**: read commit message, inspect for Marc-voice violations / PII, cherry-pick onto develop, push, clean up worktree + remote branch
|
||||
3. **Run full quality gate**: `pnpm typecheck` + `pnpm lint` + `pnpm knip` + `pnpm test --run`
|
||||
4. **W10 QA round** — dispatch 6 parallel read-only reviewers:
|
||||
- code-reviewer (superpowers:code-reviewer)
|
||||
- security review (general-purpose)
|
||||
- design review (general-purpose)
|
||||
- senior-dev (general-purpose)
|
||||
- product-lead (general-purpose)
|
||||
- simplifier-residual (code-simplifier:code-simplifier)
|
||||
5. **Reconcile** Critical / High / Med findings
|
||||
6. **Bump version** 1.4.42 → 1.4.43
|
||||
7. **Finalize CHANGELOG.md** `[1.4.43]` entry (use the same shape as `[1.4.42]`; sections: Added / Changed / Fixed / Performance / Operator notes; cite the 5 audit reports + 9 phase reports)
|
||||
8. **Squash-merge develop → main** via PR + push
|
||||
9. **Tag v1.4.43** on main and push tag
|
||||
10. **GitHub release** — body from CHANGELOG `[1.4.43]`
|
||||
11. **Coolify deploy** — `mcp__coolify-apps01__deploy` with `force: true` (auto-deploy webhook still flaky)
|
||||
12. **VERIFY `/api/version → 1.4.43`** post-deploy — per `feedback_docker_build_cache_version_stale.md` the bundle can ship stale. If 1.4.42 returns after deploy, trigger `gh workflow run docker-publish.yml --ref v1.4.43` and redeploy.
|
||||
13. **`git merge origin/main --no-edit`** on develop after squash to align merge-base (PR #193 had the same pattern); take ours on conflicts.
|
||||
|
||||
## Marc-Voice + PII cleanup pass
|
||||
|
||||
Before cherry-picking each wave, sanity-check the agent's commit messages:
|
||||
- No `Co-Authored-By: Claude` trailer
|
||||
- No "Marc" by name in commit body
|
||||
- English only
|
||||
- Conventional-commit prefix
|
||||
|
||||
If a commit has PII / trailer, amend after cherry-pick.
|
||||
|
||||
## v1.4.43 backlog already seeded (defer to v1.4.44)
|
||||
|
||||
- W7's B4 (parkIntegrationAtPersistent > 24h) + B7 (consecutiveFailures per-kind) — explicitly deferred
|
||||
- B8 — Coolify auto-deploy webhook investigation (operator-side, not code)
|
||||
- All Mediums + Lows from the four audits that the implementation waves did NOT pick up in their Medium-sweep
|
||||
|
||||
## Audit reports (input to QA reconcile)
|
||||
|
||||
- `.planning/round-v1443-AUDIT-mobile-ui-findings.md` — 1 Critical / 5 High / 6 Med / 6 Low
|
||||
- `.planning/round-v1443-AUDIT-qol-findings.md` — 0 / 6 / 8 / 8
|
||||
- `.planning/round-v1443-AUDIT-security-findings.md` — 0 / 2 / Med / Low
|
||||
- `.planning/round-v1443-AUDIT-analytics-9s-findings.md` — URGENT root cause + recipe
|
||||
|
||||
## Phase reports (output from waves, in `.planning/`)
|
||||
|
||||
Each wave produces `.planning/phase-W<N>-*-v1443-report.md`. Read them as a pack to consolidate the v1.4.43 CHANGELOG narrative.
|
||||
|
||||
## Production state (unchanged from handoff)
|
||||
|
||||
- `/api/version → 1.4.42` LIVE on apps-01
|
||||
- iOS v0.5.4 hitting prod
|
||||
- AP-2 .p8 installed
|
||||
|
||||
## Estimated time
|
||||
|
||||
2-3 hours fresh session for cherry-pick (9 × ~3-5 min) + QA round (~15 min) + reconcile (~30 min) + release endgame (~30 min including the docker-cache-fix Verify Loop).
|
||||
+19
@@ -11,6 +11,17 @@ FROM node:22-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
|
||||
# Pre-v1.4.43 the docker-publish workflow shipped a stale package.json
|
||||
# version baked into the bundle because BuildKit reused the `pnpm build`
|
||||
# layer across releases (the COPY . . layer key was content-stable when
|
||||
# only the version string had changed and the next pnpm install layer
|
||||
# was warm). Passing the tag as a build-arg forces a per-release cache
|
||||
# miss on this layer and forwards the value into the runtime env so
|
||||
# /api/version reads from $NEXT_PUBLIC_APP_VERSION first.
|
||||
ARG NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -35,6 +46,14 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
|
||||
# into the runner so /api/version reads the build-arg value instead of
|
||||
# the package.json fallback. The variable is keyed to the CI tag ref
|
||||
# (`v1.4.43`, etc.), so a release that bumps the tag but cache-reuses
|
||||
# the prior `pnpm build` layer still surfaces the right runtime version.
|
||||
ARG NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
|
||||
+23
-9
@@ -152,6 +152,13 @@
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
"description": "Ein unerwarteter Fehler ist aufgetreten. Du kannst es erneut versuchen, die technischen Details kopieren oder einen Bug-Report öffnen."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Seite nicht gefunden",
|
||||
"body": "Die gesuchte Seite existiert nicht oder wurde verschoben. Kehr zum Dashboard zurück, um dort weiterzumachen, wo du aufgehört hast.",
|
||||
"backToDashboard": "Zurück zum Dashboard"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"measurements": "Messungen",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "z.B. nach dem Essen",
|
||||
"formReset": "Formular zurücksetzen",
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"invalidValue": "Bitte einen gültigen Wert eingeben.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Verpasst",
|
||||
"emptyStateTitle": "Erfasse mehr Messungen, um Trends zu sehen",
|
||||
"emptyStateDescription": "Erfasse mindestens 3 Einträge, um die Trendlinie freizuschalten.",
|
||||
"needMoreDistinctDaysTitle": "Mehr Messtage erforderlich",
|
||||
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
|
||||
"personalBaseline": "Dein Mittel",
|
||||
"deltaVsBaseline": "{delta} vs. dein Mittel",
|
||||
"deltaUnchanged": "entspricht deinem Mittel",
|
||||
@@ -1133,13 +1143,16 @@
|
||||
"feedbackThanks": "Danke für das Signal.",
|
||||
"dailyLimitTitle": "Heute-Limit erreicht",
|
||||
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
|
||||
"providerRateLimitTitle": "Provider rate-limited",
|
||||
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min."
|
||||
"providerRateLimitTitle": "Anbieter gedrosselt",
|
||||
"providerRateLimitBody": "Anbieter ist temporär überlastet; wieder verfügbar in ~5 min."
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgo": "vor {count} Minuten",
|
||||
"relativeHoursAgo": "vor {count} Stunden",
|
||||
"relativeDaysAgo": "vor {count} Tagen",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
"relativeMinutesAgoOther": "vor {count} Minuten",
|
||||
"relativeHoursAgoOne": "vor {count} Stunde",
|
||||
"relativeHoursAgoOther": "vor {count} Stunden",
|
||||
"relativeDaysAgoOne": "vor {count} Tag",
|
||||
"relativeDaysAgoOther": "vor {count} Tagen",
|
||||
"navOverview": "Übersicht",
|
||||
"navBloodPressure": "Blutdruck",
|
||||
"navWeight": "Gewicht",
|
||||
@@ -1616,7 +1629,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"title": "Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
"description": "Anbieter, Modell, API-Schlüssel."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
@@ -1754,9 +1767,9 @@
|
||||
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
|
||||
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"rawData": "Rohdaten mitsenden",
|
||||
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Provider zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
|
||||
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Provider kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
|
||||
"rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
|
||||
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit der Anbieter zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
|
||||
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, der Anbieter kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
|
||||
"rawDataWarning": "Rohdaten aktiv: Der Anbieter erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
|
||||
"regenerateInsights": "Reports neu generieren",
|
||||
"regenerateSuccess": "Reports wurden neu generiert",
|
||||
"regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.",
|
||||
@@ -1889,6 +1902,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Verbunden",
|
||||
"errorReconnect": "Fehler — neu verbinden",
|
||||
"warningServerError": "Verbunden, aber Serverfehler",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"justNow": "gerade eben",
|
||||
"minutesAgo": "vor {count} min",
|
||||
|
||||
+17
-3
@@ -152,6 +152,13 @@
|
||||
"title": "Something went wrong",
|
||||
"description": "An unexpected error occurred. You can retry, copy the technical details, or open a bug report."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"body": "The page you were looking for doesn't exist or has been moved. Head back to the dashboard to pick up where you left off.",
|
||||
"backToDashboard": "Back to dashboard"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"measurements": "Measurements",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"loadError": "Could not load measurements",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Missed",
|
||||
"emptyStateTitle": "Add more measurements to see trends",
|
||||
"emptyStateDescription": "Log at least 3 entries to unlock the trend line.",
|
||||
"needMoreDistinctDaysTitle": "More distinct days needed",
|
||||
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
|
||||
"personalBaseline": "Your normal",
|
||||
"deltaVsBaseline": "{delta} vs. your normal",
|
||||
"deltaUnchanged": "matches your normal",
|
||||
@@ -1137,9 +1147,12 @@
|
||||
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min."
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgo": "{count} minutes ago",
|
||||
"relativeHoursAgo": "{count} hours ago",
|
||||
"relativeDaysAgo": "{count} days ago",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
"relativeMinutesAgoOther": "{count} minutes ago",
|
||||
"relativeHoursAgoOne": "{count} hour ago",
|
||||
"relativeHoursAgoOther": "{count} hours ago",
|
||||
"relativeDaysAgoOne": "{count} day ago",
|
||||
"relativeDaysAgoOther": "{count} days ago",
|
||||
"navOverview": "Overview",
|
||||
"navBloodPressure": "Blood Pressure",
|
||||
"navWeight": "Weight",
|
||||
@@ -1889,6 +1902,7 @@
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"warningServerError": "Connected, server error",
|
||||
"notConnected": "Not connected",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
|
||||
+20
-6
@@ -152,6 +152,13 @@
|
||||
"title": "Algo ha salido mal",
|
||||
"description": "Se ha producido un error inesperado. Puedes reintentar, copiar los detalles técnicos o abrir un reporte de bug."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Página no encontrada",
|
||||
"body": "La página que buscas no existe o se ha movido. Vuelve al panel para continuar donde lo dejaste.",
|
||||
"backToDashboard": "Volver al panel"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"measurements": "Mediciones",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "p. ej. después de comer",
|
||||
"formReset": "Restablecer formulario",
|
||||
"saveError": "Error al guardar",
|
||||
"loadError": "No se han podido cargar las mediciones",
|
||||
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
|
||||
"deleteError": "Error al eliminar",
|
||||
"invalidValue": "Introduce un valor válido.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Omitida",
|
||||
"emptyStateTitle": "Registra más mediciones para ver tendencias",
|
||||
"emptyStateDescription": "Registra al menos 3 entradas para desbloquear la línea de tendencia.",
|
||||
"needMoreDistinctDaysTitle": "Se necesitan más días distintos",
|
||||
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
|
||||
"personalBaseline": "Tu media",
|
||||
"deltaVsBaseline": "{delta} vs. tu media",
|
||||
"deltaUnchanged": "coincide con tu media",
|
||||
@@ -1137,9 +1147,12 @@
|
||||
"settingsContextHint": "Bloques opcionales en los que el coach puede apoyarse. Si desactivas alguno, se omitirá en cada respuesta."
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgo": "hace {count} minutos",
|
||||
"relativeHoursAgo": "hace {count} horas",
|
||||
"relativeDaysAgo": "hace {count} días",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
"relativeMinutesAgoOther": "hace {count} minutos",
|
||||
"relativeHoursAgoOne": "hace {count} hora",
|
||||
"relativeHoursAgoOther": "hace {count} horas",
|
||||
"relativeDaysAgoOne": "hace {count} día",
|
||||
"relativeDaysAgoOther": "hace {count} días",
|
||||
"navOverview": "Resumen",
|
||||
"navBloodPressure": "Presión arterial",
|
||||
"navWeight": "Peso",
|
||||
@@ -1887,9 +1900,10 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"connected": "Conectado",
|
||||
"errorReconnect": "Error — reconectar",
|
||||
"warningServerError": "Conectado, error del servidor",
|
||||
"notConnected": "No conectado",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
|
||||
+20
-6
@@ -152,6 +152,13 @@
|
||||
"title": "Une erreur est survenue",
|
||||
"description": "Une erreur inattendue est survenue. Tu peux réessayer, copier les détails techniques ou ouvrir un rapport de bug."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Page introuvable",
|
||||
"body": "La page que tu cherches n’existe pas ou a été déplacée. Reviens au tableau de bord pour reprendre où tu en étais.",
|
||||
"backToDashboard": "Retour au tableau de bord"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"measurements": "Mesures",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "p. ex. après le repas",
|
||||
"formReset": "Réinitialiser le formulaire",
|
||||
"saveError": "Erreur lors de l’enregistrement",
|
||||
"loadError": "Impossible de charger les mesures",
|
||||
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
|
||||
"deleteError": "Erreur lors de la suppression",
|
||||
"invalidValue": "Saisis une valeur valide.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Manquée",
|
||||
"emptyStateTitle": "Saisis plus de mesures pour voir les tendances",
|
||||
"emptyStateDescription": "Saisis au moins 3 entrées pour débloquer la courbe de tendance.",
|
||||
"needMoreDistinctDaysTitle": "Plus de jours distincts requis",
|
||||
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
|
||||
"personalBaseline": "Ta moyenne",
|
||||
"deltaVsBaseline": "{delta} vs. ta moyenne",
|
||||
"deltaUnchanged": "correspond à ta moyenne",
|
||||
@@ -1137,9 +1147,12 @@
|
||||
"settingsContextHint": "Blocs optionnels sur lesquels le coach peut s’appuyer. Si tu en désactives un, il sera omis dans chaque réponse."
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgo": "il y a {count} minutes",
|
||||
"relativeHoursAgo": "il y a {count} heures",
|
||||
"relativeDaysAgo": "il y a {count} jours",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
"relativeMinutesAgoOther": "il y a {count} minutes",
|
||||
"relativeHoursAgoOne": "il y a {count} heure",
|
||||
"relativeHoursAgoOther": "il y a {count} heures",
|
||||
"relativeDaysAgoOne": "il y a {count} jour",
|
||||
"relativeDaysAgoOther": "il y a {count} jours",
|
||||
"navOverview": "Vue d'ensemble",
|
||||
"navBloodPressure": "Tension artérielle",
|
||||
"navWeight": "Poids",
|
||||
@@ -1887,9 +1900,10 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"connected": "Connecté",
|
||||
"errorReconnect": "Erreur — reconnecter",
|
||||
"warningServerError": "Connecté, erreur serveur",
|
||||
"notConnected": "Non connecté",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
|
||||
+20
-6
@@ -152,6 +152,13 @@
|
||||
"title": "Qualcosa è andato storto",
|
||||
"description": "Si è verificato un errore inatteso. Puoi riprovare, copiare i dettagli tecnici o aprire una segnalazione di bug."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Pagina non trovata",
|
||||
"body": "La pagina che cercavi non esiste o è stata spostata. Torna alla dashboard per riprendere da dove eri rimasto.",
|
||||
"backToDashboard": "Torna alla dashboard"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"measurements": "Misurazioni",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "es. dopo il pasto",
|
||||
"formReset": "Reimposta modulo",
|
||||
"saveError": "Errore durante il salvataggio",
|
||||
"loadError": "Impossibile caricare le misurazioni",
|
||||
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
|
||||
"deleteError": "Errore durante l’eliminazione",
|
||||
"invalidValue": "Inserisci un valore valido.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Mancata",
|
||||
"emptyStateTitle": "Registra altre misurazioni per vedere i trend",
|
||||
"emptyStateDescription": "Registra almeno 3 voci per sbloccare la linea di tendenza.",
|
||||
"needMoreDistinctDaysTitle": "Servono più giorni distinti",
|
||||
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
|
||||
"personalBaseline": "La tua media",
|
||||
"deltaVsBaseline": "{delta} vs. la tua media",
|
||||
"deltaUnchanged": "corrisponde alla tua media",
|
||||
@@ -1137,9 +1147,12 @@
|
||||
"settingsContextHint": "Blocchi opzionali su cui il coach può basarsi. Disattivandone uno, viene omesso da ogni risposta."
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgo": "{count} minuti fa",
|
||||
"relativeHoursAgo": "{count} ore fa",
|
||||
"relativeDaysAgo": "{count} giorni fa",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
"relativeMinutesAgoOther": "{count} minuti fa",
|
||||
"relativeHoursAgoOne": "{count} ora fa",
|
||||
"relativeHoursAgoOther": "{count} ore fa",
|
||||
"relativeDaysAgoOne": "{count} giorno fa",
|
||||
"relativeDaysAgoOther": "{count} giorni fa",
|
||||
"navOverview": "Panoramica",
|
||||
"navBloodPressure": "Pressione arteriosa",
|
||||
"navWeight": "Peso",
|
||||
@@ -1887,9 +1900,10 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"connected": "Connesso",
|
||||
"errorReconnect": "Errore — riconnetti",
|
||||
"warningServerError": "Connesso, errore del server",
|
||||
"notConnected": "Non connesso",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
|
||||
+20
-6
@@ -152,6 +152,13 @@
|
||||
"title": "Coś poszło nie tak",
|
||||
"description": "Wystąpił nieoczekiwany błąd. Możesz spróbować ponownie, skopiować szczegóły techniczne lub otworzyć zgłoszenie błędu."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": {
|
||||
"title": "Nie znaleziono strony",
|
||||
"body": "Strona, której szukasz, nie istnieje lub została przeniesiona. Wróć do panelu, aby kontynuować od miejsca, w którym przerwałeś.",
|
||||
"backToDashboard": "Powrót do panelu"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"measurements": "Pomiary",
|
||||
@@ -346,6 +353,7 @@
|
||||
"notesPlaceholder": "np. po jedzeniu",
|
||||
"formReset": "Resetuj formularz",
|
||||
"saveError": "Błąd zapisu",
|
||||
"loadError": "Nie udało się wczytać pomiarów",
|
||||
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
|
||||
"deleteError": "Błąd usuwania",
|
||||
"invalidValue": "Wprowadź poprawną wartość.",
|
||||
@@ -852,6 +860,8 @@
|
||||
"legendMissed": "Pominięta",
|
||||
"emptyStateTitle": "Zarejestruj więcej pomiarów, aby zobaczyć trendy",
|
||||
"emptyStateDescription": "Zarejestruj co najmniej 3 wpisy, aby odblokować linię trendu.",
|
||||
"needMoreDistinctDaysTitle": "Potrzeba więcej różnych dni",
|
||||
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
|
||||
"personalBaseline": "Twoja średnia",
|
||||
"deltaVsBaseline": "{delta} vs. twoja średnia",
|
||||
"deltaUnchanged": "odpowiada twojej średniej",
|
||||
@@ -1137,9 +1147,12 @@
|
||||
"settingsContextHint": "Opcjonalne bloki, na których coach może się opierać. Wyłączenie pominie blok w każdej odpowiedzi."
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgo": "{count} minut temu",
|
||||
"relativeHoursAgo": "{count} godzin temu",
|
||||
"relativeDaysAgo": "{count} dni temu",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
"relativeMinutesAgoOther": "{count} minut temu",
|
||||
"relativeHoursAgoOne": "{count} godzinę temu",
|
||||
"relativeHoursAgoOther": "{count} godzin temu",
|
||||
"relativeDaysAgoOne": "{count} dzień temu",
|
||||
"relativeDaysAgoOther": "{count} dni temu",
|
||||
"navOverview": "Przegląd",
|
||||
"navBloodPressure": "Ciśnienie krwi",
|
||||
"navWeight": "Waga",
|
||||
@@ -1887,9 +1900,10 @@
|
||||
}
|
||||
},
|
||||
"integrationPill": {
|
||||
"connected": "Connected",
|
||||
"errorReconnect": "Error — reconnect",
|
||||
"notConnected": "Not connected",
|
||||
"connected": "Połączono",
|
||||
"errorReconnect": "Błąd — połącz ponownie",
|
||||
"warningServerError": "Połączono, błąd serwera",
|
||||
"notConnected": "Nie połączono",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"hoursAgo": "{count} h ago",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* v1.4.43 W3-SECURITY (H-1) — `auth.login.failed` audit row must not
|
||||
* leak the typed identifier (email / username) into operator
|
||||
* artefacts. Per the v1.4.20 PII directive, the `reason` field is the
|
||||
* only signal operators need; the identifier itself is PII and stays
|
||||
* out of the row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
|
||||
const TYPED_EMAIL = "leaked-identifier@example.com";
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: TYPED_EMAIL, password: "supersecret" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(false);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
|
||||
it("does NOT include the typed identifier in the audit details", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.login.failed",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
reason: "user_not_found_or_no_password",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Pin the absence: neither the literal identifier string nor an
|
||||
// `identifier` key may appear anywhere in the audit-row JSON.
|
||||
const calls = vi.mocked(auditLog).mock.calls;
|
||||
const failedCall = calls.find((c) => c[0] === "auth.login.failed");
|
||||
expect(failedCall).toBeDefined();
|
||||
const payload = failedCall![1] as { details?: Record<string, unknown> };
|
||||
expect(payload.details).toBeDefined();
|
||||
expect(Object.keys(payload.details!)).not.toContain("identifier");
|
||||
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
|
||||
});
|
||||
});
|
||||
@@ -58,9 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
|
||||
// the audit row — `reason` already tells the operator what
|
||||
// happened, and PII must not land in operator artefacts.
|
||||
await auditLog("auth.login.failed", {
|
||||
ipAddress: ip,
|
||||
details: { identifier, reason: "user_not_found_or_no_password" },
|
||||
details: { reason: "user_not_found_or_no_password" },
|
||||
});
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
@@ -116,20 +116,29 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.greeting.salutation).toBe("Hi, marc");
|
||||
expect(body.data.streak.currentDays).toBe(0);
|
||||
expect(body.data.compliance.scheduledToday).toBe(0);
|
||||
// REG-11 (v1.4.44): weight is the only always-emitted base metric.
|
||||
// BP + pulse + bodyFat + optional kinds gate on `latest ||
|
||||
// allTimeCount > 0` so accounts that have never logged a kind don't
|
||||
// get an empty placeholder tile.
|
||||
expect(body.data.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "weight" }),
|
||||
expect.objectContaining({ id: "bp" }),
|
||||
expect.objectContaining({ id: "pulse" }),
|
||||
]),
|
||||
expect.arrayContaining([expect.objectContaining({ id: "weight" })]),
|
||||
);
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "bp"),
|
||||
"bp must not emit when allTimeCount === 0 (REG-11)",
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "pulse"),
|
||||
"pulse must not emit when allTimeCount === 0 (REG-11)",
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits allTimeCount + lastSeenAt for every base metric (v1.4.33 maintainer-item-1)", async () => {
|
||||
// Empty-data path — every metric card still ships the new fields
|
||||
// so the iOS client can render a tile + "Letzter Wert vor Xd"
|
||||
// hint regardless of whether the route saw any rows in the 7-day
|
||||
// window.
|
||||
it("emits allTimeCount + lastSeenAt for the weight card on empty data (v1.4.33 maintainer-item-1)", async () => {
|
||||
// REG-11 (v1.4.44): the always-emitted base set shrank to just
|
||||
// `weight` — BP + pulse now gate on `latest || allTimeCount > 0`
|
||||
// so an account that has never logged either doesn't get a noisy
|
||||
// empty tile. Weight still ships unconditionally because the
|
||||
// onboarding flow expects the tile as a primary CTA.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
@@ -142,11 +151,9 @@ describe("GET /api/dashboard/summary", () => {
|
||||
};
|
||||
};
|
||||
const baseIds = body.data.metrics.map((m) => m.id);
|
||||
for (const required of ["weight", "bp", "pulse"]) {
|
||||
expect(baseIds, `${required} card missing from metrics list`).toContain(
|
||||
required,
|
||||
);
|
||||
}
|
||||
expect(baseIds, "weight card missing from metrics list").toContain(
|
||||
"weight",
|
||||
);
|
||||
for (const card of body.data.metrics) {
|
||||
expect(card.allTimeCount, `${card.id} missing allTimeCount`).toBe(0);
|
||||
expect(card.lastSeenAt, `${card.id} missing lastSeenAt`).toBeNull();
|
||||
@@ -192,6 +199,200 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(weight?.updatedAt).toBe(twoWeeksAgo.toISOString());
|
||||
});
|
||||
|
||||
it("emits the BP tile with the historical value when the last reading is 60 days old (REG-11)", async () => {
|
||||
// The original 7-day calendar filter in the latest-reading SQL
|
||||
// dropped any row outside the trailing-7-day window, so a BP
|
||||
// measurement logged 60 days ago surfaced as `latestValue: null`
|
||||
// and `sparkline: []` — the iOS tile then had nothing to render.
|
||||
// Post-REG-11 the `latestEver` aggregate carries the row + the
|
||||
// `ROW_NUMBER`-windowed sparkline carries the trailing 7 daily
|
||||
// rollup buckets regardless of how old they are.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const sixtyDaysAgo = new Date(Date.now() - 60 * 86_400_000);
|
||||
const sixtyOneDaysAgo = new Date(Date.now() - 61 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
_count: { _all: 14 },
|
||||
_max: { measuredAt: sixtyDaysAgo },
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
_count: { _all: 14 },
|
||||
_max: { measuredAt: sixtyDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
// Promise.all order: [latestEver, sparkline, streakDays].
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 132,
|
||||
measured_at: sixtyDaysAgo,
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 84,
|
||||
measured_at: sixtyDaysAgo,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucket_start: sixtyOneDaysAgo,
|
||||
mean: 130,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
bucket_start: sixtyDaysAgo,
|
||||
mean: 132,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
sparkline: number[];
|
||||
allTimeCount: number;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const bp = body.data.metrics.find((m) => m.id === "bp");
|
||||
expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
|
||||
expect(bp?.latestValue).toBe(132);
|
||||
expect(bp?.secondaryValue).toBe(84);
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
|
||||
expect(bp?.sparkline).toEqual([130, 132]);
|
||||
});
|
||||
|
||||
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
|
||||
// Accounts that have never logged BP should not get an empty
|
||||
// placeholder tile. Mirrors the bodyFat gate that's been in place
|
||||
// since v1.4.33.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ id: string }> };
|
||||
};
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "bp"),
|
||||
"bp tile must not emit when no BP reading exists",
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
body.data.metrics.find((m) => m.id === "pulse"),
|
||||
"pulse tile must not emit when no pulse reading exists",
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the weight tile fresh when the latest reading is within 7d (REG-11 regression guard)", async () => {
|
||||
// The post-REG-11 SQL drops the calendar filter — the recent-path
|
||||
// (last reading <7d) must still produce identical output so we
|
||||
// don't accidentally regress the fresh-data case.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
_count: { _all: 200 },
|
||||
_max: { measuredAt: twoDaysAgo },
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.4, measured_at: twoDaysAgo },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
type: "WEIGHT",
|
||||
bucket_start: twoDaysAgo,
|
||||
mean: 82.4,
|
||||
count: 1,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
sparkline: number[];
|
||||
allTimeCount: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const weight = body.data.metrics.find((m) => m.id === "weight");
|
||||
expect(weight?.latestValue).toBe(82.4);
|
||||
expect(weight?.sparkline).toEqual([82.4]);
|
||||
expect(weight?.allTimeCount).toBe(200);
|
||||
});
|
||||
|
||||
it("sparkline window takes the last 7 daily buckets even when they're all older than 7 days (REG-11)", async () => {
|
||||
// The new `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
|
||||
// bucket_start DESC)` window guarantees the route asks the DB for
|
||||
// exactly the trailing `SPARK_DAYS` buckets per type. Because the
|
||||
// SQL runs in Postgres, the mock here only proves the route hands
|
||||
// the buckets through in chronological order without dropping any
|
||||
// for age. The asserted order matches the SQL `ORDER BY type,
|
||||
// bucket_start ASC` so the iOS chart paints left-to-right with the
|
||||
// oldest bucket first.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const days = (n: number) => new Date(Date.now() - n * 86_400_000);
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
_count: { _all: 42 },
|
||||
_max: { measuredAt: days(30) },
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.$queryRaw)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", value: 71, measured_at: days(30) },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
metrics: Array<{
|
||||
id: string;
|
||||
latestValue: number | null;
|
||||
sparkline: number[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const pulse = body.data.metrics.find((m) => m.id === "pulse");
|
||||
expect(pulse, "pulse tile must emit for stale history").toBeDefined();
|
||||
expect(pulse?.latestValue).toBe(71);
|
||||
expect(pulse?.sparkline).toEqual([68, 70, 69, 71, 73, 72, 71]);
|
||||
expect(pulse?.sparkline).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("emits optional cards when allTimeCount > 0 but the 7-day window is empty", async () => {
|
||||
// Glucose / sleep / steps used to only emit when the 7-day
|
||||
// window had a reading. v1.4.33 widens the gate so a metric the
|
||||
|
||||
@@ -22,13 +22,21 @@
|
||||
* - the set of YYYY-MM-DD day-keys with any activity in 365 days
|
||||
*
|
||||
* v1.4.38 swaps the two unbounded reads for SQL aggregates:
|
||||
* - `DISTINCT ON (type)` over the 7-day window → one row per type
|
||||
* carrying the latest value + measuredAt (≤ N_metrics rows).
|
||||
* - `DISTINCT ON (type)` → one row per type carrying the latest
|
||||
* value + measuredAt (≤ N_metrics rows). REG-11 (v1.4.44) dropped
|
||||
* the trailing-7-day window from the WHERE clause; the tile now
|
||||
* surfaces the all-time-latest reading per type so a 60-day-old BP
|
||||
* measurement still feeds the tile (paired with the stale-caption
|
||||
* hint built off `lastSeenAt`).
|
||||
* - `measurement_rollups` DAY buckets keyed `(user_id, granularity,
|
||||
* bucketStart)` → at most 7 buckets per metric × N_metrics rows.
|
||||
* Sparkline points become the bucket means rather than individual
|
||||
* raw samples, which is a *smoother* trend signal for high-volume
|
||||
* metrics like ACTIVITY_STEPS and bounded for every other metric.
|
||||
* REG-11 (v1.4.44) switched the calendar-window filter for a
|
||||
* `ROW_NUMBER() OVER (PARTITION BY type ORDER BY bucket_start
|
||||
* DESC)` window so the sparkline takes the last `SPARK_DAYS`
|
||||
* buckets per type regardless of age.
|
||||
* - `SELECT DISTINCT date_trunc('day', measured_at)::date` over the
|
||||
* 365-day window → at most 365 dates (vs. up to 100k raw rows).
|
||||
*
|
||||
@@ -192,25 +200,40 @@ function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the most recent reading
|
||||
* per measurement type inside the 7-day window. One row per metric
|
||||
* the user touched in the window; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`. */
|
||||
interface LatestIn7dRow {
|
||||
/** v1.4.38 W-F — `DISTINCT ON (type)` row for the single most recent
|
||||
* reading per measurement type, irrespective of age. One row per
|
||||
* metric the user has ever touched; replaces the legacy unbounded
|
||||
* `prisma.measurement.findMany`.
|
||||
*
|
||||
* REG-11 (v1.4.44): dropped the trailing-7-day window from the WHERE
|
||||
* clause. The old shape returned `latestValue: null` + an empty
|
||||
* sparkline for accounts whose last BP / pulse reading was older than
|
||||
* 7 days, leaving the iOS tile blank even though the historical row
|
||||
* was still in the database. The all-time aggregate (`groupBy` on
|
||||
* `measurements`) already proves the row exists; the row count is
|
||||
* still bounded by `|measurementTypes|` via `DISTINCT ON`. */
|
||||
interface LatestEverRow {
|
||||
type: MeasurementType;
|
||||
value: number;
|
||||
measured_at: Date;
|
||||
}
|
||||
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket inside the 7-day
|
||||
* window. At most 7 buckets per metric × N metrics — bounded by
|
||||
/** v1.4.38 W-F — per-day measurement_rollup bucket feeding the dashboard
|
||||
* sparkline. At most 7 buckets per metric × N metrics — bounded by
|
||||
* `SPARK_DAYS * |measurementTypes|` rather than the raw row count.
|
||||
*
|
||||
* v1.4.39 W-SUM — `sum_value` rides along so the cumulative tile
|
||||
* (ACTIVITY_STEPS) renders the daily SUM rather than the per-bucket
|
||||
* MEAN. Spot metrics ignore the column; cumulative tiles fall back to
|
||||
* `mean * count` when the legacy NULL hits (boot-backfill convergence
|
||||
* window). */
|
||||
* window).
|
||||
*
|
||||
* REG-11 (v1.4.44): switched from a calendar-window filter
|
||||
* (`bucket_start >= sevenDaysAgo`) to a `ROW_NUMBER() OVER (PARTITION
|
||||
* BY type ORDER BY bucket_start DESC)` window so the sparkline takes
|
||||
* the last `SPARK_DAYS` daily buckets per type regardless of age. An
|
||||
* account whose last BP reading is 60 days old now still gets the
|
||||
* trailing 7 days of historical buckets feeding the tile chart. */
|
||||
interface SparklineRow {
|
||||
type: MeasurementType;
|
||||
bucket_start: Date;
|
||||
@@ -308,7 +331,10 @@ async function buildDashboardSummary(
|
||||
ctx: SummaryBuilderContext,
|
||||
) {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
// REG-11 (v1.4.44): the trailing-7-day pivot used to anchor the
|
||||
// latest-reading + sparkline filters is gone — both sub-queries now
|
||||
// take the most recent N rows per type regardless of age. Only the
|
||||
// streak window still anchors on a calendar slice.
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
@@ -337,46 +363,56 @@ async function buildDashboardSummary(
|
||||
|
||||
// v1.4.38 W-F — six bounded sub-queries replace the legacy 4
|
||||
// unbounded ones. Row counts are now:
|
||||
// - latestIn7d: ≤ N metric types (one row per type via DISTINCT ON)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80)
|
||||
// - latestEver: ≤ N metric types (one row per type via DISTINCT ON,
|
||||
// REG-11: dropped the 7-day window so a stale-but-
|
||||
// valid historical reading still feeds the tile)
|
||||
// - sparkBuckets: ≤ SPARK_DAYS × N metric types (typically <80,
|
||||
// REG-11: taken via ROW_NUMBER window, no calendar
|
||||
// filter, so a 60-day-old metric still paints a chart)
|
||||
// - allTimeAggregate: ≤ N metric types (unchanged)
|
||||
// - todaysIntakes: ≤ daily intake schedule count (unchanged)
|
||||
// - streakActivity: ≤ daily intake count × 365 (unchanged)
|
||||
// - measurementStreakDays: ≤ 365 (was: every raw row in 365d)
|
||||
const [
|
||||
latestIn7d,
|
||||
latestEver,
|
||||
sparkBuckets,
|
||||
allTimeAggregate,
|
||||
todaysIntakes,
|
||||
streakActivity,
|
||||
measurementStreakDays,
|
||||
] = await Promise.all([
|
||||
time("latest7d", () =>
|
||||
prisma.$queryRaw<LatestIn7dRow[]>`
|
||||
time("latestEver", () =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
m."measured_at" AS measured_at
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."measured_at" >= ${sevenDaysAgo}
|
||||
AND m."deleted_at" IS NULL
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean,
|
||||
r."count"::int AS count,
|
||||
r."sum_value"::double precision AS sum_value
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
AND r."bucket_start" >= ${sevenDaysAgo}
|
||||
ORDER BY r."type", r."bucket_start" ASC
|
||||
SELECT type, bucket_start, mean, count, sum_value
|
||||
FROM (
|
||||
SELECT
|
||||
r."type" AS type,
|
||||
r."bucket_start" AS bucket_start,
|
||||
r."mean"::double precision AS mean,
|
||||
r."count"::int AS count,
|
||||
r."sum_value"::double precision AS sum_value,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY r."type"
|
||||
ORDER BY r."bucket_start" DESC
|
||||
) AS rn
|
||||
FROM measurement_rollups r
|
||||
WHERE r."user_id" = ${userId}
|
||||
AND r."granularity" = 'DAY'
|
||||
) sub
|
||||
WHERE rn <= ${SPARK_DAYS}
|
||||
ORDER BY type, bucket_start ASC
|
||||
`,
|
||||
),
|
||||
time("allTime", () =>
|
||||
@@ -474,8 +510,11 @@ async function buildDashboardSummary(
|
||||
|
||||
// v1.4.38 W-F — per-type latest (one row per type) + sparkline
|
||||
// (bucket means per day) assembled from the two new SQL aggregates.
|
||||
// REG-11 (v1.4.44): `latestEver` carries the most recent reading
|
||||
// regardless of age — the iOS tile shows the historical value plus a
|
||||
// muted "Letzter Wert vor Xd" caption rather than nothing at all.
|
||||
const latestByType = new Map<MeasurementType, { value: number; at: Date }>();
|
||||
for (const row of latestIn7d) {
|
||||
for (const row of latestEver) {
|
||||
latestByType.set(row.type, {
|
||||
value: Number(row.value),
|
||||
at: new Date(row.measured_at),
|
||||
@@ -541,7 +580,10 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
// Blood pressure (paired sys/dia) — REG-11 (v1.4.44): gate the emit
|
||||
// on `latestSys || latestDia || bpAllTimeCount > 0` so an account
|
||||
// that has never logged BP doesn't get an empty placeholder tile.
|
||||
// Mirrors the body-fat gate below.
|
||||
{
|
||||
const latestSys = latestOf("BLOOD_PRESSURE_SYS");
|
||||
const latestDia = latestOf("BLOOD_PRESSURE_DIA");
|
||||
@@ -560,42 +602,48 @@ async function buildDashboardSummary(
|
||||
if (!diaAt) return sysAt;
|
||||
return sysAt >= diaAt ? sysAt : diaAt;
|
||||
})();
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
if (latestSys || latestDia || bpAllTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysSpark),
|
||||
sparkline: sysSpark,
|
||||
updatedAt:
|
||||
latestSys?.at?.toISOString() ??
|
||||
latestDia?.at?.toISOString() ??
|
||||
bpLastSeenAt,
|
||||
allTimeCount: bpAllTimeCount,
|
||||
lastSeenAt: bpLastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse
|
||||
// Pulse — REG-11 (v1.4.44): gate the emit on `latest || allTimeCount
|
||||
// > 0` so an account that has never logged pulse doesn't get an empty
|
||||
// placeholder tile. Mirrors the body-fat gate below.
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
const meta = metaForType("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
if (latest || meta.allTimeCount > 0) {
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
|
||||
allTimeCount: meta.allTimeCount,
|
||||
lastSeenAt: meta.lastSeenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Body fat — v1.4.33 widens the emit gate from `latestOf` (only
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { PUT, __resetAuditDedupMemoForTests } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -73,6 +73,7 @@ function makeReq(body: unknown): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
__resetAuditDedupMemoForTests();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
@@ -144,6 +145,26 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
}
|
||||
});
|
||||
|
||||
it("dedups the audit-ledger write across two sequential 422s for the same user (v1.4.43 B2)", async () => {
|
||||
// First 422 writes one row; the second 422 inside the 60 s window
|
||||
// returns the same envelope but skips the audit insert.
|
||||
const res1 = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res1.status).toBe(422);
|
||||
const res2 = await callPut(makeReq({ version: 2, widgets: [] }));
|
||||
expect(res2.status).toBe(422);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full multi-issue envelope still rides on every 422 — the
|
||||
// dedup only suppresses the breadcrumb write, never the response.
|
||||
const body2 = (await res2.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body2.details.issues.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not block the 422 response when the audit-row write rejects", async () => {
|
||||
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
|
||||
@@ -42,6 +42,40 @@ import type { NextRequest } from "next/server";
|
||||
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
|
||||
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
|
||||
|
||||
// v1.4.43 B2 — in-process dedup memo for the validation-failed audit row.
|
||||
// A misbehaving iOS client looping on a 422 can otherwise write thousands
|
||||
// of identical audit rows per minute. We dedup on `(userId, action)` with
|
||||
// a 60 s window: only the first 422 inside the window writes; subsequent
|
||||
// 422s still return the full multi-issue envelope but skip the breadcrumb.
|
||||
// In-memory only — a process restart resets the map, which is the safe
|
||||
// failure mode (worst case: one extra audit row per restart). Cluster
|
||||
// deployments still get one row per process per minute, which is fine for
|
||||
// the operator-grep use case the audit row exists for.
|
||||
const AUDIT_DEDUP_WINDOW_MS = 60_000;
|
||||
const auditDedupMemo = new Map<string, number>();
|
||||
|
||||
function shouldEmitAuditRow(userId: string, action: string, now: number): boolean {
|
||||
const key = `${userId}:${action}`;
|
||||
const last = auditDedupMemo.get(key);
|
||||
if (last !== undefined && now - last < AUDIT_DEDUP_WINDOW_MS) {
|
||||
return false;
|
||||
}
|
||||
auditDedupMemo.set(key, now);
|
||||
// Opportunistic GC — drop entries older than the window on every miss so
|
||||
// the map cannot grow unbounded across a long-running process.
|
||||
if (auditDedupMemo.size > 512) {
|
||||
for (const [k, t] of auditDedupMemo) {
|
||||
if (now - t >= AUDIT_DEDUP_WINDOW_MS) auditDedupMemo.delete(k);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @internal — exported for the dedup unit test only. */
|
||||
export function __resetAuditDedupMemoForTests(): void {
|
||||
auditDedupMemo.clear();
|
||||
}
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z
|
||||
@@ -143,18 +177,29 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "dashboard.widgets.validation-failed" },
|
||||
meta: { issue_count: issues.length },
|
||||
});
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
|
||||
// so a misbehaving iOS client retrying every second cannot pump
|
||||
// thousands of identical rows into the audit ledger.
|
||||
if (
|
||||
shouldEmitAuditRow(
|
||||
user.id,
|
||||
"dashboard.widgets.validation-failed",
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
// Best-effort breadcrumb — never block the 422 on a write miss.
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "dashboard.widgets.validation-failed",
|
||||
details: JSON.stringify({ issues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — validation response is the contract, audit row is best-effort */
|
||||
});
|
||||
}
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GET } from "../route";
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILD_SHA;
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
|
||||
delete process.env.NEXT_PUBLIC_APP_VERSION;
|
||||
offlineGeoReadyMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
@@ -57,6 +58,26 @@ describe("GET /api/version", () => {
|
||||
expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("prefers NEXT_PUBLIC_APP_VERSION over package.json when set (v1.4.43 B11)", async () => {
|
||||
// Build-arg-injected version wins over the bundled package.json so a
|
||||
// BuildKit layer-cache hit on `pnpm build` cannot ship a stale version
|
||||
// string. The CI tag ref (`v1.4.43`, etc.) is forwarded straight to
|
||||
// the env so the runtime answer can never drift from the release tag.
|
||||
process.env.NEXT_PUBLIC_APP_VERSION = "v1.4.43";
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.version).toBe("v1.4.43");
|
||||
});
|
||||
|
||||
it("falls through to package.json when NEXT_PUBLIC_APP_VERSION is blank", async () => {
|
||||
// A whitespace-only env value must NOT shadow the package.json read --
|
||||
// the trim()+OR pattern ensures the fallback still kicks in.
|
||||
process.env.NEXT_PUBLIC_APP_VERSION = " ";
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(\.\d+)?(-[a-z0-9.-]+)?$/i);
|
||||
});
|
||||
|
||||
it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => {
|
||||
offlineGeoReadyMock.mockReturnValue(false);
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
@@ -37,7 +37,15 @@ export const dynamic = "force-dynamic";
|
||||
* first use.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const version = packageJson.version;
|
||||
// v1.4.43 B11 — prefer the build-arg-injected env var so the runtime
|
||||
// version cannot drift from the CI release tag. The Dockerfile takes
|
||||
// `NEXT_PUBLIC_APP_VERSION` as a build arg from the docker-publish
|
||||
// workflow (`build-args: NEXT_PUBLIC_APP_VERSION=${{ github.ref_name
|
||||
// }}`); when present it wins over the package.json fallback so a
|
||||
// BuildKit-layer cache hit on `pnpm build` can never re-ship the
|
||||
// prior release's version string. Local `pnpm dev` (env unset) falls
|
||||
// through to package.json as before.
|
||||
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version;
|
||||
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
|
||||
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
|
||||
const offlineGeoEnabled = offlineGeoReady();
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({
|
||||
workoutRoute: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
// Same shape as the real Prisma client — invoke the supplied
|
||||
// callback with the mocked client itself so `tx.workout.createMany`
|
||||
@@ -120,6 +123,12 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
// v1.4.43 W9 — default to no per-user source-priority override so
|
||||
// the write-time picker walks the canonical default ladder. Tests
|
||||
// that exercise a custom ladder override this in-line.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
|
||||
@@ -377,6 +386,69 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => {
|
||||
it("looks up the user's source-priority blob exactly once per batch", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-prio-1"),
|
||||
validWorkout("uuid-prio-2", {
|
||||
startedAt: "2026-05-14T08:30:00.000Z",
|
||||
endedAt: "2026-05-14T09:15:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("threads a custom MANUAL>APPLE_HEALTH ladder so the user's preferred row survives the write-time dedup", async () => {
|
||||
// User has flipped the default in Settings. Two rows for the same
|
||||
// logical run inside the 90 s write-time window — MANUAL must
|
||||
// win because the user told us so.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: {
|
||||
steps: ["MANUAL", "APPLE_HEALTH", "WITHINGS"],
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-apple", { source: "APPLE_HEALTH" }),
|
||||
validWorkout("uuid-manual", {
|
||||
source: "MANUAL",
|
||||
startedAt: "2026-05-14T06:31:00.000Z",
|
||||
endedAt: "2026-05-14T07:16:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ index: number; status: string }>;
|
||||
};
|
||||
};
|
||||
// One survivor (MANUAL), one dropped twin (APPLE_HEALTH).
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.duplicates).toBe(1);
|
||||
// The Apple row at index 0 is the duplicate; the Manual row at
|
||||
// index 1 is the survivor.
|
||||
expect(body.data.entries[0]?.status).toBe("duplicate");
|
||||
expect(body.data.entries[1]?.status).toBe("inserted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () => {
|
||||
it("enqueues PR detection after a successful batch with silent=false", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
|
||||
@@ -119,6 +119,19 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.4.43 W9 — resolve the per-user source-priority blob once at
|
||||
// the start of the handler so the write-time canonical-row picker
|
||||
// walks the SAME ladder the read-time picker walks. Without this
|
||||
// lookup a user who customised Settings → Sources (e.g. MANUAL >
|
||||
// APPLE_HEALTH) would see their preferred row dropped at write-
|
||||
// time before the read-time picker ever ran. One indexed
|
||||
// findUnique per batch — well under the cost of letting the
|
||||
// duplicate persist and carrying it forward on every read.
|
||||
const userPriority = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
|
||||
// Content-Length pre-flight. The body cap exists to bound the
|
||||
// request-pipeline cost BEFORE Node has to read the full payload —
|
||||
// a misbehaving client shipping a 50 MB body shouldn't tie up the
|
||||
@@ -287,6 +300,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// falls through to input order via the `index` field below.
|
||||
index: p.index,
|
||||
})),
|
||||
userPriority?.sourcePriorityJson ?? null,
|
||||
);
|
||||
const survivingIndices = new Set(canonicalPrepared.map((r) => r.index ?? -1));
|
||||
const droppedByWriteDedup: number[] = [];
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
/**
|
||||
* Root-level error boundary. Renders when even the root layout itself fails,
|
||||
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
|
||||
* in English, and self-contained.
|
||||
* self-contained, and bilingual: both English and German appear side by side
|
||||
* so a German visitor never lands on an all-English last-resort screen, and
|
||||
* an English visitor can still read it.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
@@ -28,7 +30,7 @@ export default function GlobalError({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
window.prompt("Copy details", text);
|
||||
window.prompt("Details kopieren · Copy details", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +57,13 @@ export default function GlobalError({
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h1 style={{ fontSize: 20, marginBottom: 12 }}>
|
||||
Something went wrong
|
||||
Etwas ist schiefgegangen · Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: "#d0d0d0", marginBottom: 16, fontSize: 14 }}>
|
||||
A critical error occurred. You can retry or copy the details for
|
||||
support.
|
||||
Ein kritischer Fehler ist aufgetreten. Du kannst es erneut versuchen
|
||||
oder die Details kopieren.
|
||||
<br />A critical error occurred. You can retry or copy the details
|
||||
for support.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
@@ -72,7 +76,7 @@ export default function GlobalError({
|
||||
border: "1px solid #44475a",
|
||||
}}
|
||||
>
|
||||
{error.message || "Unknown error"}
|
||||
{error.message || "Unbekannter Fehler · Unknown error"}
|
||||
</pre>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
@@ -92,7 +96,7 @@ export default function GlobalError({
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
Erneut versuchen · Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
@@ -107,7 +111,7 @@ export default function GlobalError({
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Copy details
|
||||
Details kopieren · Copy details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-5
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB6 — branded 404 page. Next.js mounts this file for any
|
||||
@@ -11,12 +13,19 @@ import { Logo } from "@/components/ui/logo";
|
||||
* application error and doesn't need the bug-report path. Just a
|
||||
* branded splash with one link home.
|
||||
*
|
||||
* v1.4.43 H4 — the copy is locale-resolved server-side from the same
|
||||
* cookie / Accept-Language path the rest of the app uses, so a German
|
||||
* visitor no longer hits an English-only splash on a typo URL.
|
||||
*
|
||||
* Mobile-first: `min-h-dvh` follows the dynamic viewport so the
|
||||
* panel anchors centred under iOS Safari's animated URL bar;
|
||||
* `safe-area-inset-top` keeps the headline clear of the notch or
|
||||
* Dynamic Island.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
export default async function NotFound() {
|
||||
const locale = await resolveServerLocale();
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
return (
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -30,18 +39,17 @@ export default function NotFound() {
|
||||
404
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight md:text-3xl">
|
||||
Page not found
|
||||
{t("errors.notFound.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
The page you were looking for doesn't exist or has been moved.
|
||||
Head back to the dashboard to pick up where you left off.
|
||||
{t("errors.notFound.body")}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-medium transition-colors"
|
||||
>
|
||||
Back to dashboard
|
||||
{t("errors.notFound.backToDashboard")}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
@@ -183,7 +184,8 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
behavior: scrollBehaviorForUser(),
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.43 W2-CHART-GATE — pin the empty-state copy split.
|
||||
*
|
||||
* Pre-v1.4.43 the chart painted "Erfasse mehr Messungen, um die
|
||||
* Trendlinie freizuschalten" whenever `chartData.length < 3` — but
|
||||
* `chartData` is daily-aggregated, so a user with many measurements
|
||||
* on < 3 distinct days saw the misleading "log more measurements"
|
||||
* hint despite having logged plenty. The gate now keys on the raw
|
||||
* measurement count surfaced via the `rawCount` property the
|
||||
* queryFn stashes on its return array. When `rawCount >= 3` the
|
||||
* "need more days" copy paints; otherwise the legacy "log more"
|
||||
* copy stays.
|
||||
*/
|
||||
|
||||
function buildData(
|
||||
rows: Array<{ measuredAt: string; value: number; count?: number }>,
|
||||
rawCount: number,
|
||||
): unknown[] {
|
||||
// Re-use the same array-with-stashed-property shape the real
|
||||
// queryFn produces. The chart's `useMemo(() => …, [data])` reads
|
||||
// `(data as ChartDataPoint[] & { rawCount?: number }).rawCount`.
|
||||
const points = rows.map((r) => ({
|
||||
date: r.measuredAt.slice(0, 10),
|
||||
timestamp: new Date(r.measuredAt).getTime(),
|
||||
PULSE: r.value,
|
||||
}));
|
||||
Object.defineProperty(points, "rawCount", {
|
||||
value: rawCount,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
return points;
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart> — empty-state gate by raw count", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
|
||||
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
|
||||
// Two distinct days with 50 raw rows total — the daily-aggregated
|
||||
// chartData collapses to 2 points but the user logged plenty.
|
||||
const data = buildData(
|
||||
[
|
||||
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
|
||||
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
|
||||
],
|
||||
50,
|
||||
);
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Mehr Messtage erforderlich");
|
||||
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("renders no-data copy when rawCount < 3", async () => {
|
||||
const data = buildData(
|
||||
[{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }],
|
||||
1,
|
||||
);
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Erfasse mindestens 3 Einträge");
|
||||
expect(html).not.toContain("Mehr Messtage erforderlich");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.43 W2-CHART-GATE — medication-compliance empty-state copy split.
|
||||
*
|
||||
* The chart aggregates per-day compliance buckets, so a user with 20
|
||||
* scheduled doses on 2 calendar days collapses to `chartData.length =
|
||||
* 2`. The pre-v1.4.43 "log more measurements" hint misled — they
|
||||
* logged plenty, just on too few days. The gate now keys on
|
||||
* `rawCount = Σ scheduled` so the second message paints whenever the
|
||||
* user has enough raw doses but the chart is < 3 buckets wide.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<MedicationComplianceChart> — empty-state gate by raw count", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
|
||||
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
|
||||
const data = [
|
||||
{ date: "2026-05-19", scheduled: 10, taken: 8 },
|
||||
{ date: "2026-05-20", scheduled: 10, taken: 9 },
|
||||
];
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Mehr Messtage erforderlich");
|
||||
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("renders no-data copy when rawCount < 3", async () => {
|
||||
const data = [{ date: "2026-05-20", scheduled: 1, taken: 1 }];
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Erfasse mindestens 3 Einträge");
|
||||
expect(html).not.toContain("Mehr Messtage erforderlich");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.43 W2-CHART-GATE — mood-chart empty-state copy split.
|
||||
*
|
||||
* `chartData` collapses entries to one point per calendar day, so a
|
||||
* user with many mood entries on < 3 distinct days saw the legacy
|
||||
* "log more entries" copy despite having logged plenty. The gate now
|
||||
* keys on `rawCount = Σ entries[].samples` so the second message
|
||||
* paints when the user only needs more spread across days.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<MoodChart> — empty-state gate by raw count", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
|
||||
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
|
||||
const data = {
|
||||
entries: [
|
||||
{ date: "2026-05-19", score: 4, samples: 25 },
|
||||
{ date: "2026-05-20", score: 5, samples: 25 },
|
||||
],
|
||||
trend: null,
|
||||
};
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Mehr Messtage erforderlich");
|
||||
expect(html).not.toContain("Erfasse mindestens 3 Einträge");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("renders no-data copy when rawCount < 3", async () => {
|
||||
const data = {
|
||||
entries: [{ date: "2026-05-20", score: 4, samples: 1 }],
|
||||
trend: null,
|
||||
};
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data, isLoading: false }),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Erfasse mindestens 3 Einträge");
|
||||
expect(html).not.toContain("Mehr Messtage erforderlich");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -248,7 +248,7 @@ export function ChartOverlayControlsBody({
|
||||
prefs.comparisonBaseline === value ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
className={`h-9 px-1 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()}
|
||||
className={`min-h-11 sm:min-h-9 px-2 text-[11px] ${isGreyed ? "opacity-50" : ""}`.trim()}
|
||||
onClick={() => setComparisonBaseline(value)}
|
||||
aria-pressed={prefs.comparisonBaseline === value}
|
||||
aria-disabled={isGreyed || undefined}
|
||||
|
||||
@@ -185,6 +185,13 @@ interface ChartDataPoint {
|
||||
interface MeasurementApiRow {
|
||||
measuredAt: string;
|
||||
value: number;
|
||||
// v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return
|
||||
// the underlying raw-row count per bucket. Used downstream to gate
|
||||
// the "more days needed" empty-state copy on the actual measurement
|
||||
// count rather than the number of distinct calendar days. Optional
|
||||
// because the short-window raw-row path does not populate it (each
|
||||
// row already represents one measurement, count = 1).
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface VisibleTargetZone {
|
||||
@@ -576,6 +583,14 @@ export function HealthChart({
|
||||
}
|
||||
>();
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — accumulate the raw measurement count
|
||||
// across every fetched type so the empty-state copy can
|
||||
// distinguish "user logged < 3 measurements" from "user logged
|
||||
// many measurements but on < 3 distinct days". The chartData
|
||||
// length collapses to daily buckets and would otherwise paint
|
||||
// the "log more" hint even when the user already logged 50
|
||||
// entries on 2 days.
|
||||
let rawMeasurementCount = 0;
|
||||
async function fetchMeasurementsByType(type: string) {
|
||||
const typeParams = new URLSearchParams();
|
||||
typeParams.set("type", type);
|
||||
@@ -625,6 +640,12 @@ export function HealthChart({
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — rollup / server-aggregated rows
|
||||
// carry the underlying `count`; raw rows do not, so each
|
||||
// counts as one. Sum across types since the gate is the
|
||||
// total user-logged measurements in the window.
|
||||
rawMeasurementCount += measurement.count ?? 1;
|
||||
|
||||
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
|
||||
const bucket = dailyAggregates.get(dayKey) ?? {
|
||||
timestamp: dayKeyToTimestamp(dayKey),
|
||||
@@ -664,11 +685,30 @@ export function HealthChart({
|
||||
})
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — stash the raw measurement count on
|
||||
// the returned array as a non-enumerable property so the gate
|
||||
// logic can distinguish "few measurements" from "few days" in
|
||||
// the empty-state copy. Non-enumerable keeps the array shape
|
||||
// intact for every downstream consumer that iterates / spreads.
|
||||
Object.defineProperty(allData, "rawCount", {
|
||||
value: rawMeasurementCount,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
return allData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — surface the stashed raw count.
|
||||
const rawCount = useMemo<number>(() => {
|
||||
if (!data) return 0;
|
||||
const value = (data as ChartDataPoint[] & { rawCount?: number }).rawCount;
|
||||
return typeof value === "number" ? value : 0;
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.length) return data;
|
||||
|
||||
@@ -1252,10 +1292,25 @@ export function HealthChart({
|
||||
// v1.4.16 B1a — sparse-data placeholder. <3 daily points is too
|
||||
// few to render a meaningful trend; paint a friendly hint
|
||||
// instead so the dashboard doesn't look broken.
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
/>
|
||||
//
|
||||
// v1.4.43 W2-CHART-GATE — split the copy on the raw measurement
|
||||
// count. A user with 50 BP readings on 2 calendar days has
|
||||
// `chartData.length = 2` (daily-aggregated) but `rawCount = 50`,
|
||||
// so the legacy "log more measurements" hint was misleading.
|
||||
// When the user has logged enough raw measurements but only
|
||||
// hit < 3 distinct days, paint the "need more days" copy
|
||||
// instead so the next action is clear.
|
||||
rawCount >= 3 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.needMoreDistinctDaysTitle")}
|
||||
description={t("charts.needMoreDistinctDaysDescription")}
|
||||
/>
|
||||
) : (
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={`relative ${chartHeightClass}`}>
|
||||
{visibleBands.length > 0 ? (
|
||||
|
||||
@@ -252,6 +252,21 @@ export function MedicationComplianceChart({
|
||||
[data, tzFmt],
|
||||
);
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — raw scheduled-dose count across every day
|
||||
// in the window. The chart bucket aggregates per-day compliance, so
|
||||
// a user with 20 scheduled doses on 2 calendar days collapses to
|
||||
// `chartData.length = 2`. Sum the `scheduled` field across the raw
|
||||
// daily points so the empty-state gate can distinguish "no doses
|
||||
// tracked" from "doses tracked but on too few days".
|
||||
const rawCount = useMemo<number>(() => {
|
||||
if (!data?.length) return 0;
|
||||
return data.reduce(
|
||||
(acc, point) =>
|
||||
acc + (Number.isFinite(point.scheduled) ? point.scheduled : 0),
|
||||
0,
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
// v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a
|
||||
// user toggled to "7 days" still sees a trend (which would otherwise
|
||||
// be empty if we only used the visible window). The 14-day cap inside
|
||||
@@ -391,10 +406,23 @@ export function MedicationComplianceChart({
|
||||
) : chartData.length < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder consistent with the
|
||||
// BP/weight/pulse/mood charts.
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
/>
|
||||
//
|
||||
// v1.4.43 W2-CHART-GATE — split the copy on the raw scheduled-
|
||||
// dose count. A user with 20 scheduled doses on 2 calendar
|
||||
// days has `chartData.length = 2`; the legacy "log more" hint
|
||||
// misled — they logged plenty, just on too few days. Surface
|
||||
// "need more days" when the raw count is already enough.
|
||||
rawCount >= 3 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.needMoreDistinctDaysTitle")}
|
||||
description={t("charts.needMoreDistinctDaysDescription")}
|
||||
/>
|
||||
) : (
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)] touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -340,6 +340,20 @@ export function MoodChart({
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
|
||||
// the window. `entries[].samples` carries the per-day raw count
|
||||
// straight from the analytics endpoint; summing it produces the
|
||||
// total entries the user logged, independent of how many calendar
|
||||
// days they hit. Used by the empty-state gate to distinguish "few
|
||||
// entries" from "few days".
|
||||
const rawCount = useMemo<number>(() => {
|
||||
if (!data?.entries?.length) return 0;
|
||||
return data.entries.reduce(
|
||||
(acc, entry) => acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
|
||||
0,
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const chartData = useMemo((): ChartDataPoint[] | undefined => {
|
||||
if (!data?.entries?.length) return undefined;
|
||||
|
||||
@@ -682,11 +696,25 @@ export function MoodChart({
|
||||
// shared CHART_HEIGHT_PX so the empty state preserves the
|
||||
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
|
||||
// other dashboard chart card).
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
//
|
||||
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
|
||||
// count. A user with 50 mood entries across 2 calendar days
|
||||
// sees `chartData.length = 2` (daily-aggregated) but the raw
|
||||
// count (sum of `samples`) is 50. Surface "need more days"
|
||||
// in that scenario so the call-to-action matches reality.
|
||||
rawCount >= 3 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.needMoreDistinctDaysTitle")}
|
||||
description={t("charts.needMoreDistinctDaysDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : (
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`${
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -194,7 +195,8 @@ export function MessageThread({
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
if (wasPinnedRef.current) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
|
||||
}
|
||||
}, [messages.length, streaming?.content, optimisticUser?.localId]);
|
||||
|
||||
|
||||
@@ -1055,7 +1055,7 @@ function DayDrillDown({
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-destructive py-2 text-xs">
|
||||
{t("measurements.saveError")}
|
||||
{t("measurements.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
className="h-11 w-11"
|
||||
disabled={loading}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
|
||||
@@ -613,7 +613,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
className="h-11 w-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
|
||||
@@ -26,7 +26,11 @@ import { cn } from "@/lib/utils";
|
||||
* abbreviated time form into a 12 char string at most.
|
||||
*/
|
||||
|
||||
export type IntegrationPillState = "connected" | "error" | "disconnected";
|
||||
export type IntegrationPillState =
|
||||
| "connected"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "disconnected";
|
||||
|
||||
interface IntegrationStatusPillProps {
|
||||
state: IntegrationPillState;
|
||||
@@ -74,10 +78,15 @@ export function IntegrationStatusPill({
|
||||
}: IntegrationStatusPillProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// chipClass only applies to the `connected` branch — `error` falls back
|
||||
// to `variant="destructive"` and `disconnected` to `variant="outline"`.
|
||||
// chipClass only applies to the `connected` and `warning` branches —
|
||||
// `error` falls back to `variant="destructive"` and `disconnected` to
|
||||
// `variant="outline"`. The warning chip uses amber tokens to signal
|
||||
// "connected, but the upstream just returned a contract-level error
|
||||
// a reauth won't fix" — distinct from the red reconnect pill.
|
||||
const connectedChipClass =
|
||||
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
|
||||
const warningChipClass =
|
||||
"border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow";
|
||||
|
||||
let label: string;
|
||||
let icon: React.ReactNode;
|
||||
@@ -87,6 +96,10 @@ export function IntegrationStatusPill({
|
||||
label = t("settings.integrationPill.connected");
|
||||
icon = <CheckCircle2 aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "warning":
|
||||
label = t("settings.integrationPill.warningServerError");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
break;
|
||||
case "error":
|
||||
label = t("settings.integrationPill.errorReconnect");
|
||||
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
|
||||
@@ -111,7 +124,7 @@ export function IntegrationStatusPill({
|
||||
data-testid="integration-status-pill"
|
||||
data-state={state}
|
||||
variant={
|
||||
state === "connected"
|
||||
state === "connected" || state === "warning"
|
||||
? undefined
|
||||
: state === "error"
|
||||
? "destructive"
|
||||
@@ -121,6 +134,7 @@ export function IntegrationStatusPill({
|
||||
className={cn(
|
||||
"max-w-full whitespace-nowrap",
|
||||
state === "connected" && connectedChipClass,
|
||||
state === "warning" && warningChipClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
@@ -169,7 +170,8 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
behavior: scrollBehaviorForUser(),
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ function DialogContent({
|
||||
// crowd the dialog header out of proportion.
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 sm:min-h-9 sm:min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
|
||||
@@ -85,7 +85,7 @@ function SheetContent({
|
||||
// header out of proportion on the bottom-sheet branch.
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 sm:min-h-9 sm:min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
|
||||
@@ -17,7 +17,13 @@ function Switch({
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
// v1.4.43 W5-H1 — extend the WCAG 2.5.5 hit target to 44×44 px via
|
||||
// a `::before` pseudo-element while leaving the visual track at
|
||||
// 18×32 px. The same trick is used on Input (v1.4.34 IW-G). The
|
||||
// wrapping `relative` and `before:absolute … before:inset-[-12px]`
|
||||
// pair guarantees pointer events bubble up from the larger zone
|
||||
// without disturbing layout.
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none before:absolute before:inset-[-12px] before:content-[''] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
|
||||
// The vitest config runs unit tests under Node (no jsdom). The helper
|
||||
// only touches `window.matchMedia`, so we mount a minimal stub via
|
||||
// `vi.stubGlobal` and tear it down after each case.
|
||||
|
||||
function mountWindow(matchesReducedMotion: boolean) {
|
||||
vi.stubGlobal("window", {
|
||||
matchMedia: (query: string) => ({
|
||||
matches:
|
||||
matchesReducedMotion && query === "(prefers-reduced-motion: reduce)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("scrollBehaviorForUser", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns `auto` when window is undefined (SSR)", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(scrollBehaviorForUser()).toBe("auto");
|
||||
});
|
||||
|
||||
it("returns `auto` when prefers-reduced-motion matches", () => {
|
||||
mountWindow(true);
|
||||
expect(scrollBehaviorForUser()).toBe("auto");
|
||||
});
|
||||
|
||||
it("returns `smooth` when prefers-reduced-motion does not match", () => {
|
||||
mountWindow(false);
|
||||
expect(scrollBehaviorForUser()).toBe("smooth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* v1.4.43 — pins the `p-limit(4)` cap on the WMY per-type fan-out
|
||||
* inside the slim summaries slice.
|
||||
*
|
||||
* The pre-fix `computeAvg30LastYearMap` ran an unbounded `Promise.all`
|
||||
* over every type the user had data for. On a 15-type tenant the
|
||||
* burst held 15+ Prisma slots simultaneously, drowning the `pg.Pool`
|
||||
* max=20 when the slim and thick analytics slices fired in parallel
|
||||
* on dashboard mount (`.planning/round-v1443-AUDIT-analytics-9s-findings.md`).
|
||||
*
|
||||
* This test mirrors the v1.4.40 W-POOL pin
|
||||
* (`src/app/api/analytics/__tests__/route.test.ts` —
|
||||
* "pin concurrency cap and pool ceiling"). It mocks
|
||||
* `readBestGranularityRollups` with a controllable promise so we can
|
||||
* observe the in-flight count at the helper boundary and assert it
|
||||
* never exceeds 4 across a 15-type fan-out.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
import {
|
||||
WMY_FANOUT_CONCURRENCY,
|
||||
computeAvg30LastYearMap,
|
||||
} from "../summaries-slice";
|
||||
|
||||
const READ = readBestGranularityRollups as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
READ.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("computeAvg30LastYearMap — WMY fan-out cap (v1.4.43)", () => {
|
||||
it("pins the module-level concurrency constant at 4", () => {
|
||||
// Catches accidental loosening of the cap in a future edit. The
|
||||
// route-level `ANALYTICS_TYPE_FETCH_CONCURRENCY` uses the same
|
||||
// value; both must move together if ever revisited.
|
||||
expect(WMY_FANOUT_CONCURRENCY).toBe(4);
|
||||
});
|
||||
|
||||
it("holds at most 4 concurrent readBestGranularityRollups calls for a 15-type list", async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const resolvers: Array<() => void> = [];
|
||||
|
||||
READ.mockImplementation(() => {
|
||||
inFlight += 1;
|
||||
if (inFlight > peak) peak = inFlight;
|
||||
return new Promise((resolve) => {
|
||||
resolvers.push(() => {
|
||||
inFlight -= 1;
|
||||
// Resolve with `null` — the helper treats no-coverage as
|
||||
// `null` in the output map, which is fine for this test.
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const types = Array.from({ length: 15 }, (_, i) => `TYPE_${i}`);
|
||||
const promise = computeAvg30LastYearMap("user-1", types);
|
||||
|
||||
// Let the scheduler hand out the initial batch before we start
|
||||
// draining. Two macrotask flushes is enough for `p-limit` to fill
|
||||
// its slots up to the cap.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(WMY_FANOUT_CONCURRENCY);
|
||||
expect(peak).toBeGreaterThan(0);
|
||||
|
||||
// Drain the queued resolvers one at a time, re-checking the peak
|
||||
// after each release so a future regression that floods the
|
||||
// pool on completion would also be caught.
|
||||
while (resolvers.length > 0) {
|
||||
const next = resolvers.shift();
|
||||
next?.();
|
||||
// Yield so `p-limit` can schedule the next slot before we
|
||||
// observe again.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(peak).toBeLessThanOrEqual(WMY_FANOUT_CONCURRENCY);
|
||||
}
|
||||
|
||||
await promise;
|
||||
|
||||
// Every type must still have been probed — capping must not
|
||||
// drop work.
|
||||
expect(READ).toHaveBeenCalledTimes(types.length);
|
||||
expect(peak).toBe(WMY_FANOUT_CONCURRENCY);
|
||||
});
|
||||
|
||||
it("returns an empty map without invoking the reader when given no types", async () => {
|
||||
const result = await computeAvg30LastYearMap("user-1", []);
|
||||
expect(result.size).toBe(0);
|
||||
expect(READ).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,8 @@
|
||||
* 100`. Slope `direction` is derived from `slope`'s sign with the
|
||||
* same 0.01-units-per-day "stable" threshold the JS helper uses.
|
||||
*/
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -724,24 +726,41 @@ async function computeAvg30LastYearForType(
|
||||
* in parallel so the long-tail of types doesn't serialise into a
|
||||
* per-type round-trip stack.
|
||||
*
|
||||
* v1.4.43 — cap concurrent per-type WMY reads at 4.
|
||||
*
|
||||
* Pre-fix this helper ran an unbounded Promise.all over every type the
|
||||
* user has data for. On a 15-type tenant the burst held 15+ Prisma
|
||||
* slots simultaneously, and with the slim and thick analytics slices
|
||||
* firing in parallel on dashboard mount the combined fan-out drowned
|
||||
* the pg.Pool max=20 even after the v1.4.40 W-POOL raise. The cap
|
||||
* mirrors the W-POOL `p-limit(4)` discipline v1.4.40 applied to the
|
||||
* thick route's per-type live walk (`ANALYTICS_TYPE_FETCH_CONCURRENCY`
|
||||
* in `src/app/api/analytics/route.ts`) so both slices behave the same
|
||||
* way under burst.
|
||||
*
|
||||
* Returns a `type → mean | null` map; types with no coverage at any
|
||||
* granularity (or no buckets in the year-ago slice) map to `null` so
|
||||
* the caller can blanket-assign without per-type null-checking.
|
||||
*/
|
||||
async function computeAvg30LastYearMap(
|
||||
export const WMY_FANOUT_CONCURRENCY = 4;
|
||||
|
||||
export async function computeAvg30LastYearMap(
|
||||
userId: string,
|
||||
types: ReadonlyArray<string>,
|
||||
): Promise<Map<string, number | null>> {
|
||||
const out = new Map<string, number | null>();
|
||||
if (types.length === 0) return out;
|
||||
const limit = pLimit(WMY_FANOUT_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
const value = await computeAvg30LastYearForType(
|
||||
userId,
|
||||
type as MeasurementType,
|
||||
);
|
||||
return [type, value] as const;
|
||||
}),
|
||||
types.map((type) =>
|
||||
limit(async () => {
|
||||
const value = await computeAvg30LastYearForType(
|
||||
userId,
|
||||
type as MeasurementType,
|
||||
);
|
||||
return [type, value] as const;
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const [type, value] of results) {
|
||||
out.set(type, value);
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
* lift the body here so a future i18n key change lands once.
|
||||
*
|
||||
* Buckets: <1m → just-now, <1h → minutes, <24h → hours, else days.
|
||||
*
|
||||
* v1.4.43 H6 — pluralisation. Each bucket now branches on `count === 1`
|
||||
* to read the *One key (singular) or *Other key (plural), matching the
|
||||
* dashboard.staleHintWeeksOne / Other pattern already in the bundle.
|
||||
* Eliminates the "vor 1 Minuten" grammar bug a German user spotted.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
iso: string,
|
||||
@@ -17,9 +22,28 @@ export function formatRelativeTime(
|
||||
const diffMs = Date.now() - target;
|
||||
if (diffMs < 60_000) return t("insights.relativeJustNow");
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) return t("insights.relativeMinutesAgo", { count: minutes });
|
||||
if (minutes < 60) {
|
||||
return t(
|
||||
minutes === 1
|
||||
? "insights.relativeMinutesAgoOne"
|
||||
: "insights.relativeMinutesAgoOther",
|
||||
{ count: minutes },
|
||||
);
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t("insights.relativeHoursAgo", { count: hours });
|
||||
if (hours < 24) {
|
||||
return t(
|
||||
hours === 1
|
||||
? "insights.relativeHoursAgoOne"
|
||||
: "insights.relativeHoursAgoOther",
|
||||
{ count: hours },
|
||||
);
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return t("insights.relativeDaysAgo", { count: days });
|
||||
return t(
|
||||
days === 1
|
||||
? "insights.relativeDaysAgoOne"
|
||||
: "insights.relativeDaysAgoOther",
|
||||
{ count: days },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// v1.4.43 W5-H5 — single source of truth for `behavior` on the four
|
||||
// programmatic `scrollIntoView` / `scrollTo` call-sites in the app.
|
||||
// `prefers-reduced-motion: reduce` users were previously animated against
|
||||
// their OS-level preference, which is a WCAG 2.3.3 violation. The helper
|
||||
// returns `"auto"` whenever a window is unavailable (SSR) or when the
|
||||
// reduced-motion media query matches.
|
||||
|
||||
export function scrollBehaviorForUser(): ScrollBehavior {
|
||||
if (typeof window === "undefined") return "auto";
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
? "auto"
|
||||
: "smooth";
|
||||
}
|
||||
@@ -174,6 +174,23 @@ describe("WithingsApiError", () => {
|
||||
});
|
||||
expect(err.message).toBe("Withings subscribe error: 293");
|
||||
});
|
||||
|
||||
// v1.4.43 W3-SECURITY (H-2, v1.4.42 L-1 carry-over) — the constructor
|
||||
// caps the message at 1024 chars so a misbehaving upstream cannot
|
||||
// bloat an `AuditLog.details` row through any downstream wrapper.
|
||||
it("caps the message at 1024 chars when upstreamError is unbounded", () => {
|
||||
const huge = "x".repeat(4000);
|
||||
const err = new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: "transient",
|
||||
withingsStatus: 503,
|
||||
reason: "withings_503",
|
||||
upstreamError: huge,
|
||||
});
|
||||
expect(err.message.length).toBe(1024);
|
||||
// Prefix preserved so the legacy regex parser still matches.
|
||||
expect(err.message.startsWith("Withings measure error: 503 - ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyError", () => {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* v1.4.43 W7-B3 — typed-classification regression suite.
|
||||
*
|
||||
* Pins that both `syncUserActivity` and `syncUserSleep` route their
|
||||
* catch-block through `recordWithingsSyncFailure` (the shared helper in
|
||||
* sync.ts that consumes the typed `WithingsApiError.classification`)
|
||||
* rather than the legacy `extractWithingsStatus` /
|
||||
* `isWithingsRefreshReauthFailure` regex chain.
|
||||
*
|
||||
* Regression target: pre-v1.4.43, both sync paths re-parsed the error
|
||||
* message to infer reauth-vs-transient. Any future migration that
|
||||
* accidentally falls back to the regex (e.g. a refactor that drops the
|
||||
* `recordWithingsSyncFailure` import) silently regresses the
|
||||
* classification taxonomy — the typed path picks up rate-limit (601)
|
||||
* and quota (293) verdicts the regex never modelled.
|
||||
*
|
||||
* Strategy: spy on `recordSyncFailure` (the underlying integration
|
||||
* status writer) and assert the recorded `kind` matches the typed
|
||||
* classification — `transient` for a fetch-layer 5xx that comes through
|
||||
* as a `WithingsApiError`, `persistent` for a body-status 293, and
|
||||
* `reauth_required` for the BL-P3-2 403 short-circuit. If a future
|
||||
* refactor drops the `WithingsApiError` propagation, this test fails on
|
||||
* the 293 path (the legacy regex would map 293 to `transient`).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
withingsConnection: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
isReauthRequired: vi.fn().mockResolvedValue(false),
|
||||
parkIntegrationAtReauth: vi.fn(),
|
||||
recordSyncFailure: vi.fn(),
|
||||
recordSyncSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../sync", async () => {
|
||||
const actual = await vi.importActual<typeof import("../sync")>("../sync");
|
||||
return {
|
||||
...actual,
|
||||
getValidToken: vi.fn(async () => ({
|
||||
accessToken: "token",
|
||||
connection: { id: "conn-1", withingsUserId: "wu-1" },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: vi.fn(() => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
...actual,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { recordSyncFailure } from "@/lib/integrations/status";
|
||||
|
||||
import { syncUserActivity } from "../sync-activity";
|
||||
import { syncUserSleep } from "../sync-sleep";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
scope: "user.metrics,user.activity",
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("syncUserActivity — typed-classification catch (v1.4.43 W7-B3)", () => {
|
||||
it("records a body-status 293 (persistent contract mismatch) with kind=persistent", async () => {
|
||||
// The legacy regex chain bucketed 293 as `transient` because
|
||||
// `isWithingsRefreshReauthFailure` only matched 100/101/102 +
|
||||
// 200..299. The typed classifier in response-classifier.ts moves
|
||||
// 293 into PERSISTENT_CODES — pinning that the typed path is
|
||||
// reached.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 293, error: "invalid params" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserActivity("user-1")).rejects.toThrow(
|
||||
/Withings activity error: 293/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledTimes(1);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
integration: "withings",
|
||||
kind: "persistent",
|
||||
errorCode: "293",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a body-status 601 (rate-limited) with kind=transient", async () => {
|
||||
// The regex chain treated 601 identically to "no status info" —
|
||||
// also `transient` by default. We pin the verdict so a future
|
||||
// typo in the classifier table doesn't silently turn rate-limit
|
||||
// bursts into persistent / reauth.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 601, error: "rate limit" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserActivity("user-1")).rejects.toThrow(
|
||||
/Withings activity error: 601/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "transient",
|
||||
errorCode: "601",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a body-status 100 (auth failed) with kind=reauth_required", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 100, error: "auth failed" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserActivity("user-1")).rejects.toThrow(
|
||||
/Withings activity error: 100/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "reauth_required",
|
||||
errorCode: "100",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserSleep — typed-classification catch (v1.4.43 W7-B3)", () => {
|
||||
it("records a body-status 293 with kind=persistent (same typed path as activity)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 293, error: "invalid params" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserSleep("user-1")).rejects.toThrow(
|
||||
/Withings sleep error: 293/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
integration: "withings",
|
||||
kind: "persistent",
|
||||
errorCode: "293",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a body-status 601 with kind=transient", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 601, error: "rate limit" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserSleep("user-1")).rejects.toThrow(
|
||||
/Withings sleep error: 601/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "transient",
|
||||
errorCode: "601",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a body-status 102 with kind=reauth_required", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 102, error: "user does not exist" }),
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(syncUserSleep("user-1")).rejects.toThrow(
|
||||
/Withings sleep error: 102/,
|
||||
);
|
||||
expect(recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "reauth_required",
|
||||
errorCode: "102",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,13 @@ export class WithingsApiError extends Error {
|
||||
const statusLabel =
|
||||
typeof opts.withingsStatus === "number" ? opts.withingsStatus : "?";
|
||||
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
|
||||
super(`Withings ${opts.verb} error: ${statusLabel}${errSegment}`);
|
||||
// v1.4.43 W3-SECURITY (H-2, v1.4.42 L-1 carry-over): cap the
|
||||
// message at 1024 chars in the constructor so every downstream
|
||||
// audit / notification path inherits the bound. A misbehaving
|
||||
// upstream that returns a multi-MB error body must not be able to
|
||||
// bloat an `AuditLog.details` row.
|
||||
const raw = `Withings ${opts.verb} error: ${statusLabel}${errSegment}`;
|
||||
super(raw.slice(0, 1024));
|
||||
this.name = "WithingsApiError";
|
||||
this.verb = opts.verb;
|
||||
this.classification = opts.classification;
|
||||
|
||||
@@ -48,11 +48,7 @@ import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
} from "./response-classifier";
|
||||
import {
|
||||
extractWithingsStatus,
|
||||
isWithingsRefreshReauthFailure,
|
||||
} from "./sync";
|
||||
import { getValidToken } from "./sync";
|
||||
import { getValidToken, recordWithingsSyncFailure } from "./sync";
|
||||
import {
|
||||
isReauthRequired,
|
||||
parkIntegrationAtReauth,
|
||||
@@ -278,24 +274,37 @@ export async function syncUserActivity(
|
||||
ymd(now),
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status = extractWithingsStatus(message);
|
||||
// v1.4.26 defence-in-depth — a 403 from a scope-gated endpoint
|
||||
// always means scope-missing or token-revoked. Park at
|
||||
// `error_reauth` rather than `transient` so pg-boss stops
|
||||
// retrying and the 3-strike alert doesn't fire. The early
|
||||
// scope-skip above covers the common case; this catches the
|
||||
// race where Withings revokes scope between OAuth and the next
|
||||
// sync without invalidating the refresh token.
|
||||
const isReauth =
|
||||
isWithingsRefreshReauthFailure(message) || status === "403";
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: isReauth ? "reauth_required" : "transient",
|
||||
message,
|
||||
errorCode: status,
|
||||
});
|
||||
// v1.4.43 W7-B3 — typed-classification path. The thrown
|
||||
// `WithingsApiError` from `fetchWithingsActivity` carries the
|
||||
// verdict + `withingsStatus` directly; `recordWithingsSyncFailure`
|
||||
// routes it through `classifyError`, which falls back to the regex
|
||||
// parse for plain `Error` instances (e.g. a pg-boss retry that lost
|
||||
// the prototype during JSON round-trip). Pre-v1.4.43 this branch
|
||||
// re-parsed the message via `extractWithingsStatus` +
|
||||
// `isWithingsRefreshReauthFailure`, duplicating logic the
|
||||
// response-classifier already owns.
|
||||
//
|
||||
// BL-P3-2 defence-in-depth — a Withings 403 on this scope-gated
|
||||
// endpoint always means scope-missing or token-revoked; force the
|
||||
// verdict to `reauth_required` so pg-boss stops retrying and the
|
||||
// 3-strike alert still pages on the unexpected case. The
|
||||
// response-classifier's body-status taxonomy doesn't list 403
|
||||
// (Withings emits 403 only through the HTTP layer for scope-gated
|
||||
// resources), so the override has to live at the call site rather
|
||||
// than in the shared classifier.
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: "reauth_required",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
errorCode: "403",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await recordWithingsSyncFailure(userId, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,11 +46,7 @@ import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
} from "./response-classifier";
|
||||
import {
|
||||
extractWithingsStatus,
|
||||
isWithingsRefreshReauthFailure,
|
||||
} from "./sync";
|
||||
import { getValidToken } from "./sync";
|
||||
import { getValidToken, recordWithingsSyncFailure } from "./sync";
|
||||
import {
|
||||
isReauthRequired,
|
||||
parkIntegrationAtReauth,
|
||||
@@ -220,19 +216,29 @@ export async function syncUserSleep(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status = extractWithingsStatus(message);
|
||||
// v1.4.26 defence-in-depth — see sync-activity.ts. A 403 on a
|
||||
// scope-gated endpoint always means scope-missing or token-revoked.
|
||||
const isReauth =
|
||||
isWithingsRefreshReauthFailure(message) || status === "403";
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: isReauth ? "reauth_required" : "transient",
|
||||
message,
|
||||
errorCode: status,
|
||||
});
|
||||
// v1.4.43 W7-B3 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
// it through `classifyError`, which falls back to the legacy regex
|
||||
// for un-prototyped errors (pg-boss JSON round-trip).
|
||||
//
|
||||
// BL-P3-2 defence-in-depth — symmetric to sync-activity. A 403 on
|
||||
// this scope-gated endpoint always means scope-missing or
|
||||
// token-revoked; force `reauth_required` so the 3-strike alert
|
||||
// still pages on the unexpected case.
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "withings",
|
||||
kind: "reauth_required",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
errorCode: "403",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
await recordWithingsSyncFailure(userId, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ export async function syncUserMeasurements(
|
||||
* `Error` instances (e.g. a pg-boss job retry that lost the prototype
|
||||
* during the JSON round-trip).
|
||||
*/
|
||||
async function recordWithingsSyncFailure(
|
||||
export async function recordWithingsSyncFailure(
|
||||
userId: string,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -280,6 +280,62 @@ describe("dedupeWorkoutBatch", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("honours a custom user ladder that promotes MANUAL above APPLE_HEALTH", () => {
|
||||
// v1.4.43 W9 — when the user has customised Settings → Sources to
|
||||
// promote MANUAL above APPLE_HEALTH, a Manual row inside the 90 s
|
||||
// window MUST win over the Apple Watch twin. Without the
|
||||
// user-priority wiring this helper would silently drop the
|
||||
// user's preferred row at write-time before the read-time picker
|
||||
// ever saw it.
|
||||
const apple = row({
|
||||
id: "apple",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
caloriesKcal: 500,
|
||||
});
|
||||
const manual = row({
|
||||
id: "manual",
|
||||
source: "MANUAL",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
caloriesKcal: 300,
|
||||
});
|
||||
|
||||
const userPriorityJson = {
|
||||
steps: ["MANUAL", "APPLE_HEALTH", "WITHINGS"] as MeasurementSource[],
|
||||
};
|
||||
|
||||
const out = dedupeWorkoutBatch([apple, manual], userPriorityJson);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.id).toBe("manual");
|
||||
});
|
||||
|
||||
it("falls back to the canonical default ladder when userPriorityJson is null", () => {
|
||||
// Null = no user preference persisted. The helper must walk the
|
||||
// canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder so existing
|
||||
// callers that don't supply the blob keep their v1.4.42 behaviour.
|
||||
const apple = row({
|
||||
id: "apple",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const manual = row({
|
||||
id: "manual",
|
||||
source: "MANUAL",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
});
|
||||
|
||||
expect(
|
||||
dedupeWorkoutBatch([apple, manual], null).map((r) => r.id),
|
||||
).toEqual(["apple"]);
|
||||
expect(
|
||||
dedupeWorkoutBatch([apple, manual], undefined).map((r) => r.id),
|
||||
).toEqual(["apple"]);
|
||||
// Argument omitted entirely — same behaviour as null/undefined.
|
||||
expect(dedupeWorkoutBatch([apple, manual]).map((r) => r.id)).toEqual([
|
||||
"apple",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate caller rows", () => {
|
||||
// The picker is pure — the caller's array reference and row
|
||||
// identity survive the call so a future audit-overlay can
|
||||
|
||||
@@ -69,33 +69,35 @@
|
||||
* an array, returns an array. Testable in isolation; safely invoked
|
||||
* from a hot ingest path with no DB round-trip cost.
|
||||
*
|
||||
* Source-priority divergence vs the read-time picker
|
||||
* ──────────────────────────────────────────────────
|
||||
* Source-priority parity with the read-time picker
|
||||
* ────────────────────────────────────────────────
|
||||
*
|
||||
* The read-time picker resolves the per-user `sourcePriorityJson`
|
||||
* (from `User.sourcePriorityJson`) for every read so a user who
|
||||
* promoted MANUAL above APPLE_HEALTH in Settings → Sources sees
|
||||
* their Manual rows surface above the Apple Watch ones. The
|
||||
* write-time helper deliberately does NOT consult that preference:
|
||||
* it uses the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` constant
|
||||
* unconditionally, so a customized user's preferred row can still
|
||||
* be dropped at write-time before the read-time picker ever sees
|
||||
* it. Scope-narrow today — `DEFAULT_SOURCE_PRIORITY.steps` is the
|
||||
* Apple-first default and most paired users never override it —
|
||||
* but the moment a user customises, this helper silently
|
||||
* overrides them.
|
||||
* CLOSED in v1.4.43 (W9-WORKOUTS-PRIORITY). The read-time picker
|
||||
* resolves the per-user `sourcePriorityJson` (from
|
||||
* `User.sourcePriorityJson`) for every read so a user who promoted
|
||||
* MANUAL above APPLE_HEALTH in Settings → Sources sees their Manual
|
||||
* rows surface above the Apple Watch ones. This helper now mirrors
|
||||
* that contract — the optional `userPriorityJson` argument feeds
|
||||
* through `parseSourcePriority()` + `getSourceLadder(..., "steps")`,
|
||||
* the same chain the read-time picker walks, so a customised user
|
||||
* keeps their preferred source at BOTH write- and read-time.
|
||||
*
|
||||
* The architecturally correct fix (v1.4.43 candidate) is option
|
||||
* (b) from the v1.4.42 senior-dev QA M1: accept the small ingest-
|
||||
* path cost of one indexed `User.sourcePriorityJson` lookup and
|
||||
* thread the user's ladder into the picker. The batch route
|
||||
* already has the userId per row; the lookup is one
|
||||
* `prisma.user.findUnique({ where: { id }, select:
|
||||
* { sourcePriorityJson: true }})`. Until that lands, this
|
||||
* docstring is the load-bearing operator notice.
|
||||
* When the argument is omitted (or null/undefined) the helper falls
|
||||
* back to the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder so
|
||||
* callers that don't yet have the user blob handy stay on the
|
||||
* v1.4.42 behaviour. The batch route at
|
||||
* `src/app/api/workouts/batch/route.ts` does one indexed
|
||||
* `User.sourcePriorityJson` lookup at the start of the handler and
|
||||
* passes the blob through here; the cost is a single `findUnique`
|
||||
* per batch, well under the cost of letting a customised user's
|
||||
* preferred row drop at write-time.
|
||||
*/
|
||||
|
||||
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
|
||||
import {
|
||||
getSourceLadder,
|
||||
parseSourcePriority,
|
||||
} from "@/lib/validations/source-priority";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
@@ -159,10 +161,18 @@ export const WORKOUT_DEDUP_WINDOW_MS = 90 * 1000;
|
||||
* array — the picker still keeps that row when no ladder-listed
|
||||
* row competes for the slot (single-source group), but loses to
|
||||
* any ladder-listed competitor.
|
||||
*
|
||||
* The ladder is resolved per-call (from the user's
|
||||
* `sourcePriorityJson` or the canonical default), so the rank
|
||||
* function is parameterised on the ladder rather than reading a
|
||||
* module-level constant.
|
||||
*/
|
||||
function sourceRank(source: MeasurementSource): number {
|
||||
const idx = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf(source);
|
||||
return idx === -1 ? DEFAULT_WORKOUT_SOURCE_PRIORITY.length : idx;
|
||||
function sourceRank(
|
||||
source: MeasurementSource,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const idx = ladder.indexOf(source);
|
||||
return idx === -1 ? ladder.length : idx;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,8 +180,12 @@ function sourceRank(source: MeasurementSource): number {
|
||||
* Priority order: source rank → higher calories → earliest
|
||||
* createdAt → earliest input index.
|
||||
*/
|
||||
function compareCandidates<T extends WorkoutRow>(a: T, b: T): number {
|
||||
const sourceDelta = sourceRank(a.source) - sourceRank(b.source);
|
||||
function compareCandidates<T extends WorkoutRow>(
|
||||
a: T,
|
||||
b: T,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const sourceDelta = sourceRank(a.source, ladder) - sourceRank(b.source, ladder);
|
||||
if (sourceDelta !== 0) return sourceDelta;
|
||||
|
||||
// Tie-breaker 1 — higher caloriesKcal wins (more sensor data).
|
||||
@@ -216,9 +230,23 @@ function compareCandidates<T extends WorkoutRow>(a: T, b: T): number {
|
||||
*/
|
||||
export function dedupeWorkoutBatch<T extends WorkoutRow>(
|
||||
rows: readonly T[],
|
||||
userPriorityJson?: unknown,
|
||||
): T[] {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Resolve the source ladder from the user's persisted preference
|
||||
// when one was supplied. The read-time picker
|
||||
// (`pickCanonicalWorkoutRows`) keys workouts off the `steps` metric
|
||||
// ladder — the canonical-activity bucket — so both pickers consult
|
||||
// the same per-user configuration surface. When no preference is
|
||||
// supplied (legacy callers, tests that don't care, or a route that
|
||||
// hasn't been wired yet), fall back to the canonical default
|
||||
// ladder so behaviour matches the v1.4.42 baseline.
|
||||
const ladder: readonly MeasurementSource[] =
|
||||
userPriorityJson == null
|
||||
? DEFAULT_WORKOUT_SOURCE_PRIORITY
|
||||
: getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
|
||||
|
||||
// Stamp each input with its original index so the comparator
|
||||
// has a stable tie-breaker without mutating the caller's row.
|
||||
type Tagged = { row: T; origIndex: number };
|
||||
@@ -270,7 +298,7 @@ export function dedupeWorkoutBatch<T extends WorkoutRow>(
|
||||
index: entry.row.index ?? entry.origIndex,
|
||||
} as T,
|
||||
}));
|
||||
annotated.sort((a, b) => compareCandidates(a.row, b.row));
|
||||
annotated.sort((a, b) => compareCandidates(a.row, b.row, ladder));
|
||||
// Preserve the picker's input-order survival by remembering the
|
||||
// ORIGINAL row reference (not the annotated copy) so the caller
|
||||
// gets back exactly what it passed in.
|
||||
|
||||
Reference in new issue
Block a user